Guide · Python

Calculate Instagram engagement rate for a list of creators with Python

Two calls per creator, three rules about which posts count, and a ranked table you can rerun every week.

ScrapingBot 10 min read
Endpoints
/user/by_username, /medias/by_user_id
Cost
10 credits per creator
Language
Python 3.9+
Needs
requests
On this page

Before a brand books a creator, someone compares a shortlist: who has the followers, and whose followers actually react. The usual number for the second question is engagement rate, likes and comments as a share of followers. It is simple to compute and easy to get wrong, because an account's recent posts are not all its own and not all recent.

This guide builds a Python script that takes a list of Instagram handles, reads each account's latest posts, sets aside pinned posts and collabs, and ranks the accounts by median engagement rate. Every response and result below is real: six public accounts, read on October 13, 2026.

Before you start

  • A ScrapingBot API key. Create a free account for 100 credits, enough for 10 creators. No card needed.
  • Python 3.9 or newer and pip install requests.
  • A list of public Instagram accounts: handles, @handles or profile links.

The formula, and which posts count

For one post, engagement rate by followers is:

engagement rate = (likes + comments) ÷ followers × 100

For an account, take that rate over its recent posts and use the median, not the average. One viral post moves an average a long way: James Hoffmann's 11 own posts averaged 1.10%, but the median was 0.71%, because one reel reached 36,012 likes while the other ten had between 3,369 and 12,079.

The harder question is which posts to include. The posts feed returns what is on the account's grid, and that includes two kinds of post that don't measure the account's own audience today:

  • Pinned posts. Up to three posts pinned to the top of the grid come first in the feed, whatever their age. The Specialty Coffee Association's pinned reel is from January 13, 2026; the other 11 posts were from September 28 to October 12.
  • Collabs. A collab appears on the grid of every co-author, and its likes come from all of their audiences. The association's reel co-authored with two World of Coffee accounts had 3,496 likes; its best solo post in the same window had 700.

Two calls per creator

Every Instagram call is a POST to https://scrapingbot.io/api/v1/instagram with the endpoint and its parameters in the body. First the profile, for the follower count and the numeric account ID:

curl -X POST https://scrapingbot.io/api/v1/instagram \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "/user/by_username", "params": {"username": "nasa"}}'
{
  "success": true,
  "data": {
    "id": "528817151",
    "username": "nasa",
    "full_name": "NASA",
    "follower_count": 104340794,
    "following_count": 90,
    "media_count": null,
    "is_private": false,
    "is_verified": true,
    "category": "",
    …
  },
  …
  "creditsUsed": 5
}

follower_count is exact. media_count was null for all six accounts, so don't plan on the post count. Then the posts, by that ID:

curl -X POST https://scrapingbot.io/api/v1/instagram \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "/medias/by_user_id", "params": {"user_id": "528817151", "count": 12}}'

Each post is a node in data.edges. Here are two of NASA's: the first in the feed, pinned and posted by another account, and the fifth, NASA's own. With 104,340,794 followers, the fifth post's rate is (428,993 + 3,090) ÷ 104,340,794 × 100 = 0.41%.

{
  "success": true,
  "data": {
    "count": 12,
    "edges": [
      {
        "node": {
          "code": "DeJ28eTxo4s",
          "taken_at": 1791292211,
          "product_type": "feed",
          "like_count": 236844,
          "comment_count": 728,
          "view_count": null,
          "like_and_view_counts_disabled": false,
          "timeline_pinned_user_ids": ["528817151", "549313808"],
          "owner": { "id": "549313808", "username": "nasawebb", … },
          "coauthor_producers": [ { "username": "nasauniverse", … }, { "username": "nasa", … } ],
          "caption": { "text": "Brewing up new stars 🪄 …", … },
          …
        },
        …
      },
      …
      {
        "node": {
          "code": "DeVqBdUl4kC",
          "taken_at": 1791736201,
          "product_type": "feed",
          "like_count": 428993,
          "comment_count": 3090,
          "timeline_pinned_user_ids": [],
          "owner": { "id": "528817151", "username": "nasa", … },
          "coauthor_producers": [],
          …
        },
        …
      },
      …
    ],
    "page_info": { "end_cursor": "4002852117585374880_50101577629", "has_next_page": true, … }
  },
  …
  "creditsUsed": 5
}

