Guide · Python

Export Instagram comments and replies to CSV with Python

Give the script a post or reel link: it pages through every comment, adds the replies, checks the count against Instagram's total and writes one CSV.

ScrapingBot 10 min read
Endpoints
/comments/media_comments_by_id, /comments/replies
Cost
5 credits per page of 15
Language
Python 3.9+
Needs
requests
On this page

Comments are where a post's audience talks back: questions, objections, requests and, on posts that ask for it, a keyword typed on cue ("comment PUCK. I'll DM you the recipe"). In the app you read them a few at a time. As a CSV you can count them, sort them, search them and hand them to whatever analysis you like.

This guide exports the comments on a public Instagram post or reel with Python, adds the replies, tells you how complete the export is, and then finds everyone who commented a keyword. Every response and output below is real, collected through ScrapingBot on November 3, 2026, from three coffee posts: a Blue Bottle Coffee reel (37 comments), a Lance Hedrick reel (164) and a James Hoffmann post that asked people to comment "PUCK" (645).

Before you start

  • A ScrapingBot API key. Create a free account for 100 credits: 20 pages, up to 300 comments. No card needed.
  • Python 3.9 or newer and pip install requests.
  • The link to a public post or reel, such as https://www.instagram.com/reel/DdWpu4yMpxk/.

Meta's API reads comments on your own posts only

Meta's Instagram API has a comments edge, but it needs an access token from the account that created the post and the instagram_manage_comments permission. Its business_discovery edge reads other business and creator accounts' posts with a comments_count, not the comments (checked November 2026). For a competitor's post, a creator you're vetting or any personal account, there is no official endpoint; /comments/media_comments_by_id returns what Instagram shows to a visitor, as JSON.

For your own posts Meta's API is the better tool for one job: it can send a private reply, one message per comment within 7 days of it. The keyword section below builds the list of people to send it to.

One page of comments

Every Instagram call is a POST to https://scrapingbot.io/api/v1/instagram. Send the post in code_or_id_or_url: its link, its shortcode (the DdWpu4yMpxk in the link) or its numeric media ID.

curl -X POST https://scrapingbot.io/api/v1/instagram \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "/comments/media_comments_by_id", "params": {"code_or_id_or_url": "https://www.instagram.com/reel/DdWpu4yMpxk/"}}'

The first page for Blue Bottle Coffee's jazz reel, trimmed to one of its 15 comments. Blue Note New York wrote it twelve minutes after the reel went up:

{
  "success": true,
  "data": {
    "data": {
      "items": [
        {
          "id": "18406898806091269",
          "text": "Let’s collaborate!!!!!! 💙",
          "created_at": 1789574719,
          "comment_like_count": 31,
          "child_comment_count": 1,
          "is_pinned": false,
          "mentions": [],
          "hashtags": [],
          "user": {
            "id": "295813138",
            "username": "bluenotenyc",
            "full_name": "Blue Note New York",
            "is_private": false,
            "is_verified": true,
            "profile_pic_url": "https://scontent-iad3-1.cdninstagram.com/v/t51.2885-19/…",
            …
          },
          …
        },
        …
      ],
      "count": 15,
      "total": 37,
      "additional_data": {
        "caption": { "user_id": "354032059", "text": "For us, coffee and jazz have always shared a rhythm…", … },
        …
      }
    },
    "pagination_token": "Ek1JBAgTPhtefVFDSwpcE2keVR9KQEQfQytCNUpXAA0-PzpCG24CCxcrCRVI…"
  },
  "duration": "11.51",
  "statusCode": 200,
  "creditsUsed": 5
}
FieldWhat it is
items[].idThe comment's ID. /comments/replies takes it as comment_id.
text, created_atWhat was written, and when, as a Unix timestamp in seconds (UTC).
comment_like_countLikes on the comment.
child_comment_countHow many replies sit under it. Only these comments need a replies call.
userThe commenter: id (stable across username changes), username, full_name, is_private, is_verified.
mentions, hashtagsThe @names and tags in the text, as lists.
count, totalComments on this page (up to 15), and Instagram's count for the whole post, replies included.
additional_data.captionThe post's caption, with the creator's user_id: how you tell the creator's own comments apart.
pagination_tokenSend it back to get the next page. It is null on the last one.

