Back to Blog
tutorialpythontwitter apifollowerscsv

How to Export Twitter Followers to CSV with Python (2026)

Build a resumable Python exporter that saves Twitter follower profiles to CSV with cursor checkpoints, deduplication, retries, and formula-prefix hardening.

TweetAPI
Published
9 min read

This tutorial builds a real Twitter follower export instead of stopping at one API response. The finished Python script resolves a username, follows every available cursor, writes profiles to CSV as they arrive, and resumes from a checkpoint after an interruption.

It also prevents duplicate rows during a resumed run and hardens common formula-leading cells. Because CSV files do not define column types, the tutorial also explains how to import long follower IDs without letting a spreadsheet convert them to numbers.

Last verified: August 3, 2026.

Disclaimer: TweetAPI is a third-party service. Not affiliated with X Corp.

What You Will Build

The exporter accepts a Twitter username and produces a CSV with these fields:

  • user ID, username, and display name;
  • bio, location, and website;
  • verification and protected-account flags;
  • follower, following, post, and listed counts; and
  • account creation time.

The workflow makes one user lookup to resolve the account ID, then one followers request for each cursor page. The script never holds full profile objects for the entire export in memory.

Prerequisites

You need Python, a TweetAPI account, and an API key. Install the maintained Python client:

python -m pip install tweetapi

Keep the key outside the script:

export TWEETAPI_KEY="your_api_key"

The package currently declares Python 3.9 or newer. For a new production environment, choose a version that is still listed as supported in the Python version status table.

Complete Python Follower Exporter

Save this as export_followers.py:

import argparse
import csv
import json
import os
from pathlib import Path
from typing import Any, Optional

from tweetapi import (
    AuthenticationError,
    NetworkError,
    RateLimitError,
    TweetAPI,
    TweetAPIError,
)


CSV_FIELDS = [
    "id",
    "username",
    "name",
    "bio",
    "location",
    "website",
    "verified",
    "isBlueVerified",
    "isProtected",
    "followerCount",
    "followingCount",
    "tweetCount",
    "listedCount",
    "createdAt",
]
DANGEROUS_FORMULA_PREFIXES = (
    "=",
    "+",
    "-",
    "@",
    "\t",
    "\r",
    "\n",
    "=",
    "+",
    "-",
    "@",
)


def csv_value(value: Any) -> str:
    """Normalize a value and harden common spreadsheet formula prefixes."""
    if value is None:
        return ""
    if isinstance(value, (dict, list)):
        value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))

    text = str(value).replace("\x00", "")
    if text.startswith(DANGEROUS_FORMULA_PREFIXES):
        return "'" + text
    return text


def read_existing_ids(output_path: Path) -> set[str]:
    """Read only IDs, not full profiles, to deduplicate a resumed export."""
    if not output_path.exists() or output_path.stat().st_size == 0:
        return set()

    with output_path.open("r", encoding="utf-8", newline="") as handle:
        return {
            row["id"]
            for row in csv.DictReader(handle)
            if row.get("id")
        }


def load_checkpoint(checkpoint_path: Path, username: str) -> dict[str, Any]:
    if not checkpoint_path.exists():
        return {"username": username, "pages": 0, "rows": 0}

    state = json.loads(checkpoint_path.read_text(encoding="utf-8"))
    if state.get("username", "").lower() != username.lower():
        raise ValueError(
            f"Checkpoint belongs to @{state.get('username')}, not @{username}"
        )
    return state


def save_checkpoint(checkpoint_path: Path, state: dict[str, Any]) -> None:
    checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
    temporary_path = checkpoint_path.with_name(checkpoint_path.name + ".tmp")
    temporary_path.write_text(
        json.dumps(state, indent=2, sort_keys=True),
        encoding="utf-8",
    )
    temporary_path.replace(checkpoint_path)