What we found in 72 recent posts

We read the 12 latest posts of six accounts: three coffee creators (James Hoffmann, Morgan Eckroth, Lance Hedrick), a roaster (Blue Bottle Coffee), an industry association (the Specialty Coffee Association) and NASA. These fields decide which posts count:

FieldIn 72 postsWhat the script does
timeline_pinned_user_ids10 posts listed their own account: pinned to its grid. 4 of the 6 accounts had pinned posts, all at the start of the feed. Morgan Eckroth's three went back to February 26.Skips a post when the list contains the account's own ID. The list can hold other IDs too: a collab can be pinned on a co-author's grid only.
owner, coauthor_producers21 collabs (3 of them also pinned): 17 posted by another account and 4 posted by the account itself with co-authors. 7 of NASA's 12 latest were posted by astronauts and other NASA accounts.Skips a post owned by someone else or with any co-author.
like_and_view_counts_disabledfalse on all 72.Skips a post if it is true: the owner hid its like count.
view_countnull on all 72, including the 40 reels. There is no play count in this feed.Nothing: the rate is by followers, not by views.
product_typeclips (reel) 40, carousel_container 16, feed (single photo or video) 16.Counts the reels, so you can see each account's format mix.

The script

Save this as ig_engagement.py. It takes a CSV of handles or handles on the command line, looks each account up, reads its latest POSTS posts, keeps only its own unpinned posts, prints a table ranked by median rate and appends a dated row per account to instagram_engagement.csv.

import csv
import statistics
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone

import requests

API = "https://scrapingbot.io/api/v1/instagram"
HEADERS = {"x-api-key": "YOUR_API_KEY"}
POSTS = 12  # recent posts to read per account (1 to 50)
CONCURRENCY = 1  # your plan's limit: Free 1, Starter 10, Startup 50
OUT = "instagram_engagement.csv"
FIELDS = ["date", "username", "name", "followers", "posts", "avg_likes", "avg_comments",
          "er_median", "er_mean", "reels", "posts_per_week", "last_post",
          "skipped_pinned", "skipped_collab", "skipped_hidden", "status"]
credits = {"used": 0}
lock = threading.Lock()


class NotFound(Exception):
    pass


class Stop(Exception):
    pass


def call(endpoint, params, tries=4):
    """One Instagram call: 5 credits on success. Failed calls are refunded."""
    for attempt in range(tries):
        res = requests.post(API, headers=HEADERS, timeout=60,
                            json={"endpoint": endpoint, "params": params})
        body = res.json()
        if body.get("success"):
            with lock:
                credits["used"] += body.get("creditsUsed", 0)
            return body["data"]
        if res.status_code == 404:  # no such account: retrying will not help
            raise NotFound(body.get("error"))
        if res.status_code in (401, 402):  # bad key or out of credits: stop the run
            raise Stop(body.get("error"))
        if res.status_code == 400:
            raise RuntimeError(body.get("error"))
        time.sleep(2 ** attempt)  # 408, 429 or 5xx: wait and try again
    raise RuntimeError(f"kept failing: {body.get('error')}")


def clean(handle):
    """'@NASA', 'nasa' and 'https://www.instagram.com/nasa/' all become 'nasa'."""
    h = handle.strip().lstrip("@")
    if "instagram.com/" in h:
        h = h.split("instagram.com/")[1].split("/")[0].split("?")[0]
    return h.lower()