How much of a post comes back

We exported all three posts to the end:

PostInstagram's totalWhat came back
Blue Bottle Coffee reel, September 163732 comments in 3 pages and an empty fourth; 5 replies under them
Lance Hedrick reel, August 2716494 comments in 7 pages; 70 replies under them, 64 fetched in 15 calls
James Hoffmann post, July 24645630 comments in 42 pages; 4 replies under them; 11 not listed
  • total counts replies. 32 comments plus 5 replies is Blue Bottle's 37; 94 plus 70 is Lance Hedrick's 164. On the Hoffmann post the sum is 634, so Instagram counted 11 comments it didn't list. Check comments plus child_comment_count against total rather than expecting the rows to match it.
  • Pages come in Instagram's ranking, not by time. The Hoffmann post's first page held comments written between July 24 and October 25. Sort by created_at yourself.
  • No comment showed up twice in the 53 pages, but the script de-duplicates on id anyway.
  • The last token can lead to an empty page. Blue Bottle's third page held 2 comments and still carried a token; the fourth came back empty with no token. It is a successful call, 5 credits, so stop on the missing token rather than on a short page: Lance Hedrick's second page held 13 and five more pages followed.

Replies

/comments/replies takes the post and the parent comment's id, and returns the replies in data.data.items, each with a parent_comment_id:

curl -X POST https://scrapingbot.io/api/v1/instagram \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "/comments/replies", "params": {"code_or_id_or_url": "DcjiDP4tTIL", "comment_id": "18117688043313791"}}'

One call returns the first page of a thread. On Lance Hedrick's reel, 14 of the 15 threads (1 to 8 replies each) came back whole; the one with 15 replies returned 9. A 53-reply thread under a NASA post also returned 9. The response carries a pagination_token, but /comments/replies takes no cursor, so the rest of a long thread isn't available. Of the 64 replies we did get, 16 were Lance Hedrick answering his viewers: the script marks those in a by_creator column.

The script

Save this as ig_comments.py. It takes a post link, shortcode or media ID, pages through the comments up to --pages pages, fetches the replies with --replies (only for comments that have some), and writes a new CSV each run, oldest comment first, each followed by its replies.

import csv
import re
import sys
import time
from datetime import datetime, timezone

import requests

API = "https://scrapingbot.io/api/v1/instagram"
HEADERS = {"x-api-key": "YOUR_API_KEY"}
FIELDS = ["comment_id", "parent_id", "created_at", "username", "user_id", "is_private",
          "is_verified", "by_creator", "likes", "replies", "mentions", "text"]
DONE = "end of comments"


class Stop(Exception):
    """Why paging stopped early. Whatever was collected is still saved."""


def call(endpoint, params, tries=4):
    """One Instagram call: 5 credits on success. Failed calls are refunded."""
    error = None
    for attempt in range(tries):
        try:
            res = requests.post(API, headers=HEADERS, timeout=60,
                                json={"endpoint": endpoint, "params": params})
            body = res.json()
        except (requests.RequestException, ValueError) as err:  # no answer, or not JSON
            error = err
        else:
            if body.get("success"):
                return body["data"]
            if res.status_code in (400, 401, 402, 404):  # retrying will not help
                raise Stop(f"{res.status_code} {body.get('error')}")
            error = body.get("error")
        time.sleep(2 ** attempt)  # 408, 429, 5xx or a network error: wait and try again
    raise Stop(f"kept failing: {error}")


def shortcode(post):
    """The code in a post or reel link (instagram.com/p/<code>/); anything else as given."""
    m = re.search(r"instagram\.com/(?:[\w.]+/)?(?:p|reels?|tv)/([\w-]+)", post)
    return m.group(1) if m else post.strip().strip("/")


