Skip to content
Twitter ScrapingWeb ScrapingPythonBest PracticesTwitter API

GUIDE

Twitter Scraping, Best Practices for Production in 2026

Production-grade Twitter scraping patterns, retry logic, pagination, proxy strategy, rate-limit handling, and cost optimization for any third-party API.

TwitterAPIs··Updated May 7, 2026
Twitter scraping best practices for production workflows in 2026

A Twitter scraper that worked in 2024 is almost certainly broken today. Three things changed at once. The official X API switched to metered pricing that starts north of $5 per 1,000 tweets (X API pricing). The community libraries that reverse-engineered Twitter's private frontend, snscrape chief among them, stopped tracking the web app's changes. And the headless-browser route now trips IP-level throttling inside the first hour of a real job.

What follows is not an introduction. It is the set of engineering habits that decide whether your pipeline finishes a million-tweet pull cleanly or stalls halfway through with duplicate rows and a stack of 429 responses. The habits are stack-agnostic: they hold whether you run them through TwitterAPIs, a homegrown scraper sitting behind a proxy pool, or a different vendor entirely.

TL;DR: Production Twitter scraping in 2026 comes down to five habits: retry only transient codes (429, 502, 503), page with cursors until next_cursor comes back empty, cache profiles to avoid re-buying data, dedupe on tweet ID across workers, and keep API keys in the environment. Against a REST API like TwitterAPIs at $0.04 per 1,000 tweets, they hold a million-tweet pull together.

Already scraping and want the deep cuts? Keep reading. If you are still at the starting line, the how to scrape tweets walkthrough and the best Twitter scraper comparison are better first stops. Product details and pricing live on the Twitter scraper page.

Every code sample below targets the TwitterAPIs REST surface, where reads run $0.0008 per call (roughly 20 tweets back, so $0.04 per 1,000 tweets), but swap the base URL and the patterns carry straight over to any provider.


1. Match the Endpoint to the Job Before You Write Code

The single cheapest optimization happens before your first request: picking the right endpoint. TwitterAPIs exposes 48 endpoints (34 read, 14 write), and several read pairs look interchangeable while returning different data at the same price. Reach for the wrong one and you pay full freight for an incomplete answer.

Two examples that bite people constantly. user/info hands back the core profile fields, name, bio, follower count, while user/about returns the same fields plus the account creation date, location, and username history. They cost the same, so when you need account history, calling the lighter endpoint just means a second round trip. On the follower side, user/followers (v1) and user/followers_v2 page identically, about 70 records on the first page and fewer after that, so there is no page-size or cost advantage either way. What separates them is the response: v2 attaches a fuller profile object, including the DM-eligibility metadata that outreach pipelines actually use, and has the more consistent cursor.

Profile lookup: info versus about

Field groupuser/infouser/about
Cost per call$0.0008$0.0008
Core profileReturnedReturned
Account historyNot includedReturned (created-at, location, handle history)

Use user/info for a fast name-and-count lookup; reach for user/about only when the account's history matters.

The decision table that saves the most credits

What you wantReach forThe mistake that costs you
Tweets matching a keywordtweet/advanced_searchLooping user/tweets over a list of accounts
One account's latest postsuser/tweetsWrapping it in a from:user search query
A full follower exportuser/followers_v2 (richer records, same page size)v1, unless you are already built against its shape
A DM-outreach shortlistuser/followers_v2 (DM signals)v1, which drops the DM-eligibility fields
Account creation and historyuser/aboutuser/info, which omits the history block
Every reply under a tweettweet/repliesA conversation_id:ID search, which is patchier

For a column-by-column map of these endpoints against the official X API, the Twitter API v2 vs TwitterAPIs comparison lays them side by side.


2. Walk Pages With Cursors, Never With a Counter

Most read endpoints hand back about 20 records and a next_cursor string for pagination. You feed next_cursor back in as the cursor parameter on the following request, and you keep going until next_cursor comes back null or empty. That empty cursor is the only correct stop signal. Hardcode a fixed number of pages and any dataset that varies in size will get silently chopped off at the end.

Cursor-based pagination flow

Page sizes by endpoint

EndpointRecords per callPagination field
user/followers~70next_cursor
user/following~70next_cursor
user/followers_v2~70next_cursor
user/following_v2~70next_cursor
tweet/advanced_search~20 tweetsnext_cursor
tweet/replies~20 repliesnext_cursor
user/tweets~20 tweetsnext_cursor
user/tweets_and_replies~20 tweetsnext_cursor
user/likes~20 tweetsnext_cursor
user/media~20 postsnext_cursor
user/home_timeline~20 tweetsnext_cursor
user/bookmark_search~20 tweetsnext_cursor
user/search~20 usersnext_cursor
user/verified_followers~20next_cursor
user/followers_you_know~20next_cursor
list/members~20 membersnext_cursor