def own_posts(user_id, username, edges):
    """Keep the posts that measure this account's own audience, and count the rest."""
    kept, skipped = [], {"skipped_pinned": 0, "skipped_collab": 0, "skipped_hidden": 0}
    for edge in edges:
        post = edge["node"]
        if user_id in (post.get("timeline_pinned_user_ids") or []):
            skipped["skipped_pinned"] += 1  # pinned to the top of the grid, often months old
        elif post["owner"]["username"] != username or post.get("coauthor_producers"):
            skipped["skipped_collab"] += 1  # a collab: the likes come from two audiences
        elif post.get("like_and_view_counts_disabled"):
            skipped["skipped_hidden"] += 1  # the owner hid the like count
        else:
            kept.append(post)
    return kept, skipped


def engagement(username):
    row = {"date": datetime.now(timezone.utc).date().isoformat(), "username": username}
    try:
        profile = call("/user/by_username", {"username": username})
        followers = profile["follower_count"]
        row.update(name=profile.get("full_name") or "", followers=followers)
        if profile.get("is_private"):
            return {**row, "status": "private"}
        feed = call("/medias/by_user_id", {"user_id": profile["id"], "count": POSTS})
    except NotFound:
        return {**row, "status": "not found"}
    except RuntimeError as err:  # still failing after the retries: report it, keep going
        return {**row, "status": f"error: {err}"}

    posts, skipped = own_posts(profile["id"], profile["username"], feed["edges"])
    row.update(skipped)
    if not posts or not followers:
        return {**row, "status": "no posts to measure"}

    likes = [p["like_count"] for p in posts]
    comments = [p["comment_count"] or 0 for p in posts]
    rates = [(l + c) / followers * 100 for l, c in zip(likes, comments)]
    oldest = min(p["taken_at"] for p in posts)
    newest = max(p["taken_at"] for p in posts)
    days = max((time.time() - oldest) / 86400, 1)
    return {**row, "status": "ok", "posts": len(posts),
            "avg_likes": round(statistics.mean(likes)),
            "avg_comments": round(statistics.mean(comments)),
            "er_median": round(statistics.median(rates), 3),
            "er_mean": round(statistics.mean(rates), 3),
            "reels": sum(p.get("product_type") == "clips" for p in posts),
            "posts_per_week": round(len(posts) / days * 7, 1),
            "last_post": datetime.fromtimestamp(newest, timezone.utc).date().isoformat()}