def export_followers(
    username: str,
    output_path: Path,
    checkpoint_path: Path,
    max_pages: Optional[int],
) -> None:
    client = TweetAPI(api_key=os.environ["TWEETAPI_KEY"])
    state = load_checkpoint(checkpoint_path, username)

    if state.get("completed"):
        print(f"Export is already complete: {output_path}")
        return

    user_id = state.get("userId")
    if not user_id:
        profile = client.user.get_by_username(username=username)
        user_id = profile["data"]["id"]
        state["userId"] = user_id
        save_checkpoint(checkpoint_path, state)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    seen_ids = read_existing_ids(output_path)
    state["rows"] = len(seen_ids)
    has_header = output_path.exists() and output_path.stat().st_size > 0
    pages_this_run = 0
    cursor = state.get("cursor")

    with output_path.open("a", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS)
        if not has_header:
            writer.writeheader()

        while max_pages is None or pages_this_run < max_pages:
            page = client.user.get_followers(user_id=user_id, cursor=cursor)

            for follower in page.get("data", []):
                follower_id = str(follower.get("id", ""))
                if not follower_id or follower_id in seen_ids:
                    continue

                writer.writerow(
                    {
                        field: csv_value(follower.get(field))
                        for field in CSV_FIELDS
                    }
                )
                seen_ids.add(follower_id)
                state["rows"] = state.get("rows", 0) + 1

            handle.flush()
            pages_this_run += 1
            state["pages"] = state.get("pages", 0) + 1
            cursor = page.get("pagination", {}).get("nextCursor")
            state["cursor"] = cursor
            state["completed"] = not bool(cursor)
            save_checkpoint(checkpoint_path, state)

            print(
                f"pages={state['pages']} rows={state['rows']} "
                f"next_cursor={'yes' if cursor else 'no'}"
            )
            if not cursor:
                break

    if cursor and max_pages is not None:
        print("Stopped at --max-pages. Run the same command to resume.")
    else:
        print(f"Export complete: {output_path}")


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Export Twitter follower profiles to a resumable CSV file."
    )
    parser.add_argument("username", help="Twitter username, with or without @")
    parser.add_argument("--output", default="followers.csv")
    parser.add_argument(
        "--checkpoint",
        help="Defaults to <output>.checkpoint.json",
    )
    parser.add_argument(
        "--max-pages",
        type=int,
        help="Stop after this many pages in the current run",
    )
    args = parser.parse_args()

    username = args.username.lstrip("@").strip()
    if not username:
        parser.error("username cannot be empty")
    if args.max_pages is not None and args.max_pages < 1:
        parser.error("--max-pages must be at least 1")
    if "TWEETAPI_KEY" not in os.environ:
        parser.error("set TWEETAPI_KEY before running the exporter")

    output_path = Path(args.output)
    checkpoint_path = Path(
        args.checkpoint or f"{output_path}.checkpoint.json"
    )

    try:
        export_followers(
            username=username,
            output_path=output_path,
            checkpoint_path=checkpoint_path,
            max_pages=args.max_pages,
        )
    except AuthenticationError:
        raise SystemExit("Authentication failed. Check TWEETAPI_KEY.")
    except RateLimitError as error:
        retry_after = getattr(error, "retry_after", None)
        detail = f" Retry after about {retry_after} seconds." if retry_after else ""
        raise SystemExit("Rate limit reached." + detail)
    except NetworkError:
        raise SystemExit("Network request failed after SDK retries.")
    except (OSError, ValueError) as error:
        raise SystemExit(f"Local export error: {error}")
    except TweetAPIError as error:
        raise SystemExit(
            f"TweetAPI error {error.status_code}: {error.message}"
        )


if __name__ == "__main__":
    main()

Run a bounded test first:

python export_followers.py nasa --output nasa-followers.csv --max-pages 2

Inspect the CSV and checkpoint. If both look correct, repeat the same command without --max-pages:

python export_followers.py nasa --output nasa-followers.csv

The second command resumes at the saved cursor. It does not start the collection again.

How Resume and Deduplication Work

After every successful page, the script flushes the CSV and atomically replaces the checkpoint file. The checkpoint records the account ID, next cursor, completed page count, and written row count.

On restart, the exporter reads IDs already present in the CSV. If the process stopped after writing a row but before saving the page cursor, requesting that page again will not create duplicate rows.

