Instagram Scraper for extracting follower counts, bios, captions, hashtags, engagement and post images from Instagram.com. This repo has a free Instagram web scraping script you can run right now, and an Instagram data API with 2 endpoints returning real structured JSON.
This repo reads public business accounts only, and collects no personal data. Every capture, example and screenshot on this page targets a brand account (nike, natgeo, cocacola). There is nothing here that collects contact details, follower lists, comment authors or tagged users, and there is no tooling here for profiling a private individual. That is a deliberate scope decision, and it is why this repo documents 2 endpoints instead of padding the list. More on that in Scope.
Last updated: 2026-07-17. Working against Instagram.com as of July 2026, and re-verified whenever Instagram changes their markup.
Every JSON block on this page was captured from the live API on 2026-07-16. Long arrays are trimmed and long strings are truncated where marked (each block says exactly what was cut); every field shown is verbatim. Full uncut samples are committed in instagram_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from instagram_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python instagram_scraper_api_codes/profile.py nikeThose three lines return this, live from Instagram.com:
{
"username": "nike",
"full_name": "Nike",
"biography": "Just Do It.",
"follower_count": 291779880,
"following_count": 264,
"posts_count": 1663,
"is_verified": true,
"category": null
}...for any public business account you point it at, with 18 fields each:
That is the whole point of this repo. The rest of this page is the free path, the wall it runs into, and the two endpoints that get through it.
- Scope: public business accounts only
- Free Instagram Scraper
- Avoid getting blocked when scraping Instagram
- Instagram's official API
- Instagram Scraper API reference
- Track competitor follower counts over time
- Measured latency
Instagram is mostly personal data, and that shapes what this repo is willing to be.
What it does: read the public profile of a business account, and read a public post published by that account. Follower counts, bio, verified status, captions, hashtags, engagement counts, post images.
What it does not do: anything that turns a person into a row. No contact details, no follower lists, no comment authors, no tagged users, no individual profiling.
You will also notice there is no hashtag-feed reader here, and that one is worth explaining because it is the interesting case. A hashtag feed looks like brand data and is not: we sampled #nike and got 6 posts by 6 private individuals, and #natgeo returned 6 more, with neither brand appearing in its own tag. A public hashtag feed is, in practice, a list of members of the public and their photographs. Shipping a convenient way to harvest that at scale is not a thing this repo is going to do, so this repo is a smaller one for it: 2 endpoints, both pointed at businesses.
This repo is built for Instagram data about brands. Data about people is out of scope by design.
Instagram server-renders a public profile's headline counts into an og:description meta tag, and sometimes ships the full user object in an embedded JSON blob. When a request gets through, you can extract that without a headless browser, JavaScript rendering, or a login. No key, no cost:
python free_scraper/instagram_free_scraper.py nikeSource: free_scraper/instagram_free_scraper.py. It reads the og:description sentence ("292M Followers, 267 Following, 1,663 Posts - See Instagram photos and videos from Nike (@nike)"), un-abbreviates the counts, and also looks for the exact follower_count / biography fields in the embedded blob.
After running the command, your terminal should look something like this:
That is a real run: HTTP 200, a full-size page, and no profile data in it. The next section is why.
We ran exactly that script against instagram.com/nike/ while writing this README, 4 times. All 4 returned no data, and the way it fails is the point:
| What we measured | Value |
|---|---|
| HTTP status | 200 (not 403, not 429) |
| Response size | 596,425 characters decoded (~110 KB on the wire): a full-size page, not an error stub. It drifts by a few hundred per fetch, so the screenshot above and this table differ slightly. Both are real runs. |
| Redirect | none (final URL is still /nike/) |
<title> |
Instagram (the real render says Nike (@nike) • Instagram photos and videos) |
og:description |
absent |
follower_count / biography |
absent |
| Fields returned | 0 |
Note what this is not. It is not a redirect to a login page, not a captcha, and not an HTTP 403. Instagram returned a complete-looking 596 KB application shell with the profile data simply left out of it. Nothing announces a failure.
This is worth dwelling on, because it is the trap. The words captcha (13 occurrences), login (22) and checkpoint (4) all appear in that response body, buried in the JavaScript bundle starting around byte 164,865. A block detector that greps the whole body for "captcha" will report BLOCKED on a page that has no captcha on it, and would report it on a successful page too, because the same bundle ships either way. Our script therefore parses first and only checks markers in the <title> and the first 4 KB, which is why it reports "no data" rather than crying wolf about a block it cannot prove. If you write your own, do the same: parse first, and only call it a block when the data is genuinely absent.
Here is the same URL, the same day, fetched two ways:
Both are HTTP 200. One is 596,425 characters containing zero profile fields; the other is 611,992 characters of HTML containing all of them. The page size is nearly identical. The difference is not the parser, and it is the most important thing to understand about scraping Instagram:
| What bites you | Why | What it costs you |
|---|---|---|
| HTTP 200 is not success | Instagram serves the data-less shell with a 200, no redirect and no error. A naive scraper parses it, finds nothing, and logs "0 followers" rather than "blocked". |
The expensive failure is not a crash. It is three weeks of empty rows that looked like "no data". |
| The scary strings are always there | captcha and login are in the JS bundle on good pages too. Substring-matching the body makes your detector fire on success. |
You debug a block that never happened, or you trust a detector that cannot fail. |
| It is not a header problem | Instagram fingerprints the connection itself, not just the User-Agent. No combination of headers makes a stock HTTP client look like a browser. |
You cannot patch your way out in code. It is an infrastructure problem. |
| Datacenter IPs are pre-blocked | AWS, GCP and Azure ranges are well known. This is why the script above fails from CI even when it works from your laptop. | Clean IP supply is a recurring bill, not a one-off fix. |
| The counts are abbreviated at source | Instagram publishes 32K Posts, not 32,000. If you read the meta tag, you inherit its rounding (see the profile endpoint). |
Analytics built on rounded numbers that look exact. |
Every "free Instagram scraper" on GitHub hits the same wall this one does: the ones that fetch Instagram directly get the empty shell, and several of the ones that look like they work are wrappers that need a paid vendor's API key anyway. The wall is not a code problem, and it is the reason the API below exists.
The managed option, and the one this repo is built around. The Chocodata Instagram Scraper API has two endpoints for Instagram data extraction at scale (public business profiles and public posts), a ~99% success rate against the wall, and no proxy management. Free for the first 1,000 requests.
Meta publishes an official path to some of this data. It is worth knowing what it covers and what it asks of you before you pick a route.
From the Instagram Platform overview (read 2026-07-16; the page carries no published revision date):
"A permission only allows access to data created by the app user who granted the permission. There are a few endpoints that allow apps to access data not created by the app user, but the accessible data is limited and public."
One of those endpoints is business_discovery. It returns followers_count and media_count for a public professional account you have no relationship with, and the target does not need to install your app.
What it asks for is your own setup. From the business_discovery reference and the overview (both read 2026-07-16):
- "A Facebook User access token with the following permissions:
instagram_basic,instagram_manage_insights,pages_read_engagement", plusads_managementorads_readif the Page role came via Business Manager. - It lives in the Facebook Login configuration, which per the overview serves "Instagram professional accounts that are linked to a Facebook Page". So this path needs your own professional account and a linked Page. (The Page link is not required if you use Instagram Login, which is a distinction a lot of write-ups get wrong, but Instagram Login is not where
business_discoverylives.) - "Your app must complete Meta App Review to be granted Advanced Access", and "Advanced Access is the access level required if your app serves Instagram professional accounts that you don't own or manage". Meta does not publish an approval time.
- "Data about age-gated Instagram Business IG Users will not be returned."
- The returnable data is a fixed public subset. Meta's own words for it are "limited and public": you get the fields Meta chose, not the page.
That is the trade. Clear Meta's gate once and read a curated public subset, or call an endpoint and get the parsed page.
Below is the Instagram Scraper API reference to get you started. Two endpoints, each with its parameters, a call you can paste, and the JSON that comes back.
curl "https://api.chocodata.com/api/v1/instagram/profile?api_key=YOUR_KEY&username=nike"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/instagram/profile",
params={"api_key": "YOUR_KEY", "username": "nike"},
timeout=90,
)
print(r.json()["username"], r.json()["follower_count"])
# nike 291779880After running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model.
Accepted by every endpoint below:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
country |
string (ISO-2) | no | us |
Egress location. |
add_html |
boolean | no | false |
Also return the raw upstream HTML alongside the parsed JSON (debugging). |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
Real captured error bodies, not paraphrases. Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
A required param is missing or the wrong type. Body lists the exact issue and path. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up ($0.90 / 1,000 requests, never expires) or upgrade. |
429 |
RATE_LIMITED |
Over your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
extraction_failed |
Instagram served the data-less shell for this request. Also what you get for a handle or shortcode that does not exist. | no | Retry. This is the case the free scraper hits permanently. |
There is no 404. A username that does not exist and a username that Instagram refused to render both come back as 502 extraction_failed, because from outside they look identical: an app shell with no user in it. If you need to tell "typo" from "try again", retry two or three times and treat persistent failure as "probably does not exist".
Two response shapes exist: auth/billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string. Both are shown below.
The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/instagram/profile?api_key=totally_invalid_key_123&username=nike"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}A missing parameter, verbatim. It tells you the exact path that failed:
{
"error": "invalid_params",
"issues": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["username"],
"message": "Required"
}
]
}There is no per-minute request cap. The limit is concurrency: how many requests you may have in flight at once.
| Plan | Concurrent requests |
|---|---|
| Free | 10 |
| Vibe | 30 |
| Pro | 50 |
| Custom | 100 to 500+ |
Exceed it and you get 429, not a queue. Every endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 because a request that has to be re-attempted takes longer than the median (see Measured latency).
Sizing: at Pro (50 concurrent) and a ~1.3s median profile call, one worker pool sustains roughly 50 / 1.3 = 38 requests/second. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(username):
r = requests.get("https://api.chocodata.com/api/v1/instagram/profile",
params={"api_key": KEY, "username": username}, timeout=90)
return username, (r.json().get("follower_count") if r.ok else None)
brands = ["nike", "natgeo", "cocacola", "adidas", "starbucks"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for username, followers in pool.map(one, brands):
print(username, followers)One public Instagram business profile: identity, bio, exact follower and following counts, post count, and the verified/private flags.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | yes | - | The Instagram handle, without the leading @ (e.g. nike). A leading @ and trailing slashes are stripped for you. |
curl "https://api.chocodata.com/api/v1/instagram/profile?api_key=YOUR_KEY&username=nike"Real response. The object is complete, all 18 fields verbatim; only the profile_pic_url string is truncated where marked, because it is a 600-character CDN URL (full sample):
{
"id": "13460080",
"username": "nike",
"full_name": "Nike",
"url": "https://www.instagram.com/nike/",
"biography": "Just Do It.",
"profile_pic_url": "https://scontent-iad3-1.cdninstagram.com/v/t51.82787-19/551608484_185671...",
"external_url": null,
"category": null,
"is_verified": true,
"is_private": false,
"is_business": null,
"follower_count": 291779880,
"following_count": 264,
"posts_count": 1663,
"posts": null,
"og_description": "292M Followers, 267 Following, 1,663 Posts - See Instagram photos and videos from Nike (@nike)",
"source": "instagram",
"data_source": "embedded"
}follower_count is the field most people come for, and it is exact: 291,779,880, not the "292M" that Instagram prints on the page. It also moves in real time: this sample says 291,779,880 while accounts.json, captured a few minutes later in the same session, says 291,779,882. Two committed files, two different numbers. That resolution is what you are buying.
The nulls are shown on purpose. category, external_url and is_business are genuinely null for this account: Instagram does not ship the business contact block to a logged-out reader, so we return null rather than invent it. data_source: "embedded" tells you the exact counts came from the embedded user object rather than the meta tag; if that ever reads opengraph, your counts silently became approximations and you should treat them as such.
Field notes:
postsis alwaysnull. The post grid is not served to a logged-out reader, so this endpoint gives you the account, not its timeline.posts_countis only as precise as Instagram's own meta tag.follower_countandfollowing_countcome from the embedded object and are exact.posts_counthas no embedded source, so it inherits whatever the meta tag says:@nikeprints1,663 Postsand you get1663, but@natgeoprints32K Postsand you get exactly32000, which is "32K" parsed, not a count of 32,000 posts. Do not build post-volume analytics on it for large accounts.- The two surfaces disagree, and we report both.
og_descriptionsays267 Followingwhilefollowing_countsays264. We measured the same disagreement on all three brands we tested (natgeo: 195 vs 193, cocacola: 357 vs 339). Instagram's meta tag and its embedded object simply do not match; we hand you each verbatim rather than reconciling them for you, which is why the rawog_descriptionstring stays in the payload.
Runnable: instagram_scraper_api_codes/profile.py
One public Instagram post, reel or carousel by its shortcode: caption, hashtags, image URLs, dimensions, comment count and author.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
shortcode |
string | one of shortcode/url/id |
- | The /p/<code>/ permalink slug (e.g. CfwXgNprcSA). |
url |
string (URL) | one of shortcode/url/id |
- | Full post, reel or tv permalink. Normalised to a shortcode for you. |
id |
string | one of shortcode/url/id |
- | Back-compat alias for shortcode. |
curl "https://api.chocodata.com/api/v1/instagram/post?api_key=YOUR_KEY&shortcode=CfwXgNprcSA"Real response from a post published by @nike on Nike's own account. images is cut to 2 of 47 and the caption/title/URL strings are truncated where marked; the object itself is complete, all 27 fields verbatim (full sample):
{
"id": "CfwXgNprcSA",
"shortcode": "CfwXgNprcSA",
"media_id": "2877903530640655488",
"url": "https://www.instagram.com/p/CfwXgNprcSA/",
"embed_url": "https://www.instagram.com/p/CfwXgNprcSA/embed/captioned/",
"media_type": "carousel",
"is_video": false,
"product_type": null,
"title": "Meet the bold new collection inspired by a 50-year tradition of breaking tradition. This is Circa 72. \n\nEach piece reimagines the rebellious streak th...",
"author": "nike",
"author_name": null,
"author_id": "13460080",
"author_url": "https://www.instagram.com/nike/",
"images": [
"https://scontent-atl3-3.cdninstagram.com/v/t51.71878-15/62264272...",
"https://scontent-atl3-3.cdninstagram.com/v/t51.71878-15/62264272..."
],
"thumbnail": "https://scontent-atl3-3.cdninstagram.com/v/t51.71878-15/62264272...",
"video_url": null,
"dimensions": { "width": 720, "height": 900 },
"caption": "Meet the bold new collection inspired by a 50-year tradition of breaking tradition. This is Circa 72. \n\nEach piece reimagines the rebellious streak th...",
"hashtags": [],
"mentions": [],
"likes": null,
"comments": 814,
"plays": null,
"taken_at": null,
"location": null,
"source": "instagram",
"data_source": "embedded"
}caption is the field most people come for: it is the copy the brand actually shipped, and hashtags and mentions are parsed out of it for you (both are empty on this particular post because Nike used neither). author_id here is 13460080, the same id the profile endpoint returns for @nike, which is the join key between the two endpoints.
Running it:
Field notes:
likesis alwaysnull. Instagram strips the like count from the surface this endpoint reads.comments(814 here) does come back.len(images)is not the picture count, and it is not stable. This is a 4-image carousel, butimagescarries 47 entries in the committed sample: the same 4 pictures repeated at every CDN size variant. Four consecutive calls returned 47, 49, 49 and 47 entries, because Instagram hands back a varying number of variants. De-duplicate on the URL path (strip the query string) to get the real pictures; never uselen(images).post.pyships aunique_images()helper that does exactly this, which is why it prints "4 images (47 CDN variants)" rather than the meaningless 47.taken_atisnullhere, as areauthor_name,playsandproduct_type. That is the null-handling you have to code against.
Runnable: instagram_scraper_api_codes/post.py
Tracking your own and your competitors' account performance is the main reason to scrape Instagram, so that use case is in the repo end to end rather than as a snippet. account_tracker.py polls a set of public brand accounts, stores every observation as a local dataset in SQLite (export it to CSV with one sqlite3 command), and prints what moved since the last run:
python instagram_scraper_api_codes/account_tracker.py nike natgeo cocacolaThat is a real second run, a few minutes after the first. The deltas are small because the interval was minutes rather than days, and they are real: follower_count is exact and it moves constantly on accounts this size. Run it on a daily cron and the same diff lines become the follower trend for your brand and its competitors.
One API call per account per run. 1,000 free requests covers roughly 11 months of daily checks on three accounts (333 runs), one-time.
The script skips an account that returns 502 for a run rather than dying, because 502 is retryable and one flaky account should not kill the batch. That is not hypothetical: @cocacola failed on its first call while we were writing this, and returned 200 on each of the next three.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-16. This includes the upstream fetch, the anti-bot handling, and the parse:
| Endpoint | Median | Range | n |
|---|---|---|---|
/instagram/profile |
1.3s | 1.3 to 1.9s | 5 |
/instagram/post |
2.1s | 1.8 to 2.3s | 5 |
All 10 of those requests returned 200, from one laptop on one afternoon at n=5 per endpoint. The wider session that produced this README also saw a post call at 4.9s, and a profile call that needed one retry before it succeeded. Budget for retries, not for the median. Reproduce it with the scripts in this repo.
MIT. See LICENSE.






