Skip to content

ChocoData-com/instagram-scraper

Repository files navigation

Instagram Scraper

Instagram Scraper

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 nike

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

Retrieved Instagram brand account 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 two endpoints that get through it.


Contents


Scope: public business 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 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.


Free Instagram Scraper

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 nike

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

Free Instagram 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

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:

Free scraper getting nothing vs Chocodata API parsing 18 fields

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.


Using the Chocodata Instagram Scraper API

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.


Instagram's official API

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", plus ads_management or ads_read if 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_discovery lives.)
  • "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.


Instagram Scraper API reference

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.

Quickstart

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 291779880

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

Running the Instagram Scraper API profile endpoint

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.

Global parameters

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

Errors

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:

Instagram Scraper API error handling

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

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

1. Profile: follower counts, bios and verified status

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:

  1. 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.
  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: @nike prints 1,663 Posts and you get 1663, 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, and we report both. og_description says 267 Following while following_count says 264. 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 raw og_description string stays in the payload.

Runnable: instagram_scraper_api_codes/profile.py


2. Post: captions, hashtags, images and engagement

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:

Instagram post endpoint output

Field notes:

  1. likes is always null. Instagram strips the like count from the surface this endpoint reads. comments (814 here) does come back.
  2. len(images) is not the picture count, and it is not stable. This is a 4-image carousel, but images carries 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 use len(images). post.py ships a unique_images() helper that does exactly this, which is why it prints "4 images (47 CDN variants)" rather than the meaningless 47.
  3. taken_at is null here, as are author_name, plays and product_type. That is the null-handling you have to code against.

Runnable: instagram_scraper_api_codes/post.py


Track competitor follower counts over time

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 cocacola

Instagram brand account tracker showing real follower deltas

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


Measured latency

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.


License

MIT. See LICENSE.