Skip to content

ChocoData-com/instagram-profile-scraper

Repository files navigation

Instagram Profile Scraper

Instagram Profile Scraper

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 nasa

Those 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:

Retrieved Instagram profile data

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.


Contents


Scope: public accounts only

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.


Free Instagram Profile Scraper

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 nasa

Source: 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:

Free Instagram profile scraper getting a 200 and no data

That is a real run: HTTP 200, a full-size page, and no profile data in it. The next section is why.

Avoid getting blocked when scraping Instagram profiles

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:

Free scraper getting nothing vs the API parsing 18 fields

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.


Using the Chocodata Instagram Profile Scraper API

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.


Instagram Profile Scraper API reference

Below is the Instagram Profile Scraper API reference to get you started: authentication, the parameters, error handling, concurrency, and the endpoint itself.

Quickstart

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 104305152

After running the command, your terminal should look something like this:

Running the Instagram Profile Scraper API

Get a key at chocodata.com (1,000 requests, one-time, no card).

Authentication

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.

Parameters

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).

Errors

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:

Instagram Profile Scraper API error handling

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"
    }
  ]
}

Rate limits and concurrency

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)

Profile: follower counts, bios and verified status

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:

Instagram Profile Scraper API output

Field notes:

  1. The exact count moves, and that is the point. Successive calls for nasa on 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.json and profile_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 @nike reads 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.
  2. posts_count is only as precise as Instagram's own meta tag. follower_count and following_count come from the embedded object and are exact. posts_count has no embedded source, so it inherits whatever the meta tag says: @nasa prints 4,853 Posts and you get 4853, but @natgeo prints 32K Posts and you get exactly 32000, which is "32K" parsed, not a count of 32,000 posts. Do not build post-volume analytics on it for large accounts.
  3. The two surfaces disagree on following_count, and we report both. og_description says 95 Following while following_count says 91. 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 raw og_description string stays in the payload and you get each verbatim rather than a reconciled number neither surface returned.
  4. posts is always null. The post grid is not served to a logged-out reader, so this endpoint gives you the account, not its timeline.
  5. category, external_url and is_business were 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.
  6. og_description does not always carry a display name. Five of the six accounts put the name before the handle (@natgeo renders ... from National Geographic (@natgeo)), but @adidas renders ... from @adidas with no name at all. full_name still comes back as adidas, 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


Rank a list of brand accounts by follower count

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 starbucks

Instagram brand account benchmark output

That 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.


Measured latency

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.


License

MIT. See LICENSE.