def read_handles(args):
    if len(args) == 1 and args[0].endswith(".csv"):
        with open(args[0], newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            col = "username" if "username" in reader.fieldnames else reader.fieldnames[0]
            return [r[col] for r in reader if r[col].strip()]
    return args


if __name__ == "__main__":
    handles = list(dict.fromkeys(clean(h) for h in read_handles(sys.argv[1:])))
    rows, stopped = [], None
    with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
        futures = [pool.submit(engagement, h) for h in handles]
        for future in futures:
            if future.cancelled():
                continue
            try:
                rows.append(future.result())
            except Stop as err:  # bad key or no credits left: skip the rest, keep what finished
                stopped = err
                for f in futures:
                    f.cancel()

    for r in sorted(rows, key=lambda r: r.get("er_median", -1), reverse=True):
        if r["status"] == "ok":
            print(f"@{r['username']:<27} {r['followers']:>11,} followers  "
                  f"{r['er_median']:>6.2f}% median  {r['er_mean']:>6.2f}% mean  "
                  f"{r['posts']:>2} posts  {r['posts_per_week']:>4} a week")
        else:
            print(f"@{r['username']}: {r['status']}")

    with open(OUT, "a", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if f.tell() == 0:
            writer.writeheader()
        writer.writerows(rows)
    print(f"{sum(r['status'] == 'ok' for r in rows)} of {len(handles)} measured, "
          f"{credits['used']} credits; added to {OUT}")
    if stopped:
        sys.exit(f"Stopped early: {stopped}")
  • own_posts() holds the three rules and counts what it skipped, so every row shows how many posts were pinned, collabs or hidden. When an account's rate rests on only a few posts, you can see it.
  • Median and average are both kept. The table is ranked by the median; a mean well above it means one or two posts did far better than the rest.
  • Each failure is handled once, in call(). A missing account (404) becomes a "not found" row. Timeouts, rate limits and server errors (408, 429, 5xx) are retried with a growing pause; an account that keeps failing becomes an "error" row and the rest of the list carries on. A bad key or an empty balance (401, 402) stops the run, and the accounts already measured are still printed and saved. Failed calls are refunded.
  • Private accounts cost one call. The profile says is_private, so the script stops there instead of asking for posts it can't read.
  • posts_per_week is the account's own posts from the oldest one counted until today, and last_post is the newest. Together they show whether a creator is still active.
  • Set CONCURRENCY to your plan's limit (Free 1, Starter 10, Startup 50) to check long lists in minutes.

Run it

A shortlist as it usually arrives: a mix of handles, an @, a profile link, different cases, and one account that doesn't exist.

username,notes
@jimseven,
MorganDrinksCoffee,
https://www.instagram.com/lancehedrick/,
bluebottle,brand
specialtycoffeeassociation,
nasa,
zzzz_no_such_user_8812734,
python ig_engagement.py creators.csv
@lancehedrick                    133,331 followers    2.02% median    2.48% mean   9 posts   0.7 a week
@morgandrinkscoffee            1,386,780 followers    0.99% median    1.29% mean   5 posts   2.5 a week
@jimseven                        844,045 followers    0.71% median    1.10% mean  11 posts   0.9 a week
@nasa                        104,340,794 followers    0.46% median    0.46% mean   4 posts   5.8 a week
@bluebottle                      518,305 followers    0.09% median    0.12% mean   8 posts   1.4 a week
@specialtycoffeeassociation      351,167 followers    0.07% median    0.09% mean   7 posts   3.3 a week
@zzzz_no_such_user_8812734: not found
6 of 7 measured, 60 credits; added to instagram_engagement.csv

Twelve calls at 5 credits each; the missing account cost nothing. The rows in instagram_engagement.csv:

date,username,name,followers,posts,avg_likes,avg_comments,er_median,er_mean,reels,posts_per_week,last_post,skipped_pinned,skipped_collab,skipped_hidden,status
2026-10-13,jimseven,James Hoffmann,844045,11,9089,203,0.71,1.101,9,0.9,2026-09-17,0,1,0,ok
2026-10-13,morgandrinkscoffee,Morgan Eckroth,1386780,5,17774,101,0.993,1.289,5,2.5,2026-10-10,3,4,0,ok
2026-10-13,lancehedrick,Lance Hedrick,133331,9,3223,80,2.021,2.477,8,0.7,2026-09-21,0,3,0,ok
2026-10-13,bluebottle,Blue Bottle Coffee,518305,8,618,6,0.094,0.121,2,1.4,2026-10-06,3,1,0,ok
2026-10-13,specialtycoffeeassociation,Specialty Coffee Association,351167,7,299,4,0.07,0.086,4,3.3,2026-10-12,1,4,0,ok
2026-10-13,nasa,NASA,104340794,4,477015,3139,0.46,0.46,1,5.8,2026-10-11,3,5,0,ok
2026-10-13,zzzz_no_such_user_8812734,,,,,,,,,,,,,,not found

Read the table with its columns. Lance Hedrick, the smallest account here, has the highest rate, and the two organizations the lowest. NASA's rate rests on only 4 posts, because 8 of its 12 latest were pinned or collabs; read 24 posts (POSTS = 24) when you need more of them. James Hoffmann's newest post was almost four weeks old.

Why the filters matter

The simple version takes all 12 posts and averages them. On the same responses, that changes the numbers, and the order:

AccountAll 12 posts, averageAll 12 posts, medianOwn unpinned posts, median
@lancehedrick1.93%1.82%2.02%
@morgandrinkscoffee1.01%0.73%0.99%
@jimseven1.05%0.66%0.71%
@nasa0.32%0.22%0.46%
@bluebottle0.28%0.11%0.09%
@specialtycoffeeassociation0.24%0.13%0.07%
  • The ranking flips. On the simple average, James Hoffmann (1.05%) is ahead of Morgan Eckroth (1.01%). On their own posts, Morgan's median is 0.99% and James's 0.71%. James's average leans on one viral reel (4.31% on its own); Morgan's is pulled down by three collabs that smaller accounts posted (0.08% to 0.27%).
  • Brand accounts fall by two-thirds. Blue Bottle's pinned reel had 7,826 likes and a collab posted by Nespresso had 3,012; its own recent posts had 281 to 1,549. The association's pinned January reel and its big collab lifted it the same way.
  • NASA goes up. Seven of its 12 latest were posted by astronauts and other NASA accounts and drew far fewer likes than NASA's own posts: 0.05% to 0.23% of NASA's 104 million followers, against 0.31% to 0.62%. Measured on NASA's own unpinned posts, its rate is 0.46%, not 0.32%.

Track it over time

Run the script on the same list every week. Each run appends rows with that day's date, so the CSV becomes a history. This compares each account's two latest runs:

import csv
from collections import defaultdict

history = defaultdict(dict)  # username -> {date: median engagement rate}
with open("instagram_engagement.csv", newline="", encoding="utf-8") as f:
    for r in csv.DictReader(f):
        if r["status"] == "ok":
            history[r["username"]][r["date"]] = float(r["er_median"])

for user, by_date in sorted(history.items()):
    days = sorted(by_date)
    if len(days) >= 2:
        before, after = by_date[days[-2]], by_date[days[-1]]
        print(f"@{user}: {before:.2f}% -> {after:.2f}% "
              f"({after - before:+.2f} points) from {days[-2]} to {days[-1]}")

Accounts get renamed. For long-running tracking, key the history on the numeric ID from the profile and look accounts up with /user/by_id.

Cost and speed

  • 10 credits per creator: 5 for the profile, 5 for one page of posts. 1,000 creators is 10,000 credits, about $1.82 on the Starter plan ($49.99 for 275,000 credits). A private account costs 5, a missing one nothing.
  • More posts, same price per call. count goes up to 50, but Instagram decides the page size: asking NASA's feed for 50 returned 33. Page further with page_info.end_cursor for 5 credits a page.
  • About 2 seconds per creator. In this run, profiles answered in 0.3 to 0.4 seconds and posts in 1.3 to 1.6. With 10 workers on Starter, 1,000 creators take a few minutes.

Where to go from here

Common questions

How do I calculate Instagram engagement rate with an API?

Look the account up with /user/by_username to get follower_count and its numeric id, then read its recent posts with /medias/by_user_id. For each post, engagement rate is (like_count + comment_count) ÷ followers × 100. Take the median over the account's own recent posts. That is two calls and 10 credits per account.

Which posts should I leave out?

Pinned posts and collabs. Pinned posts come first in the feed whatever their age; one account in our sample had a pinned reel from January next to posts from the last two weeks. Collabs show up on every co-author's grid, and their likes come from all of those audiences. In 72 recent posts from six accounts, 10 were pinned and 21 were collabs (3 were both), so 28 were left out.

Why use the median instead of the average?

One viral post moves the average a long way. James Hoffmann's 11 own posts had an average rate of 1.10% but a median of 0.71%, because one reel reached 36,012 likes when the others had 3,369 to 12,079. The median describes a typical post.

Can I calculate engagement by views or reach instead?

Not from public data. view_count was empty on all 72 posts we read, reels included, and the posts feed has no play count. Reach and impressions are private insights that only the account owner can share, through Meta's own API.

What is a good Instagram engagement rate?

Compare accounts of a similar size and type in the same niche rather than against one universal number. In our six coffee and space accounts, own-post medians ran from 0.07% for an industry association to 2.02% for a creator with 133,331 followers.

How much does it cost?

10 credits per account: 5 for the profile and 5 for the posts. 1,000 accounts is 10,000 credits, about $1.82 on the Starter plan ($49.99 for 275,000 credits). The 100 free credits cover 10 accounts, and lookups that fail are not charged.

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.