Platform

How to export Instagram followers to CSV with Python

Export the followers of a public Instagram account to a CSV with Python, requests and one API key. Full script, real output, credits per page.

ToolzerHub5 min read

Related API

Instagram API

Public Instagram profiles, posts, reels, comments, highlights, followers and following lists.

What you need

  • A ToolzerHub API key from app.toolzerhub.com/signup, exported as TOOLZERHUB_API_KEY
  • Python 3 and pip install requests
  • A public Instagram username
  • Credits: the free plan includes 50 credits a month, enough for 25 pages

Prefer no code? The Instagram Followers Scraper Actor on Apify does the same job from a form.

Step 1: Fetch one page

The endpoint is GET /v2/instagram/user/followers. Auth is the x-api-key header. username_or_id is required.

bash
curl "https://api.toolzerhub.com/v2/instagram/user/followers?username_or_id=lisbonbakerywpg" \
  -H "x-api-key: $TOOLZERHUB_API_KEY"

Static API example (run on 2026-10-07, trimmed to two business accounts and a shortened cursor):

json
{
  "data": {
    "users": [
      {
        "pk": "32365844271",
        "id": "32365844271",
        "username": "manitobaentryleveldrop",
        "full_name": "Manitoba Entry Level Jobs",
        "is_private": false,
        "is_verified": false
      },
      {
        "pk": "27928287175",
        "id": "27928287175",
        "username": "landmarkcustomhomes_",
        "full_name": "Landmark Custom Homes",
        "is_private": false,
        "is_verified": false
      }
    ],
    "page_size": 24,
    "has_more": true,
    "page_info": {
      "has_next_page": true,
      "end_cursor": "QVFBSllTQTZMaS1TNnVibnB5..."
    }
  }
}

The real response has more fields per user (profile picture URL, badges) and some paging metadata. This page had 24 users.

Step 2: Follow the cursor

Pass page_info.end_cursor from the response as the end_cursor query parameter on the next request. Stop when end_cursor is null or has_next_page is false.

python
cursor = None
while True:
    params = {"username_or_id": "lisbonbakerywpg"}
    if cursor:
        params["end_cursor"] = cursor
    data = requests.get(URL, params=params, headers=HEADERS, timeout=60).json()["data"]
    info = data.get("page_info") or {}
    cursor = info.get("end_cursor")
    if not cursor or not info.get("has_next_page"):
        break

Full script

Usage: python export_followers.py <username> [max_pages] [resume_cursor] (0 means no page limit). Tested on 2026-10-07 with Python 3 and requests.

python
import csv
import os
import sys
import time

import requests

API_KEY = os.environ["TOOLZERHUB_API_KEY"]
URL = "https://api.toolzerhub.com/v2/instagram/user/followers"
FIELDS = ["username", "full_name", "is_private", "is_verified", "id"]
MAX_ATTEMPTS = 5
MAX_WAIT = 60


def get_page(username, cursor=None):
    params = {"username_or_id": username}
    if cursor:
        params["end_cursor"] = cursor
    for attempt in range(MAX_ATTEMPTS):
        r = requests.get(URL, params=params, headers={"x-api-key": API_KEY}, timeout=60)
        if r.status_code == 200:
            return r.json()["data"]
        if r.status_code not in (429, 503):
            r.raise_for_status()
        try:
            details = r.json().get("error", {}).get("details") or {}
        except ValueError:
            details = {}
        wait = details.get("retryAfter") or r.headers.get("Retry-After") or 5 * (attempt + 1)
        wait = min(int(wait), MAX_WAIT)
        print(f"{r.status_code}, retrying in {wait}s", file=sys.stderr)
        time.sleep(wait)
    raise RuntimeError(f"gave up after {MAX_ATTEMPTS} attempts")


def export(username, max_pages=None, cursor=None):
    pages, total = 0, 0
    # Rows are written page by page, so an error later keeps what was fetched.
    # Pass a cursor to resume: rows are appended to the same file.
    with open(f"{username}_followers.csv", "a" if cursor else "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if not cursor:
            writer.writeheader()
        try:
            while True:
                data = get_page(username, cursor)
                writer.writerows({k: u.get(k) for k in FIELDS} for u in data["users"])
                f.flush()
                total += len(data["users"])
                pages += 1
                info = data.get("page_info") or {}
                cursor = info.get("end_cursor")
                if not cursor or not info.get("has_next_page") or (max_pages and pages >= max_pages):
                    break
                time.sleep(1)
        except Exception as e:
            print(f"stopped on page {pages + 1}: {e}", file=sys.stderr)
            print(f"resume with: python export_followers.py {username} 0 {cursor}", file=sys.stderr)
    print(f"{total} followers, {pages} pages, {pages * 2} credits")


if __name__ == "__main__":
    args = sys.argv[1:]
    export(args[0], int(args[1]) if len(args) > 1 and args[1] != "0" else None, args[2] if len(args) > 2 else None)
bash
python export_followers.py lisbonbakerywpg 3
text
62 followers, 3 pages, 6 credits

The three pages returned 24, 19 and 19 users. Example rows from lisbonbakerywpg_followers.csv (order changed):

csv
username,full_name,is_private,is_verified,id
manitobaentryleveldrop,Manitoba Entry Level Jobs,False,False,32365844271
landmarkcustomhomes_,Landmark Custom Homes,False,False,27928287175
user_1,Private person,True,False,
user_2,Private person,False,True,
user_3,Private person,True,False,

Rows 3 to 5 are individuals; their usernames, names and ids are replaced (names shortened). Your file has the real values.

Limits

  • Page size: 19 to 25 users per page in our runs (pages of 24, 19, 19, 24, 25, 25 on one account). Do not assume a fixed size.
  • Credits: 2 credits per request, charged only on success. Errors cost nothing. The count field in the response schema was absent in every run, so you cannot read the total up front.
  • Private accounts: the request succeeds with "users": [], has_next_page: false and a null cursor. You get an empty CSV.
  • Unknown username: NOT_FOUND, HTTP 404, retryable: false.
  • Rate limits: plan-dependent and not published. RATE_LIMITED (429) is normally retryable. Instagram pages that fail upstream come back as 503 with error.details.retryAfter in seconds. The script waits that long (capped at 60 seconds), falls back to the Retry-After header, then to 5, 10, 15 seconds, and stops after 5 attempts. A 429 carries Retry-After as a header, not in details. We did not hit a 429 or 503 in the test run, so that branch was not exercised live.
  • Other errors: INVALID_REQUEST, UNAUTHORIZED and PAYMENT_REQUIRED (out of credits) are not retried. The script stops, keeps the rows already written and prints the command to resume from that page. The error envelope is { "error": { "code", "status", "message", "retryable", "details" } }.
  • Data: public profiles only. Keep the key server-side.

Sources

Query this from your own code

373 documented endpoints, one x-api-key header, a consistent JSON envelope.