Instagram Profile Scraper for extracting exact follower counts, bios, post counts, verified status and profile pictures from Instagram.com. This repo has a free Instagram profile scraping script you can run right now, and an Instagram profile data API returning 18 fields of real structured JSON per account.
This repo reads public brand, business and creator accounts only, and collects no personal data. Every capture, example and screenshot on this page targets an organisation account (nasa, nike, natgeo, cocacola, starbucks, adidas). 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. More on that in Scope.
Last updated: 2026-07-20. Working against Instagram.com as of July 2026, and re-verified whenever Instagram changes their markup.
This repo covers one endpoint in depth. For Instagram posts and the other surfaces, see the Instagram Scraper repo.
Every JSON block on this page was captured from the live API on 2026-07-20. Long strings are truncated where marked, and each block says exactly what was cut; every field shown is verbatim. Full uncut samples are committed in instagram_profile_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from instagram_profile_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python instagram_profile_scraper_api_codes/profile.py nasaThose three lines return this, live from Instagram.com (8 of the 18 fields, verbatim; the full object is below):
{
"username": "nasa",
"full_name": "NASA",
"biography": "Making the seemingly impossible, possible. ✨",
"follower_count": 104305152,
"following_count": 91,
"posts_count": 4853,
"is_verified": true,
"is_private": false
}...for any public 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 endpoint that gets through it.
- Scope: public accounts only
- Free Instagram Profile Scraper
- Avoid getting blocked when scraping Instagram profiles
- Instagram Profile Scraper API reference
- Rank a list of brand accounts by follower count
- Measured latency
- License
Instagram is mostly personal data, and that shapes what this repo is willing to be.
What it does: read the public profile of a brand, business or creator account. Handle, display name, bio, exact follower and following counts, post count, verified and private flags, profile picture URL.
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.
The demand for the other thing is real and easy to see. Google's own autocomplete for this topic offers "scrape instagram private profile", "instagram api view private profile", "scrape email from instagram profile" and an "instagram profile phone number scraper". None of that is here, and none of it is coming. The endpoint reads what Instagram already publishes to anyone with a browser, for accounts that exist to be found.
This repo is built for Instagram profile data about organisations. 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 a render it trusts also ships the exact user object in an embedded JSON blob. When a request gets through, both can be read without a headless browser, JavaScript rendering, or a login. No key, no cost:
python free_scraper/instagram_profile_free_scraper.py nasaSource: free_scraper/instagram_profile_free_scraper.py. It reads the og:description sentence, un-abbreviates the counts, and separately looks for the exact follower_count, following_count and 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 that script against 6 public brand accounts on 2026-07-20, 8 fetches in total, including a cold re-run after a pause. All 8 returned zero fields, and the way it fails is the point. Every fetch is recorded in free_scraper_runs.json:
| What we measured | Value |
|---|---|
| HTTP status | 200 (not 403, not 429) |
| Response size | 597,646 to 598,119 characters across the 8 fetches: a full-size page, not an error stub. |
| Redirect | none (the final URL is still the profile URL) |
<title> |
Instagram (a real render says NASA (@nasa) • Instagram photos and videos) |
og:description |
absent |
follower_count / biography |
absent |
| Fields returned | 0 of 8 attempts returned any |
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 ~598 KB application shell with the profile data 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, inside the JavaScript bundle. 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. The script in this repo 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 account, the same machine, the same day, fetched two ways:
| 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 JavaScript 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 | Swapping in a browser User-Agent changed nothing: same status, same byte count, same page. 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 a script like the one above fails from CI even on the days it works from a laptop. | Clean IP supply is a recurring bill, not a one-off fix. |
| The counts are abbreviated at source | Instagram publishes 104M Followers and 32K Posts, not the integers. Read the meta tag and you inherit its rounding, which is the difference between 104,305,152 and "104M". |
Analytics built on rounded numbers that look exact. See the field notes. |
The wall is not a parsing problem, which is why a better parser does not get past it: the shell is what the server returned, and the fields are already missing before your code runs. That is the reason the API below exists.
The managed option, and the one this repo is built around. The Chocodata Instagram Profile Scraper API returns the profile as parsed JSON with the exact integer counts rather than the abbreviated ones, at a ~99% success rate against the wall, with no proxy management. Free for the first 1,000 requests.
Below is the Instagram Profile Scraper API reference to get you started: authentication, the parameters, error handling, concurrency, and the endpoint itself.
curl "https://api.chocodata.com/api/v1/instagram/profile?api_key=YOUR_KEY&username=nasa"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/instagram/profile",
params={"api_key": "YOUR_KEY", "username": "nasa"},
timeout=90,
)
print(r.json()["username"], r.json()["follower_count"])
# nasa 104305152After 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. No OAuth, no Instagram login, no session cookie: you never hand us or Instagram an account.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
username |
string | yes | - | The Instagram handle, without the leading @ (e.g. nasa). A leading @ and trailing slashes are stripped for you: @nasa/ and nasa return the same account, which is why you can feed it a copy-pasted handle without cleaning it up first. |
country |
string (ISO-2) | no | us |
Accepted for consistency with the rest of the API. Profile fields are not localised: us and de returned identical payloads for nasa apart from which CDN edge signed the profile_pic_url. |
add_html |
boolean | no | false |
Also return the upstream HTML in an extra html key alongside the parsed JSON, for debugging a parse. It adds about 692,000 characters to the response, so leave it off in production. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
The two bodies shown below are captured verbatim from real calls. Nothing here is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
username is missing or the wrong type. The body names the exact path that failed. |
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 or upgrade at chocodata.com. |
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 that does not exist. | no | Retry. This is the case the free scraper hit on every attempt we made. |
There is no 404. A username that does not exist and a username Instagram refused to render both come back as 502 extraction_failed, because from outside they look identical: an application 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 and billing errors nest under error.code in uppercase, while scrape-layer errors are flat with a lowercase error string. The 401 and 400 bodies are committed in full in errors.json, along with the status and error code recorded for a handle that does not exist.
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=nasa"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}A missing parameter, verbatim. It names 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. The 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 upstream takes longer than the median (see Measured latency).
Sizing: at Pro (50 concurrent) and the measured 1.4s median, one worker pool sustains roughly 50 / 1.4 = 35 requests per 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)
accounts = ["nasa", "nike", "natgeo", "cocacola", "starbucks"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for username, followers in pool.map(one, accounts):
print(username, followers)One public Instagram profile: identity, bio, exact follower and following counts, post count, and the verified and private flags.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | yes | - | The Instagram handle, without the leading @. |
curl "https://api.chocodata.com/api/v1/instagram/profile?api_key=YOUR_KEY&username=nasa"Real response. The object is complete, all 18 fields verbatim; only the profile_pic_url string is truncated where marked, because it is a 447-character signed CDN URL (full sample):
{
"id": "528817151",
"username": "nasa",
"full_name": "NASA",
"url": "https://www.instagram.com/nasa/",
"biography": "Making the seemingly impossible, possible. ✨",
"profile_pic_url": "https://scontent.cdninstagram.com/v/t51.2885-19/29090066_159271188110124_115206815...",
"external_url": null,
"category": null,
"is_verified": true,
"is_private": false,
"is_business": null,
"follower_count": 104305152,
"following_count": 91,
"posts_count": 4853,
"posts": null,
"og_description": "104M Followers, 95 Following, 4,853 Posts - See Instagram photos and videos from NASA (@nasa)",
"source": "instagram",
"data_source": "embedded"
}follower_count is the field most people come for, and it is exact: 104,305,152, not the "104M" Instagram prints on the page. data_source: "embedded" is the field that tells you so. It reads embedded when the exact integers came from the embedded user object, and opengraph when they had to fall back to the meta tag. If it ever reads opengraph, your counts silently became approximations and you should treat them that way.
Running it:
Field notes:
- The exact count moves, and that is the point. Successive calls for
nasaon 2026-07-20 returned 104,305,134, then 104,305,144, then 104,305,152, then 104,305,153. Two of those are committed here:profile.jsonandprofile_at_handle.json, captured minutes apart, disagree by 8 followers. Nothing is broken, and it is worth knowing before you diff two of your own pulls. It is also why@nikereads 291,755,289 in the table image near the top of this page and 291,755,299 in the benchmark run further down: two observations, minutes apart, both real. 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:@nasaprints4,853 Postsand you get4853, 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 on
following_count, and we report both.og_descriptionsays95 Followingwhilefollowing_countsays91. We measured the same disagreement on 6 of 6 accounts (nike 267 vs 264, natgeo 195 vs 194, cocacola 358 vs 340, starbucks 5,409 vs 5,257, adidas 1,071 vs 1,054). Instagram's meta tag and its embedded object simply do not match, so the rawog_descriptionstring stays in the payload and you get each verbatim rather than a reconciled number neither surface returned. postsis alwaysnull. The post grid is not served to a logged-out reader, so this endpoint gives you the account, not its timeline.category,external_urlandis_businesswere null on all 6 accounts we sampled. Instagram does not ship the business contact block to a logged-out reader, so these come back null rather than invented. Code against the nulls.og_descriptiondoes not always carry a display name. Five of the six accounts put the name before the handle (@natgeorenders... from National Geographic (@natgeo)), but@adidasrenders... from @adidaswith no name at all.full_namestill comes back asadidas, because it is read from the embedded object rather than parsed out of that sentence. A free scraper that only reads the meta tag loses the name on accounts like that one.
Runnable: instagram_profile_scraper_api_codes/profile.py
Checking a set of accounts and their audience sizes in one pass is the main reason to scrape Instagram profiles, so that use case is in the repo end to end rather than as a snippet. follower_benchmark.py takes a list of handles, fans out with a thread pool sized to the free plan's concurrency, appends every observation to a CSV dataset, and prints the accounts ranked:
python instagram_profile_scraper_api_codes/follower_benchmark.py nasa nike natgeo cocacola starbucksThat is a real run. One API request per account per pass, so the arithmetic is simple: a 25-account list checked daily is 25 requests a day, and the 1,000 free requests cover about 40 days of that, one-time.
A 502 skips that one account and the batch continues, because 502 is retryable and one flaky account should not kill a run. The CSV is appended rather than overwritten, so running it on a cron gives you a follower time series for every account in the list without any further work.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20 across 5 public accounts. This includes the upstream fetch, the anti-bot handling, and the parse:
| Endpoint | Median | Range | n |
|---|---|---|---|
/instagram/profile |
1.4s | 1.2 to 2.7s | 5 |
All 5 returned 200. Read the range, not just the median: the slowest call took roughly twice the fastest, and a request that has to be re-attempted upstream lands at that end of the spread. Absorbing those attempts, silently, is a good part of what you are paying for. Small sample from one laptop on one afternoon, so size your timeouts off the tail rather than the median, and reproduce it with the scripts in this repo.
MIT. See LICENSE.