def export(post, max_pages=20, replies=False):
    """Top-level comments, 15 a page, then (optionally) the replies under them."""
    params = {"code_or_id_or_url": post}  # a shortcode or a media ID (a link works too)
    comments, threads, total, creator = {}, {}, None, None
    pages, calls, end, failed = 0, 0, "reached --pages; raise it for more", False
    while pages < max_pages:
        try:
            data = call("/comments/media_comments_by_id", params)
        except Stop as reason:
            end, failed = str(reason), True
            break
        pages += 1
        page = data.get("data") or {}
        total = page.get("total", total)
        caption = (page.get("additional_data") or {}).get("caption") or {}
        creator = creator or caption.get("user_id")
        for c in page.get("items", []):
            comments.setdefault(c["id"], c)  # keep the first copy if a page repeats one
        if not data.get("pagination_token"):
            end = DONE
            break
        params = {"code_or_id_or_url": post, "pagination_token": data["pagination_token"]}

    if replies and not failed:
        for c in comments.values():
            if not c.get("child_comment_count"):
                continue  # most comments have no replies: skip the call
            try:
                data = call("/comments/replies", {"code_or_id_or_url": post, "comment_id": c["id"]})
            except Stop as reason:
                end = f"replies: {reason}"
                break
            calls += 1
            threads[c["id"]] = (data.get("data") or {}).get("items", [])
    return comments, threads, total, creator, pages, calls, end


def cell(text):
    """Text a spreadsheet would run as a formula (=, +, -, @) gets a leading apostrophe."""
    return "'" + text if text[:1] in ("=", "+", "-", "@", "\t", "\r") else text