Cursor paging on tweet/advanced_search has been verified clean across consecutive pages: no repeated tweet IDs, IDs descending monotonically by snowflake, so the duplicate-results bug that hit this endpoint earlier in 2026 is gone. You can trust a deep cursor chain.

That said, once a single query runs past 50 pages, splitting it into date windows with the since: and until: operators usually pulls faster and parallelizes cleanly, because each window gets its own independent cursor chain:

  • q=AI lang:en since:2026-01-01 until:2026-01-07
  • q=AI lang:en since:2026-01-07 until:2026-01-14
  • q=AI lang:en since:2026-01-14 until:2026-01-21
  • ...continuing across your full range

Separate windows can run on separate workers at the same time. For minute-by-minute targets like a breaking-news term, add a timestamp to the operators:

  • q=from:elonmusk since:2026-01-01_12:00:00_UTC until:2026-01-01_18:00:00_UTC

Date range chunking for Advanced Search

The same windowing trick is the backbone of a historical backfill, walked through in the scrape tweet history API guide, and of paging an oversized follower list, covered in the export Twitter followers guide. For the complete operator vocabulary that goes in the q string, the canonical twitter-advanced-search reference on GitHub is the one to bookmark.

Four ways pagination goes wrong

  1. Ignoring the empty next_cursor. TwitterAPIs signals the end of results by returning next_cursor null or empty, so a loop that never checks it either runs forever or retries a dead cursor.
  2. Baking in a page count. Stop when next_cursor comes back empty, but keep a max_pages ceiling as a runaway guard.
  3. Hammering pages back to back. A short delay between calls (200ms is plenty) keeps an aggressive loop under the rate ceiling.
  4. Throwing away cursors on a crash. Persist the current cursor and a mid-run failure resumes instead of restarting from page one.

3. Retry the Transient Failures, and Only Those

Every networked job hits the occasional blip: a dropped connection, an upstream hiccup, a momentary rate ceiling. Skip retries and you lose data to noise. Retry indiscriminately and you turn a one-second stumble into a self-inflicted outage. The discipline is knowing which status codes are worth a second attempt.

StatusWhat it meansWorth retrying?
429Rate ceiling hitYes, after a pause
502Upstream gateway failureYes, after a pause
503Service briefly downYes, after a pause
200Request succeededNo, you have the data
400Malformed requestNo, fix the parameters
401Bad key or auth_tokenNo, fix the credentials
404User or tweet is goneNo, it does not exist

Retry logic with exponential backoff

Spread retries out with jitter

Picture 100 workers all tripping the same rate ceiling in the same second. With a fixed backoff they all wake up and fire again at the same instant, a thundering herd that re-triggers the limit they were waiting out. Sprinkling a random fraction of a second onto each delay scatters the retries so they no longer collide.

Honor Retry-After when the server sends it

A 429 frequently arrives with a Retry-After header naming the exact pause the upstream wants. The MDN page on HTTP 429 documents the header and its semantics. Reading that value when it is present, and only falling back to your own doubling schedule when it is absent, unblocks the job faster and is far kinder to the upstream than a blind exponential loop. The classifier in section 7 reads the header directly.

Stop retrying the dead ends

Retrying a 401 three times just spends three calls confirming your key is still wrong. Retrying a 404 will not resurrect a deleted tweet. Transient codes only: 429, 502, 503, and raw network timeouts. For the endpoint-level view of where the rate ceilings actually bind, the Twitter API rate limit guide breaks down the windows that matter when you are sizing a retry budget.

This is exactly the wall a lot of developers hit when they try to roll their own. The pain shows up clearly in this thread:

the r/OnlineMarketing thread on how to scrape tweets without using the Twitter API from r/OnlineMarketing

https://www.reddit.com/r/OnlineMarketing/comments/1lotx8g/how_can_i_scrape_tweets_without_using_the_twitter/

The minimal loop

Pagination, retry, and backoff in one tight function:

import os
import time
import requests

SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['TWITTERAPIS_KEY']}"
SEARCH_URL = "https://api.twitterapis.com/twitter/tweet/advanced_search"
TRANSIENT = (429, 502, 503)


def pull_tweets(query: str, page_cap: int = 10) -> list[dict]:
    harvested: list[dict] = []
    page_token = None

    for _ in range(page_cap):
        qs = {"query": query, "product": "Latest"}
        if page_token:
            qs["cursor"] = page_token

        # up to three tries per page, doubling the wait on transient codes
        for attempt in range(3):
            resp = SESSION.get(SEARCH_URL, params=qs, timeout=15)
            if resp.status_code == 200:
                break
            if resp.status_code in TRANSIENT:
                time.sleep(2 ** attempt)
                continue
            resp.raise_for_status()

        payload = resp.json()
        harvested.extend(payload.get("tweets", []))

        page_token = payload.get("next_cursor")
        if not page_token:
            break

    return harvested


found = pull_tweets("AI min_faves:100 lang:en since:2026-01-01")
print(f"collected {len(found)} tweets")