The restart also reconciles the checkpoint's row count with the IDs already in the CSV. That keeps the reported count accurate across the same write-before-checkpoint interruption window.

Keep the CSV and its .checkpoint.json file together until the export is complete. To intentionally start over, choose new output and checkpoint filenames. Do not attach an old checkpoint to a different CSV.

What Formula-Prefix Hardening Does

A Twitter name, bio, or location can begin with =, +, -, @, a control character, or a full-width equivalent. Some spreadsheet applications can interpret those prefixes as formulas. The exporter adds a leading apostrophe when a cell starts with =, +, -, @, tab, carriage return, line feed, or the corresponding full-width characters.

The Python csv module still owns quoting, commas, line breaks, and UTF-8 text. Do not replace csv.DictWriter with string concatenation.

This is bounded hardening, not a universal guarantee across every spreadsheet application and save/reopen workflow. If the file contains untrusted profile text, review the current OWASP CSV injection guidance for the spreadsheet application and downstream workflow you use.

Import Follower IDs as Text

The CSV stores follower IDs digit for digit, but CSV has no schema that marks the id column as text. Spreadsheet applications can therefore interpret a long Snowflake ID as a number. Excel documents a 15-significant-digit numeric limit, which is shorter than many Twitter IDs.

When ID integrity matters, import the file through the spreadsheet's text/CSV import flow and set the id column to Text before loading it. Do not rely on opening the CSV directly and saving it again. Microsoft's large-number import guidance shows the Text-column workflow for Excel.

Estimate Requests Before a Full Export

The exact request count depends on the number of cursor pages returned, not only the public follower count:

total API requests = 1 username lookup + follower pages requested

For example, a test capped at 10 pages makes at most 11 API calls before SDK retry attempts: one lookup and ten page requests. A resumed run normally reuses the saved account ID, so it does not repeat the username lookup.

Do not calculate cost from an assumed number of followers per page. Page sizes can vary, and the documented contract is the presence of data plus pagination.nextCursor. Use a small --max-pages run to measure the current workload, then compare it with current TweetAPI plans.

Production Improvements for Large Exports

The script stores only seen IDs in memory, but that set still grows with the CSV. For multi-million-row jobs, move deduplication into SQLite or a database table with the user ID as a unique key.

Other useful production changes are:

  • upload completed CSV parts to object storage instead of one local disk;
  • record job status and cursor in a database;
  • split output files after a fixed row count;
  • emit metrics for pages, rows, retries, and failures; and
  • encrypt or restrict access to files containing profile data.

The SDK already retries transient 429, 5xx, timeout, and connection failures. A production queue should still bound concurrency and total request volume. The complete Python and TypeScript SDK guide explains retry and typed-error behavior.

Data and Completeness Limits

This export is a changing collection, not a transactionally consistent snapshot. Accounts can gain or lose followers while pagination is running, and profile fields can change after a row is written.

The script only exports data returned by the API. Protected, unavailable, suspended, deleted, or otherwise inaccessible accounts may not be present. Do not describe the result as a complete historical follower list unless you have independently measured and documented completeness.

Store only the fields your use case needs. Apply appropriate retention, access control, and deletion handling for your users and jurisdiction.

FAQ

Can I export followers without loading everything into memory?

Yes. The script writes each profile directly to CSV. It keeps only the set of existing user IDs in memory so interrupted runs can deduplicate rows.

Can I stop and continue later?

Yes. Use the same username, output path, and checkpoint path. --max-pages is useful for intentionally dividing a job into bounded runs.

Does the exporter include email addresses?

No. The selected fields come from the public user response and do not include email addresses.

Can I export following instead of followers?

Use the corresponding following endpoint and SDK method, then keep the same checkpoint and CSV pattern. Confirm its current request parameters in the API reference before changing the script.

Run the First Two Pages

Create a TweetAPI account, install the Python SDK, and run the two-page command first. That gives you a real CSV, a real cursor checkpoint, and a measured request count before you commit to the full export.

If your goal is continuous discovery rather than a one-time follower file, build the Python Twitter brand monitoring tool next.