def row(c, creator, parent_id=""):
    user = c.get("user") or {}
    return {
        "comment_id": c["id"], "parent_id": parent_id,
        "created_at": datetime.fromtimestamp(c["created_at"], timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
        "username": user.get("username", ""), "user_id": user.get("id", ""),
        "is_private": user.get("is_private"), "is_verified": user.get("is_verified"),
        "by_creator": user.get("id") == creator, "likes": c.get("comment_like_count", 0),
        "replies": c.get("child_comment_count", 0),
        "mentions": cell(" ".join(c.get("mentions") or [])), "text": cell(c.get("text", "")),
    }


if __name__ == "__main__":
    post = sys.argv[1]
    max_pages = int(sys.argv[sys.argv.index("--pages") + 1]) if "--pages" in sys.argv else 20
    code = shortcode(post)  # links from the app carry ?igsh=…; the shortcode alone is enough
    comments, threads, total, creator, pages, calls, end = export(
        code, max_pages, replies="--replies" in sys.argv)
    if pages == 0:
        sys.exit(f"{post}: {end}")

    oldest_first = lambda items: sorted(items, key=lambda c: c["created_at"])
    rows = []  # comments in the order they were written, each followed by its replies
    for c in oldest_first(comments.values()):
        rows.append(row(c, creator))
        rows += [row(r, creator, c["id"]) for r in oldest_first(threads.get(c["id"], []))]

    path = f"{code}_comments_{datetime.now():%Y%m%d-%H%M%S}.csv"
    with open(path, "w", newline="", encoding="utf-8-sig") as f:  # utf-8-sig: Excel shows emoji
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows(rows)

    got = sum(len(t) for t in threads.values())
    under = sum(c.get("child_comment_count", 0) for c in comments.values())
    print(f"{code}: {len(comments):,} comments" + (f" and {got:,} replies" if threads else "")
          + f" in {pages + calls} calls ({5 * (pages + calls)} credits), saved to {path}")
    if total is not None and end == DONE:
        missing = total - len(comments) - under
        print(f"  Instagram's total is {total:,}: {len(comments):,} comments"
              f" + {under:,} replies under them" + (f", {missing:,} not listed" if missing > 0 else ""))
    short = [i for i, t in threads.items() if len(t) < comments[i]["child_comment_count"]]
    if short:
        print(f"  {got:,} of {under:,} replies saved: {len(short)} of {len(threads)} threads"
              " had more than one call returns")
    if under and not threads and end == DONE:
        with_replies = sum(1 for c in comments.values() if c.get("child_comment_count"))
        print(f"  {with_replies} comments have replies: add --replies to fetch them (5 credits each)")
    if end != DONE:
        print(f"  Stopped early: {end}")
  • Any form of the post works. A /p/ or /reel/ link, with or without the ?igsh=… the app's Copy link adds, the shortcode or the media ID. The script sends the shortcode, which also names the file.
  • --pages caps the spend. The default of 20 pages is 100 credits, the free allowance, and up to 300 comments. A page is always 5 credits.
  • Replies cost a call per thread. Only comments with child_comment_count above 0 get one, so on the Hoffmann post --replies would add 3 calls, and on Lance Hedrick's reel 15.
  • An early stop keeps what you have. A bad key, an empty balance (402) or a bad input ends the run but still writes the CSV. Timeouts (408), concurrency limits (429), server errors and dropped connections are retried with a growing pause; failed calls are refunded.
  • The file opens cleanly in Excel. It is written as utf-8-sig, so emoji and accents survive; Google Sheets and pandas read it either way.
  • Comments stay text. Anyone can write a comment that starts like a formula (=HYPERLINK(…)), and replies often start with an @name. Text starting with =, +, - or @ gets a leading apostrophe, so a spreadsheet shows it rather than running it; strip it off if you load the file into code.

Run it

Blue Bottle's reel, from its link:

python ig_comments.py https://www.instagram.com/reel/DdWpu4yMpxk/
DdWpu4yMpxk: 32 comments in 4 calls (20 credits), saved to DdWpu4yMpxk_comments_20261103-121326.csv
  Instagram's total is 37: 32 comments + 5 replies under them
  2 comments have replies: add --replies to fetch them (5 credits each)

Lance Hedrick's reel, with the replies:

python ig_comments.py DcjiDP4tTIL --replies
DcjiDP4tTIL: 94 comments and 64 replies in 22 calls (110 credits), saved to DcjiDP4tTIL_comments_20261103-121326.csv
  Instagram's total is 164: 94 comments + 70 replies under them
  64 of 70 replies saved: 1 of 15 threads had more than one call returns

The Hoffmann post needs more than the default 20 pages:

python ig_comments.py DbLR0mzFuWj --pages 50
DbLR0mzFuWj: 630 comments in 42 calls (210 credits), saved to DbLR0mzFuWj_comments_20261103-121327.csv
  Instagram's total is 645: 630 comments + 4 replies under them, 11 not listed
  3 comments have replies: add --replies to fetch them (5 credits each)

Its 42 pages took five minutes: most pages answered in 2 to 4 seconds, seven took 18 to 39. The CSV has one row per comment or reply:

comment_id,parent_id,created_at,username,user_id,is_private,is_verified,by_creator,likes,replies,mentions,text
18406898806091269,,2026-09-16 16:05:19,bluenotenyc,295813138,False,True,False,31,1,,Let’s collaborate!!!!!! 💙
…

Everyone who commented a keyword

"Comment PUCK. I'll DM you the recipe" turns a post into a sign-up form, and the export is the list of sign-ups. Save this as keyword.py: it reads the CSV, matches the keyword as a whole word whatever its case or punctuation ("PUCK", "Puck!", "puck ☕"), keeps each account once with its first matching comment, leaves out the creator, and lists the near misses for a person to judge.

import csv
import difflib
import re
import sys
import unicodedata
from datetime import datetime
from pathlib import Path


def words(text):
    """Lowercase words, with fancy Unicode letters folded to plain ones."""
    return re.findall(r"[^\W_]+", unicodedata.normalize("NFKC", text).casefold())


path, keyword = sys.argv[1], sys.argv[2].casefold()
with open(path, newline="", encoding="utf-8-sig") as f:
    comments = [r for r in csv.DictReader(f) if not r["parent_id"]]  # replies don't count

first = {}  # each account once, with its first matching comment (the file is oldest first)
for r in comments:
    if keyword in words(r["text"]) and r["by_creator"] != "True":
        first.setdefault(r["user_id"], r)
matches = sum(keyword in words(r["text"]) for r in comments)
close = [r["text"] for r in comments if keyword not in words(r["text"])
         and difflib.get_close_matches(keyword, words(r["text"]), n=1, cutoff=0.75)]

start = datetime.fromisoformat(comments[0]["created_at"]) if comments else None
hours = [(datetime.fromisoformat(r["created_at"]) - start).total_seconds() / 3600 for r in first.values()]
out = Path(path).with_name(f"{keyword}_{Path(path).name}")
with open(out, "w", newline="", encoding="utf-8-sig") as f:
    writer = csv.DictWriter(f, fieldnames=["username", "user_id", "is_private", "created_at", "text"],
                            extrasaction="ignore")
    writer.writeheader()
    writer.writerows(first.values())

print(f'{len(first):,} accounts commented "{keyword}" ({matches:,} of {len(comments):,} comments), '
      f"saved to {out}")
if hours:
    print(f"  {sum(h < 24 for h in hours):,} in the first 24 hours, {sum(h < 168 for h in hours):,} "
          f"in the first week, the latest {max(hours) / 24:.0f} days after the first comment")
if close:
    print(f"  {len(close)} near misses to check by hand: {', '.join(repr(t) for t in close[:6])}")
python keyword.py DbLR0mzFuWj_comments_20261103-121327.csv PUCK
608 accounts commented "puck" (610 of 630 comments), saved to puck_DbLR0mzFuWj_comments_20261103-121327.csv
  379 in the first 24 hours, 538 in the first week, the latest 93 days after the first comment
  4 near misses to check by hand: 'Pick', 'Pick', 'Pick', 'Pucl'
  • 97% of the comments contained the keyword. 610 of 630 had "puck" as a word and 553 were nothing else; two people commented it twice. The other 20 were questions, jokes, friends tagging friends, and two puns ("#eatthepuck", "Puckman!") that a whole-word match skips.
  • Near misses need a human. "Pucl" is a typo. "Pick", three times, may be autocorrect, but it is also a word. The script lists them and leaves the call to you.
  • Most of the list arrives on day one, not all of it. 379 of the 608 accounts commented within 24 hours of the first comment and 538 within a week, but the post still drew requests three months later. If it's your post and you answer through Meta's private replies, each comment has its own 7-day window: export daily, not once.

Cost and speed

  • 5 credits per page of up to 15 comments. 1,000 comments is about 67 pages, 335 credits, roughly 6 cents on Starter ($49.99 for 275,000 credits). The Hoffmann post's 630 comments cost 210 credits, under 4 cents.
  • Replies are 5 credits per thread that has any. Lance Hedrick's reel with replies cost 110 credits in all.
  • Pages come one after another, each needing the previous page's token: about 125 comments a minute on the Hoffmann post. To export many posts faster, run one post per worker, up to your plan's concurrency limit.
  • Failed calls cost nothing. The empty last page some posts end with is a successful call, 5 credits.

Using comment data responsibly

Commenters are people. Keep only the columns you need, message only the people who asked for it, and if you work with data about people in the EU or UK, the GDPR applies to what you store. The terms of service cover what you may do with data from ScrapingBot.

Where to go from here

Common questions

Is there an official API for Instagram comments?

Only for your own posts. Meta's Instagram API reads the comments on media created by the account whose access token you use, with the instagram_manage_comments permission. Its business_discovery edge reads other business and creator accounts' posts with a comments_count, but not the comments themselves (checked November 2026). For anyone else's public post you need a data API such as ScrapingBot's /comments/media_comments_by_id.

How do I get all the comments on an Instagram post?

Call /comments/media_comments_by_id with the post's link, shortcode or media ID, then send each page's pagination_token back until it comes back empty. A page holds up to 15 comments and costs 5 credits. In our test a post with 645 comments came back as 630 comments in 42 pages.

Why does my export have fewer comments than the post shows?

Instagram's total counts replies too. On a reel with a total of 164 there were 94 comments and 70 replies under them. Add --replies to fetch the replies, and compare comments plus child_comment_count with total: on one post of ours Instagram counted 11 more than it listed.

Can I get every reply to a comment?

Replies come from /comments/replies, one call per comment. A call returns the first page of a thread: every thread with up to 8 replies came back whole in our checks, while threads of 15 and 53 replies returned 9 each. There is no cursor for the rest, and child_comment_count tells you how many there are.

In what order do the comments come back?

In Instagram's own ranking, not by time. The first page of a July post held comments from July 24 to October 25. Sort by created_at (a Unix timestamp) yourself; the script in this guide writes the CSV oldest first.

How much does it cost to export 1,000 comments?

About 67 pages at 5 credits each: 335 credits, roughly 6 cents on the Starter plan ($49.99 for 275,000 credits). Replies add 5 credits per comment that has any. The 100 free credits cover 20 pages, up to 300 comments.

Start scraping in the next five minutes.

100 free credits, no credit card. One API key works for websites, TikTok, Instagram, Google, Amazon and ChatGPT.