Thirty-odd lines, and that is the whole reliable core.


4. Drop Duplicates Across Pages and Workers

Run at volume and the same tweet or profile will surface twice, across overlapping pages or across parallel workers reading adjacent windows. Leave it unhandled and your engagement counts inflate, your user stats double, and your store fills with redundant rows. A seen set in memory handles a single process; a Redis set handles a fleet.

import os
import requests

CLIENT = requests.Session()
CLIENT.headers["Authorization"] = f"Bearer {os.environ['TWITTERAPIS_KEY']}"
ENDPOINT = "https://api.twitterapis.com/twitter/tweet/advanced_search"


def collect_distinct(query: str, page_cap: int = 20) -> list[dict]:
    """Page through search results, keeping only first-seen tweet IDs."""
    already_seen: set[str] = set()
    distinct: list[dict] = []
    page_token = None

    while page_cap > 0:
        page_cap -= 1
        qs = {"query": query, "product": "Latest"}
        if page_token:
            qs["cursor"] = page_token

        reply = CLIENT.get(ENDPOINT, params=qs, timeout=15)
        reply.raise_for_status()
        block = reply.json()

        for tweet in block.get("tweets", []):
            tweet_id = tweet.get("id")
            if tweet_id and tweet_id not in already_seen:
                already_seen.add(tweet_id)
                distinct.append(tweet)

        page_token = block.get("next_cursor")
        if not page_token:
            break

    return distinct

Going multi-process? Swap the in-memory set for a Redis SADD plus SISMEMBER so every worker checks the same membership table. The Redis SADD reference spells out why this works as a shared dedup primitive: adds are idempotent, lookups are O(1), and one key serves every worker at once.


5. Cache Profiles So You Stop Re-Buying the Same Data

Profile fields drift on a daily timescale, not a per-minute one. Follower counts, bios, and verification badges barely move between hours. Re-fetching an author's profile every time you encounter one of their tweets means paying $0.0008 over and over for a record that has not changed since this morning.

import time

_cache: dict[str, tuple[dict, float]] = {}
TTL_SECONDS = 3600  # refresh hourly


def profile_for(handle: str) -> dict:
    """Return a profile, served from cache while still fresh."""
    cached = _cache.get(handle)
    if cached:
        record, stored_at = cached
        if time.time() - stored_at < TTL_SECONDS:
            return record

    reply = CLIENT.get(
        "https://api.twitterapis.com/twitter/user/info",
        params={"userName": handle},
        timeout=15,
    )
    reply.raise_for_status()
    fresh = reply.json()["data"]
    _cache[handle] = (fresh, time.time())
    return fresh

In production, lift the cache out of process memory into Redis or Memcached so every worker shares it and you set the TTL once at the cache layer. As an illustrative model, an enrichment job touching 100,000 unique authors a day can fall from 100,000 profile lookups to under 20,000 by adding a 12-hour cache, roughly a 5x cut in credit burn with no loss of freshness for fields that move that slowly. For a single process, the standard-library functools.lru_cache decorator gives you the same memoization for free, which is precisely what the full template in section 9 uses on its lookup method.


Start building with TwitterAPIs

$0.04 per 1,000 tweets. $0.50 free credits. No credit card required.

6. Keep the Credit Bill Down

A read call costs $0.0008 and returns about 20 tweets. The write actions (the toggle operations covered in section 8) are priced separately at $0.0008 per call. Most of your spend is reads, so the savings live there. Six habits move the number more than anything else:

  1. Never re-fetch what you hold. Tweet IDs and profiles go in a local cache; check it before you spend a call.
  2. Treat tweet/detail as a last resort. If advanced_search already returned the tweet, do not pull it again by ID.
  3. Pick the right follower version. Both versions page at about 70 records, so prefer user/followers_v2 for new work and keep v1 only where an integration is already built against its response shape.
  4. Filter at the query. Operators like min_faves:100 discard low-signal tweets before they occupy a page slot you paid for.
  5. Page with intent. Need the first 100 tweets? Cap at max_pages = 5 and stop. Only walk to the end when you genuinely need everything.
  6. Batch the work. Process users in groups that share pagination state instead of one isolated lookup at a time.

What the math looks like at volume

Tweets pulledRead callsRead costTypical use case
1K50$0.04A quick check
10K500$0.50A small dataset
100K5,000$5.00A research pull
1M50,000$50.00A full pipeline run

Cost is the reason most teams leave the official API in the first place, and the headline rate is not the number that lands on your invoice. The cheapest Twitter API ranking puts eight providers next to each other on real per-1,000-tweet cost, which is what you actually pay.

The hunt for cheaper access is a recurring theme on developer timelines, and scraping-tool roundups go viral precisely because the official pricing sent everyone looking for an exit:

https://x.com/aiwithkhush/status/2068300454275224045

The community has also been frank about how much the ground shifted in the last year. This thread gathers several of the lessons teams learned the expensive way:

the r/scrapingenthusiasts thread on five surprising truths about web scraping in 2026 from r/scrapingenthusiasts

https://www.reddit.com/r/scrapingenthusiasts/comments/1s2lmxh/5_surprising_truths_about_web_scraping_in_2026/


7. Sort Errors Into Three Buckets

A scraper that runs unattended needs a single place that decides what each HTTP status means: a transient failure worth retrying, a permanent request error worth raising, or an account-level error worth paging a human about. Blur those categories and you get one of two failures, quiet data loss when you do not retry something you should, or wasted budget when you retry something you never could.

import os
import time
import logging
import requests

CLIENT = requests.Session()
CLIENT.headers["Authorization"] = f"Bearer {os.environ['TWITTERAPIS_KEY']}"

TRANSIENT = {429, 500, 502, 503, 504}
FATAL = {400, 401, 403, 404, 422}

log = logging.getLogger(__name__)


def fetch_classified(url: str, params: dict, tries: int = 3) -> dict:
    """Retry transient statuses; raise on fatal ones; return JSON on success."""
    backoff = 1.0
    for attempt in range(tries):
        reply = CLIENT.get(url, params=params, timeout=15)

        if reply.status_code == 200:
            return reply.json()

        if reply.status_code in FATAL:
            log.error("fatal %s on %s: %s", reply.status_code, url, reply.text[:200])
            reply.raise_for_status()

        if reply.status_code in TRANSIENT:
            pause = float(reply.headers.get("Retry-After", backoff))
            log.warning("transient %s, attempt %d, waiting %.1fs", reply.status_code, attempt + 1, pause)
            time.sleep(pause)
            backoff = min(backoff * 2, 60)
            continue

        log.error("unrecognized status %s: %s", reply.status_code, reply.text[:200])
        reply.raise_for_status()

    raise RuntimeError(f"out of retries for {url}")

Wrap this around any endpoint. The load-bearing rule is that a 401 (wrong key) and a 404 (missing record) must never loop, while a 429 or 502 almost always clears on the next attempt. Wire an alert onto the fatal bucket so a key expiry or an account suspension surfaces before it quietly drains a day of data.


8. Auth Tokens for Write Actions and Private Reads

A slice of the API needs more than your API key. Write actions and the endpoints that read a specific account's private surface require an auth_token, the Twitter session token bound to one logged-in account, alongside the matching ct0 value. You supply both on each request, and TwitterAPIs uses them in flight and discards them; nothing is stored. You obtain the pair by lifting the auth_token and ct0 cookies out of a logged-in browser session, or by calling POST /twitter/user_login with the account credentials.

Auth token flow, two ways to get and use tokens

The 12 write actions cover four toggle pairs, each $0.0008 per call: favorite and unfavorite, retweet and unretweet, bookmark and unbookmark, follow and unfollow, plus delete and media upload at the same $0.0008, and tweet creation and DM send at $0.0016. The private reads (a user's own timeline, bookmarks, or likes) need the same session credentials because they expose data only that account can see.

Where the session token is required

EndpointSession token?Reason
tweet/favoriteRequiredActs as a specific account
tweet/retweetRequiredActs as a specific account
tweet/bookmarkRequiredWrites to a private bookmark list
user/followRequiredActs as a specific account
user/home_timelineRequiredA personalized, per-account feed
user/bookmark_searchRequiredReads private bookmarks
user/likesRequiredReads an account's liked tweets
user/followers_you_knowRequiredNeeds the viewer's social graph

Handling the tokens safely

  1. Treat them like passwords. Never write auth_token or ct0 to a log.
  2. Keep them in environment variables, never in committed source.
  3. Expect expiry. A 401 on a write means the session lapsed; re-authenticate.
  4. One pair per account. Do not reuse a single account's tokens across others.
  5. Nothing is persisted. TwitterAPIs uses the credentials per request and drops them.

9. Proxies for Write-Heavy Automation

A write action runs on Twitter as your account, using the session credentials from section 8. By default the request leaves TwitterAPIs's servers, so Twitter records the platform's IP rather than yours. For write-heavy automation, that shared origin is a pattern you may want to break up.

Proxy architecture for read vs write endpoints

Routing your write traffic through your own residential proxy makes each action originate from an IP you control, which keeps a high-volume account from looking like it shares an address with thousands of others. A few rules earn their keep:

  1. Residential over datacenter. Datacenter ranges get flagged sooner.
  2. Rotate across accounts. Posting from several accounts behind one static IP links them.
  3. Match the geography. A US-based account should egress from a US IP.
  4. Prove it on one action first. Confirm the proxy works on a single write before a bulk run.
  5. Never cross-pollinate. Keep proxies isolated between accounts that should stay unconnected.

Reads do not need any of this. Search, profile lookups, and follower pulls fetch public data and write to no account, so spend the proxy budget on writes alone. If you are running a self-hosted scraper that genuinely needs a pool, the provider choice matters more than teams expect; the best residential proxies for Twitter scraping breakdown weighs pool size, geographic coverage, and the per-GB economics that decide whether self-hosting even undercuts a managed API.


10. Run Several Requests in Flight, Within a Cap

After retry, pagination, and caching are solid, throughput is the next lever. A loop that waits for each response before sending the next leaves most of your bandwidth idle, because the bottleneck is round-trip latency, not your CPU. The fix is bounded concurrency: several requests open at once, with a ceiling so you neither idle nor flood the upstream into rate limiting.

In Python the clean expression is an asyncio loop driving an httpx.AsyncClient, with a semaphore that caps in-flight requests at a fixed number, usually 5 to 20 against a managed API. That semaphore is the knob that matters most. Set it low and throughput suffers; set it high and you start collecting 429 responses, which pushes work onto the retry path and slows the whole job. Tune it empirically: start conservative, watch the error rate, and lift the cap only while 429 stays at zero.

Reuse one client across every request rather than spinning up a fresh one each call. A long-lived client keeps the connection pool warm and skips the TLS handshake per request, often a bigger win than another point of concurrency. Pair the cap with the same exponential backoff from section 3 so one worker's transient error never cascades through the pool. When you fan out across many accounts at once, say a follower-graph crawl, concurrency and dedup work together: workers pull separate cursors in parallel and write into one shared Redis set so the merged output stays unique. The same shape drives a Twitter bot built on the API, where reads and writes fan out against a fixed request budget. On a JavaScript stack, the Node.js Twitter API tutorial shows the equivalent p-limit pattern over fetch and axios.


11. Watch the Right Three Numbers

An unattended scraper has to announce when it is degrading, not only when it has stopped. Three rates tell that story: the success rate (share of requests returning 200), the retry rate (share that needed at least one retry), and the cost rate (credits per useful record). A retry rate that is quietly climbing is the earliest signal that something upstream is shifting, often before a single request fails outright.

Give every log line the endpoint, the status, the attempt number, and the cursor position. Structured logs make the incident question answerable in seconds: is this error spike concentrated on one endpoint or smeared across all of them? A single-endpoint spike usually points at an account or token problem; a broad spike points at an upstream outage or a rate ceiling you just walked into. Telling those two apart fast is the gap between a five-minute fix and an afternoon of guessing.

Alert on the fatal bucket and on sustained rate pressure, not on the lone transient blips the retry layer already swallows. A sane default pages when the 401 rate stays non-zero for over a minute (a credential or token fault) and warns when the 429 rate holds above a few percent for several minutes (your concurrency cap is too hot). Track credit burn against a daily budget so the classic runaway, a missing pagination stop-condition, gets caught by a spend alert long before it empties your balance. If your scraper also performs write actions, watch for account-level signals too; the Twitter bot detection guide covers the behavioral patterns that flag automation so you can keep write volume under the review thresholds. Reads and writes share infrastructure, and the Apify scraper comparison is a useful look at how a browser-based pipeline's observability differs from a REST API's, because the failure modes are not the same.


The cheapest pay-as-you-go Twitter API. Try it free.

$0.04 per 1,000 tweets. $0.50 free credits. No credit card required.

12. Store the Key in the Environment, Rotate It on Schedule

A production key never belongs in source. Keep TWITTERAPIS_KEY in an environment variable or a secrets manager, smoke-test it with a cheap call before you ship, rotate it on any 401 or any exposure in logs or version control, and keep a .env holding credentials out of git for good.

# put this in your shell profile or an uncommitted .env
export TWITTERAPIS_KEY="your-api-key-here"

# confirm the key answers before deploying
python3 -c "
import os, requests
reply = requests.get(
    'https://api.twitterapis.com/twitter/user/info',
    params={'userName': 'elonmusk'},
    headers={'Authorization': f\"Bearer {os.environ['TWITTERAPIS_KEY']}\"},
    timeout=10
)
print('OK' if reply.status_code == 200 else f'ERROR: {reply.status_code}')
"

A rotation runs in five steps: mint a new key in the dashboard, push it to every environment starting with staging, verify it in production with a test call, revoke the old key, then update any monitor watching for 401 (a spike there flags a rotation gap). Keep both keys live for a 24-hour overlap so a key cached somewhere in your stack does not cause a blip mid-rotation.

If a secrets manager is in play, AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager, store the key there and inject it at runtime through the SDK instead of a plain environment variable. Both AWS Secrets Manager and Vault support scheduled rotation and fine-grained access policies, so one rotation event reaches every consumer without a redeploy and leaves an audit trail of who read the secret and when. Rotate quarterly as a floor, or immediately whenever a key leaves the team, lands in source control by accident, or shows up in a log. Set a calendar reminder for the first of each quarter, and write each rotation into the ops runbook with the date, the reason, and who ran it; an undocumented rotation just creates doubt the next time you have to move under pressure.

For the end-to-end build of a production scraper, including the SDK wrapper, async patterns, and a tweepy migration path, see the Python Twitter API tutorial. For the operator syntax feeding the q parameter throughout this guide, see the Twitter search operators reference. For a full per-volume cost breakdown, including the owned-read versus standard-read split and real monthly bills at light, medium, heavy, and enterprise tiers, see the Twitter API cost guide.


13. The Whole Thing in One Class

Everything above folds into one reusable object: backoff retry on transient codes, cursor paging that stops when next_cursor comes back empty, tweet-ID dedup via a seen set, per-handle profile caching with lru_cache, and a clean split between transient and fatal statuses. Drop it into a project as your starting point.

import os
import time
import requests
from functools import lru_cache


class XScraper:
    ROOT = "https://api.twitterapis.com"
    TRANSIENT = {429, 500, 502, 503, 504}

    def __init__(self) -> None:
        self.http = requests.Session()
        self.http.headers["Authorization"] = f"Bearer {os.environ['TWITTERAPIS_KEY']}"
        self._seen: set[str] = set()

    def _call(self, path: str, params: dict, tries: int = 3) -> dict:
        wait = 1.0
        for attempt in range(tries):
            reply = self.http.get(f"{self.ROOT}{path}", params=params, timeout=15)
            if reply.status_code == 200:
                return reply.json()
            if reply.status_code in self.TRANSIENT:
                time.sleep(min(wait * 2 ** attempt, 60))
                continue
            reply.raise_for_status()
        raise RuntimeError(f"retries exhausted: {path}")

    def search(self, query: str, page_cap: int = 10) -> list[dict]:
        """Paginate a search, keeping only first-seen IDs."""
        out, token = [], None
        for _ in range(page_cap):
            qs = {"query": query, "product": "Latest"}
            if token:
                qs["cursor"] = token
            block = self._call("/twitter/tweet/advanced_search", qs)
            for tweet in block.get("tweets", []):
                if tweet["id"] not in self._seen:
                    self._seen.add(tweet["id"])
                    out.append(tweet)
            token = block.get("next_cursor")
            if not token:
                break
        return out

    @lru_cache(maxsize=1000)
    def profile(self, handle: str) -> dict:
        """Cached profile lookup."""
        return self._call("/twitter/user/info", {"userName": handle})["data"]


# usage
bot = XScraper()
batch = bot.search("AI min_faves:500 lang:en since:2026-01-01", page_cap=5)
author = bot.profile(batch[0]["author"]["userName"])
print(f"{len(batch)} unique tweets, first author: {author['name']}")

To layer sentiment scoring on top of this collection step, see the Twitter sentiment analysis guide. For the full operator syntax behind the query argument, see the Twitter search operators reference.


14. The Python Stack, and Why Not the Alternatives

For Twitter scraping in 2026, plain requests against TwitterAPIs is the stack worth defaulting to. One Bearer header, no OAuth dance, $0.0008 a read call, and every field comes back inline with no expansion parameters to manage. The requests documentation is the reference for the session reuse, timeouts, and connection-pool behavior you will lean on at scale. The competing approaches each carry a real tax: tweepy adds OAuth machinery you only need for user-delegated flows, snscrape is mostly broken after Twitter's 2023-2026 anti-scraping changes, and browser automation collects IP bans inside hours.

The fade of the old library route is well documented. Tools like twint existed specifically to read Twitter without the official API, and they spread widely right up until the anti-scraping changes made them unreliable:

https://x.com/akaclandestine/status/1946652340607447327

If a video helps, Rob Mulla's short walkthrough of scraping Twitter with snscrape is a clean demonstration of the library pattern that used to work, and a useful marker for why a maintained REST API replaced it for production:

https://www.youtube.com/watch?v=PUMMCLrVn8A

StackBest fitThe catch
requests + TwitterAPIsProduction scraping at any sizeOne Bearer header, no OAuth, $0.0008 a call for ~20 tweets
tweepy + official X APIOAuth user-delegated flowsRare for scraping; metered pricing runs ~100x the cost
snscrapeLegacy projects onlyLargely broken in 2026, most endpoints fail
Selenium / PlaywrightEdge cases no API coversIP bans within hours, proxy spend often beats API pricing

The full Python tutorial covering search, profiles, followers, replies, pagination, retries, async with httpx, a tweepy migration path, and a drop-in SDK class is the How to Use the Twitter API with Python, 2026 Tutorial.


A Note on Legality

Pulling publicly visible Twitter/X data is generally not a federal crime in the United States. The Ninth Circuit's 2022 decision in hiQ Labs v. LinkedIn held that scraping public web data does not violate the Computer Fraud and Abuse Act (CFAA), and that reasoning gets applied broadly to other public-web cases.

"Not a CFAA violation" is not the same as "frictionless," though:

What you are doingWhere the law standsWhat actually happens
Reading public profiles, tweets, searchGenerally fine under hiQ LabsIP blocks within hours, proxy costs climb
Behind-login content (timelines, bookmarks)Needs authentication, a different access pathSuspension exposure, fragile sessions
Re-syndicating what you collectedDepends on jurisdictionPersonal-data rules (PDPL/GDPR/CCPA) kick in
Building ML/AI training setsLitigated case by caseNew ground, get counsel for production work

The cleanest way to skip the infrastructure question entirely is to put a third-party Twitter data API in front of it, so the only terms you operate under are the provider's developer agreement. For anything specific to your own use case, talk to a lawyer rather than a guide.


The One-Screen Cheat Sheet

Five rules carry most of the reliability: retry only 429, 502, and 503 (never 400/401/404); reserve proxies for write actions and leave reads direct; stop pagination when next_cursor comes back empty, never on a hardcoded count; keep session tokens in the environment and rotate on a 401; and cache aggressively so you never buy the same record twice.

AreaDo thisNot this
RetriesBack off on 429, 502, 503Retry 400, 401, 404
ProxiesRoute write actions through your IPProxy your read traffic
PaginationStop when next_cursor is emptyHardcode a page count
Auth tokensKeep in env vars, rotate on 401Paste them into source
CostCache and pre-filter with operatorsRe-fetch data you already hold

Start Scraping the Right Way

TwitterAPIs hands you $0.50 in free credits at signup, about 625 read calls, roughly 12,500 tweets, with no card required. That is enough room to run every pattern in this guide and stand up a working scraper before you commit a cent.

  1. Sign up at twitterapis.com
  2. Grab your API key from the dashboard
  3. Read the full API documentation for per-endpoint parameters and response schemas

For more depth, see Twitter API v2 vs TwitterAPIs, the Twitter API cost guide, the Python Twitter API tutorial, and the Twitter advanced search operators guide for the query construction that pairs with the scraping loop above.


Best-practice patterns verified against live TwitterAPIs endpoints May 2026. hiQ Labs v. LinkedIn precedent sourced from the Ninth Circuit opinion (2022). Rate-limit and pricing data from the official X API pricing page as of May 2026.

// sources

Where these numbers come from

Each row is a figure in this post and the artefact it was read from. Prices and limits on this platform move, so check the date on the source before you plan against it.

X API pricing page
Source of the metered official rate the post opens on, north of $5 per 1,000 tweets, and the pricing basis for its cost-optimization math.
MDN HTTP 429 status reference
Documents the Retry-After header and its semantics, backing the rule that a retry layer should read that value when present and only fall back to its own doubling schedule when it is absent.
Redis SADD command reference
Backs the multi-process dedup design, that set adds are idempotent and membership lookups are O(1), so one key can serve every worker as a shared dedup table.
Python functools.lru_cache documentation
The single-process memoization used by the template's lookup method, behind the modelled drop from 100,000 profile lookups a day to under 20,000 with a 12-hour cache.
twitter-advanced-search operator reference
The canonical operator vocabulary the post points to for building the q string used in the time-windowing and backfill patterns.
Ninth Circuit opinion in hiQ Labs v. LinkedIn
The primary court document behind the legal precedent the post cites when discussing what public-data collection is permitted.

Frequently Asked Questions

Scraping public Twitter/X data is generally not a federal crime in the US under the hiQ Labs v. LinkedIn precedent, where the Ninth Circuit ruled that scraping public web data does not fall under the Computer Fraud and Abuse Act. The simpler path for most production use cases is a third-party Twitter data API that runs the infrastructure layer for you. For specific legal questions about your own use case, consult a qualified lawyer rather than relying on a general guide.

Technically yes: browser automation with Puppeteer or Playwright can read Twitter's web UI. In practice it is increasingly unreliable in 2026 because Twitter's anti-scraping defenses detect headless browsers, fingerprint requests, and rate-limit by IP within hours. Self-hosted scrapers also incur rotating residential-proxy costs of roughly $5 to $15 per GB that frequently exceed third-party API pricing for the same volume of data.

Direct browser scraping hits per-IP rate limits within minutes. The official X API enforces 15-minute and 24-hour windows per endpoint and returns 429 Too Many Requests when you exceed them. A managed third-party API like TwitterAPIs has no platform-level rate caps for normal-volume workloads, so your throughput is governed by your own concurrency settings and credit balance rather than a hard per-endpoint window.

A Twitter API (official or third-party) returns structured JSON through documented HTTP endpoints, and the provider handles authentication, retries, anti-bot defenses, and rate limits on your behalf. Scraping refers to extracting data directly from the rendered Twitter web UI using browser automation or HTML parsing. APIs are far more reliable; a scraper breaks every time Twitter ships a UI change to its frontend.

For most production workloads, TwitterAPIs is the most cost-effective option at $0.04 per 1,000 tweets ($0.0008 per call returning roughly 20 tweets), about 100x cheaper than the official X API standard read rate. Open-source tools like snscrape are largely broken in 2026 after Twitter's anti-scraping updates. Self-hosted browser automation with Selenium or Playwright hits IP-level rate limits within hours, and the rotating-proxy costs often exceed third-party API pricing for the same data volume.

Costs vary significantly by approach: $5 to $10 per 1,000 tweets on the official X API standard read rate, $0.04 per 1,000 tweets on TwitterAPIs, $0.15 per 1,000 on twitterapi.io, $0.25 to $0.40 per 1,000 on Apify scrapers, and $0 plus proxy costs for self-hosted scrapers, which typically works out to $1 to $5 per 1,000 tweets once you account for residential-proxy spend.

Largely no. The maintainers paused active development in 2023, and most endpoints (search, user timelines, followers) are unreliable or fully broken after Twitter tightened its anti-scraping defenses. For a working Python alternative, send requests against a maintained REST API instead of relying on a library that reverse-engineers Twitter's private frontend, which breaks every time the web app ships a change.

Check out similar blogs

More guides on the Twitter/X API, scraping, and pricing.

How to scrape the full tweet history of any public X account in 2026, past the 3,200-tweet timeline limit, using date-window search and cursor pagination
Tweet HistoryWeb Scraping

Scrape Full Tweet History of Any Account in 2026 (Beyond the 3,200 Limit)

Why the X timeline stops at 3,200 tweets and how to pull an account's full history with date-window search, cursor pagination, and dedup. Live-tested code in Python and curl.

TwitterAPIs·
Twitter (X) API authentication in 2026, covering OAuth 1.0a and OAuth 2.0 bearer tokens, the four credential types, and how to fix 401 Unauthorized and 403 errors in Python and Node.js
Twitter API AuthenticationOAuth 2.0

Twitter API Authentication in 2026: OAuth, Bearer Tokens, and Fixing 401

How Twitter (X) API authentication works in 2026: the four credential types, OAuth 1.0a versus OAuth 2.0, generating and using a bearer token, runnable Python and Node.js, and a fix for every 401 Unauthorized and 403 error, plus the one-header alternative.

TwitterAPIs·
How to get the full list of accounts that retweeted a tweet via API in 2026, with Python and Node.js, cursor pagination, and amplifier analysis
Twitter Retweeters APITutorial

How to Get Everyone Who Retweeted a Tweet via API (2026)

Pull the full list of accounts that reposted any tweet with a real 2026 API. Runnable Python and Node.js, cursor pagination for the whole list, a real amplifier ranking over live data, a bot filter, and the honest per-call cost.

TwitterAPIs·
How to get all replies to a tweet via API in 2026, with Python and Node.js, cursor pagination, the conversation_id long-tail sweep, and nested reply handling
Tweet Replies APIConversation ID

How to Get All Replies to a Tweet via API (2026)

Pull the replies under any tweet with a real 2026 API. Runnable Python and Node.js, cursor pagination, the conversation_id tail sweep for the long tail, nested replies-to-replies, signal-versus-noise filtering over live data, and the honest per-call cost.

TwitterAPIs·
Twitter API pagination in 2026, showing how the official next_token and pagination_token cursor loop works and a simpler single-cursor alternative with per-call costs
Twitter APIPagination

Twitter API Pagination 2026: How next_token Works (and a Simpler Alternative)

How Twitter API pagination works in 2026. The official next_token loop explained field by field, a simpler single-cursor alternative, runnable Python and Node code, and the real per-call cost of a paginated pull.

TwitterAPIs·
How to search tweets by hashtag via API in 2026 with Python and Node.js, showing the hashtag search endpoint and its per-call cost
Twitter Hashtag APITutorial

How to Search Tweets by Hashtag via API 2026 (Python + Node.js)

Search tweets by hashtag with a real 2026 API in Python and Node.js. Runnable code for the hashtag operator, engagement filters, cursor pagination, deduping retweets, counting authors, and the real per-call cost.

TwitterAPIs·
How to build a Twitter X chatbot on an API in 2026 that watches mentions and auto-replies, covering the two endpoints, the poll filter generate reply loop, code, and per-call cost
Twitter chatbotX chatbot

How to Build a Twitter (X) Chatbot on an API in 2026

Build a Twitter/X chatbot that watches @mentions and auto-replies via an API. The two endpoints, a full poll-filter-generate-reply loop in Python, real per-call cost, and an honest read on X's automation rules.

TwitterAPIs·
How to fetch a full Twitter thread through an API in one call in 2026, pulling the root tweet plus every connected tweet in the chain instead of paginating replies by hand
twitter thread apitweet thread

Fetch a Full Twitter Thread via API in One Call (2026)

How to pull an entire Twitter thread, the root tweet plus every connected tweet in the chain, in a single API call with the tweet/thread endpoint, instead of walking replies by hand. Live-tested code in curl, Python, and Node.js, with the real per-call cost.

TwitterAPIs·