snscrape for Twitter (X): why it returns blocked (404) and how to migrate

By TwitterAPI.io Team•21 min read
Three-lane migration map on a dark background. Left lane, the snscrape CLI commands twitter-search, twitter-user, twitter-profile, twitter-hashtag, twitter-tweet and twitter-list-posts; middle lane, the matching sntwitter scraper classes TwitterSearchScraper, TwitterUserScraper, TwitterProfileScraper, TwitterHashtagScraper, TwitterTweetScraper and TwitterListPostsScraper; right lane, the twitterapi.io endpoint that replaces each: /twitter/tweet/advanced_search, /twitter/user/last_tweets, /twitter/user/info, advanced_search with a #tag query, /twitter/tweets with tweet_ids, and /twitter/list/tweets with listId. Footer notes that the guest-token flow returns blocked (404) since X's 2023 login wall, snscrape issue #996.
The migration map this page follows: each snscrape CLI scraper and module class on the left, the endpoint that replaces it on the right. Diagram, not measured data.

snscrape was the scraper people reached for after twint died. One pip3 install snscrape, one snscrape --jsonl --max-results 500 twitter-search "query since:2022-01-01" > out.jsonl, and a thesis, a sentiment model or a newsroom dataset had its data, with no developer account and no bill. The notebooks that did this are still on GitHub and Kaggle, their first cell still installs the package, and the first run still fails with four blocked (404) lines. That failure is what most searches for snscrape are about.

This page is the migration those notebooks need. It is specific to snscrape's Twitter module: what it did, what broke and why (with the issue threads), what you lose by moving to a paid API, and how each scraper class and each Tweet dataclass field maps onto a GET request against https://api.twitterapi.io with an X-API-Key header. The wrapper at the end prints snscrape-shaped JSON Lines so your pd.read_json(lines=True) and column names keep working. For the wider landscape of options, the Twitter scraper API hub and the guide to scraping Twitter without the official API are the parent pages.

Everything quoted about snscrape comes from the GitHub repository, its issue tracker, the twitter.py module source and the PyPI page as read this year. The field-coverage, page-size and pace numbers come from a study we ran ourselves in 2026: 40 pages of a twitter-search replacement for a neutral developer topic and 10 pages of a twitter-user replacement for three organization accounts, 1,400 tweets in total. Where the honest answer is "snscrape did this and the API does not", the page says so.

01 — Section

What snscrape was, and why people still install it

snscrape describes itself in its README as "a scraper for social networking services (SNS). It scrapes things like user profiles, hashtags, or searches and returns the discovered items, e.g. the relevant posts." It is a Python 3.8+ package by JustAnotherArchivist, GPL-3.0, with 5.5k stars, 785 forks and 466 commits on GitHub, and it covered eight services: Facebook, Instagram, Mastodon, Reddit, Telegram, Twitter, VKontakte and Weibo. The Twitter module was the one most people used, and it was broad: "users, user profiles, hashtags, searches (live tweets, top tweets, and users), tweets (single or surrounding thread), list posts, communities, and trends."

The interface had two halves. The CLI: snscrape twitter-user textfiles to get a user's posts, snscrape --max-results 100 twitter-hashtag archiveteam for a hashtag, and snscrape --jsonl [SCRAPER-NAME] [ARGUMENTS] to get every extracted field as one JSON object per line (those three are the README's own examples). And the module, which the README says "is currently undocumented" but which every tutorial used anyway: import snscrape.modules.twitter as sntwitter, then for tweet in sntwitter.TwitterSearchScraper("query since:2022-01-01 until:2022-02-01").get_items(): with a counter to stop at N, appending tweet.date, tweet.id, tweet.rawContent, tweet.user.username, tweet.likeCount and friends to a list that became a DataFrame. The twitter.py source defines ten scraper classes with CLI names: twitter-search, twitter-user, twitter-profile, twitter-hashtag, twitter-cashtag, twitter-tweet, twitter-list-posts, twitter-community, twitter-trends and twitter-users.

What made it popular is also what killed it. snscrape needed no API key and no account because it asked X's own web front end for the same JSON the browser loaded for a logged-out visitor. That was free, fast, had no documented rate limit and returned more fields than the official v1.1 API did. It also meant the data source was X's to remove, and in mid-2023 X removed it. The install still happens because the tutorials still rank; the error on the first run is the next section.

02 — Section

Why snscrape stopped working on X: the guest token, blocked (404), and issue #996

snscrape's Twitter module never authenticated. Before the first real request it called _ensure_guest_token(), which fetched a guest token from https://api.twitter.com/1.1/guest/activate.json (the same call the x.com front end made for anonymous visitors) and then sent that token with each request to the logged-out search, timeline and profile endpoints. The error strings in the module are the two ways that flow can fail: Unable to retrieve guest token when the activation call is refused, and blocked (404) when a data endpoint answers with a 404 for a request that used to work.

June 30, 2023 is the date. That day X required a login to view any post or search, and every snscrape Twitter scraper started returning blocked (404) four times (the module retries four times, then gives up) followed by a ScraperException. The maintainer opened issue #996, "All Twitter scrapes are failing: blocked (404)", the same day and pinned it, with the diagnosis: "This is likely connected to Twitter as a whole getting locked behind a login wall since earlier today. There is no known workaround at this time, and it's not known whether this will be fixable." X lifted the login requirement for viewing a single post a few days later, but the logged-out search and timeline JSON that snscrape read never came back. The thread now has over 160 comments, is still open, and comments posted in 2026 report the identical error.

Logged-in scraping was ruled out two years earlier. Issue #270, "Support for authentication", was opened by the maintainer in August 2021 and closed as wontfix with the reasoning that "snscrape has always been about making it easier to programmatically access _public_ social network service content", that accounts used for scraping would be banned, that testing with accounts was unreliable for the same reason, and that handling credentials safely would need substantial changes. So the one route that could have kept the module alive was out of scope by design, and the project did not change its mind after #996.

The duplicates tell you how often people still hit this: #997 "Yesterday i can, but today 404" (July 2023), #1003 "i am getting 404 block", #1013 "Does the Scrapper even work after Elon's API updates?", #1018 "ScraperException Error", #1048 "CRITICAL:snscrape.base:Errors: blocked (404), blocked (404)..." (July 2024), #1052 (December 2024) and #1056 "New 443 SSL Error" (February 2025), each closed as a duplicate of #996. A second open bug from the same day, #991, is the KeyError: 'timeline' crash on the twitter-profile scraper, which is the same login wall seen from a different endpoint.

So the diagnosis for a snscrape not working search does not depend on your setup: it is not your Python version, not a proxy, not the development branch (pip install git+https://github.com/JustAnotherArchivist/snscrape.git has the same module), and not a --retries or -vv flag. The PyPI release is 0.7.0.20230622 from June 22, 2023, eight days before the wall; the code asks X for data X no longer serves without a session. The rest of this page is what to do about it.

03 — Section

What snscrape did that an API does not, and the three routes forward

Be clear about the trade before writing code. snscrape was free, keyless and offline-installable, and it covered eight networks with one interface. A hosted Twitter (X) API gives up all three of those: you need a key, every tweet returned is billed, and the API covers X only. If your snscrape pipeline also pulled Reddit, Telegram or Mastodon, those modules are a separate question (the Reddit module uses Pushshift, which has its own history); this page only replaces the Twitter half.

RouteWhat it isWhat it needsWhat breaks it
snscrape itself, or a forkThe archived-in-practice Twitter module, or a GitHub fork that patches the token stepPython, patienceX's login wall: every fork that only patches _ensure_guest_token() still ends in blocked (404)
Cookie-based library (twscrape, Twikit and similar)Python libraries that log in with X accounts you supply and call the GraphQL endpoints the web app uses; the #996 thread's own commenters point to theseReal X accounts, a proxy per account, rotation; one commenter in #996 reports about 50 search requests per account per 15 minutesX rate-limits, locks or suspends the accounts; GraphQL query IDs change; you maintain all of it, which is the burden the maintainer declined in #270
Hosted read API (this page)GET https://api.twitterapi.io plus the endpoint path, X-API-Key header, JSON outAn API key from the dashboard; no X account, no OAuth appPay per tweet returned: 15 credits each, $0.15 per 1,000
Official X API (pay-per-usage)api.x.com v2 with an approved developer app and OAuthDeveloper account, app approval, card on file$0.005 per post read, $0.010 per user read, capped at 3 million post reads a month (docs.x.com pricing)

The cookie libraries are the closest in spirit to snscrape: they act as logged-in accounts, so the account risk snscrape deliberately avoided is now yours, and the parser-chasing-the-front-end maintenance that killed the Twitter module is still there, in a library that is currently kept up. For a one-off academic pull that can be fine; for anything scheduled, the cost section's arithmetic usually says to pay for the reads instead.

The rest of this guide assumes the hosted-API route, because it is the one that maps onto snscrape's scrapers and fields without you operating accounts. The official X API is the other no-maintenance option and is in every cost table so you can see what it charges for the same reads. Python users coming from Tweepy rather than snscrape have their own Tweepy migration guide.

04 — Section

The migration table: every snscrape scraper, mapped to an endpoint

Each row is one scraper from snscrape/modules/twitter.py with its CLI name, and the request that replaces it. Endpoint paths are relative to https://api.twitterapi.io, all are GET with the X-API-Key header, and the response shapes are the ones in docs.twitterapi.io: advanced_search returns a top-level tweets array with has_next_page and next_cursor; last_tweets nests the list at data.tweets with the same two pagination fields at the top level and wraps errors as status: "error" with the reason in msg; user/info returns the profile in data.

snscrape scraper (CLI / class)What it didtwitterapi.io replacement
twitter-search "q" / TwitterSearchScraper(q)Live search results for a query, newest first, with X's operators in the stringGET /twitter/tweet/advanced_search?query=q&queryType=Latest&cursor= (top-level tweets[], has_next_page, next_cursor, up to 20 per page)
--top / TwitterSearchScraperMode.TOPTop instead of live resultsqueryType=Top
TwitterSearchScraperMode.USER / twitter-usersPeople searchGET /twitter/user/search?query= (users[], snake_case fields)
twitter-user handle / TwitterUserScraperA user's posts, newest first, replies includedGET /twitter/user/last_tweets?userName=handle&includeReplies=true (data.tweets; userId= is the more stable key)
twitter-profile handle / TwitterProfileScraperProfile plus posts, pinned post firstGET /twitter/user/info?userName=handle for the profile (data.followers, data.statusesCount, data.createdAt), then last_tweets, whose first page carries data.pin_tweet
twitter-hashtag tag / TwitterHashtagScraperPosts with a hashtagadvanced_search with query=#tag (no separate endpoint)
twitter-cashtag TICKER / TwitterCashtagScraperPosts with a cashtagadvanced_search with query=$TICKER
twitter-tweet id / TwitterTweetScraperOne post, or the surrounding threadGET /twitter/tweets?tweet_ids=id (comma-separated, tweets[]); for the thread, GET /twitter/tweet/thread_context?tweetId=id; for replies, GET /twitter/tweet/replies?tweetId=id
twitter-list-posts id / TwitterListPostsScraperPosts from a listGET /twitter/list/tweets?listId=id (exactly 20 per page; sinceTime / untilTime optional)
twitter-community id / TwitterCommunityScraperPosts in a communityGET /twitter/community/tweets?community_id=id
twitter-trends / TwitterTrendsScraperTrending topicsGET /twitter/trends?woeid=1 (trends[]; a WOEID per location)
--max-results NStop after N itemsPages x 20: stop paginating when you have N rows or has_next_page is false (the wrapper does this)
--since YYYY-MM-DD, or since: / until: inside the queryDate boundssince:YYYY-MM-DD until:YYYY-MM-DD inside query (UTC, until exclusive); for a time of day use since_time: / until_time: with Unix seconds, because the _HH:MM:SS_UTC suffix is not supported
--jsonlOne JSON object per line with every fieldWrite it yourself: the wrapper below prints snscrape-shaped lines
--progress, --retries, -vvOperational flagsNot needed: no token to refresh; handle HTTP 429 with a short sleep

Three details to carry into the code. First, advanced_search pages are "up to 20" tweets because ads and non-tweet cards are filtered out, so count rows, not pages, toward --max-results (in our study every page did hold exactly 20). Second, X search operators work inside query only on advanced_search; last_tweets takes no operators, which is why a dated pull on one account becomes from:handle since: until: on search. Third, a from: search does not return the account's retweets while last_tweets does; our search sample had 0 retweets in 800 tweets and the timelines had a few, so pick the endpoint by whether reposts matter to you. The search operators reference and the search-by-date guide cover the operator grammar in full.

python
# Before: the notebook cell that stopped working in 2023
import snscrape.modules.twitter as sntwitter

rows = []
for i, t in enumerate(sntwitter.TwitterSearchScraper("python pandas since:2023-01-01").get_items()):
    if i >= 500:
        break
    rows.append([t.date, t.id, t.rawContent, t.user.username,
                 t.likeCount, t.retweetCount, t.replyCount, t.quoteCount, t.conversationId])

# After: same loop, same column order, over twitterapi.io (snscrape_compat.py is at the end of this page)
from snscrape_compat import TwitterSearchScraper

rows = []
for t in TwitterSearchScraper("python pandas since:2026-01-01").get_items(max_results=500):
    rows.append([t["date"], t["id"], t["rawContent"], t["user"]["username"],
                 t["likeCount"], t["retweetCount"], t["replyCount"], t["quoteCount"], t["conversationId"]])
# 500 tweets = 25 pages = 7,500 credits = $0.075
05 — Section

Field mapping: the snscrape Tweet dataclass against the twitterapi.io tweet object

This is the table that decides whether your downstream code changes. The left column is the Tweet and User dataclass in snscrape/modules/twitter.py (field names verbatim); the middle is the path in the tweet object that advanced_search and last_tweets return; the right is the share of tweets in our 2026 sample where that path was populated (search / timelines), so you know which columns will be full and which are only filled when the tweet has that thing.

snscrape fieldtwitterapi.io pathPopulated (search / timelines)Note
id (int)id (string)100% / 100%Cast with int() if your schema needs it; strings avoid JavaScript precision loss
urlurl (also twitterUrl)100% / 100%
date (datetime)createdAt100% / 100%Classic format Fri Oct 16 18:13:45 +0000 2026, not ISO; parse with %a %b %d %H:%M:%S %z %Y
rawContenttext100% / 100%Full text; in the search sample the median was 292 characters and 56% exceeded 280
renderedContentnonen/aRebuild by swapping each t.co URL in text for entities.urls[].expanded_url
user.username, user.id, user.displaynameauthor.userName, author.id, author.name100% / 100%Full author object on every tweet, no second lookup
user.followersCount, user.friendsCount, user.statusesCountauthor.followers, author.following, author.statusesCount100% / 100%
user.createdauthor.createdAt100% / 100%Classic format inside tweets; ISO 8601 (2009-08-08T01:26:03.000000Z) from /twitter/user/info
user.verifiedauthor.isVerified, author.isBlueVerified100% / 100%Two flags where snscrape had one
replyCount, retweetCount, likeCount, quoteCountsame names100% / 100%Integers on every tweet
viewCount, bookmarkCountviewCount, bookmarkCount100% / 100%Optional in snscrape; always an integer here in our sample
conversationIdconversationId100% / 100%
langlang100% / 100%77% of the search sample was en
sourceLabelsource100% / 100%e.g. Twitter for iPhone; no sourceUrl
linksentities.urls[] (url, expanded_url, display_url)40% / 77%Empty object when there are none, so use .get("urls", [])
mediaextendedEntities.media[] (type, media_url_https)47% / 52%Types photo, video, animated_gif
hashtagsentities.hashtags[].text32% / 30%
cashtagsentities.symbols[].text0% / 0%Rare on a developer topic
mentionedUsersentities.user_mentions[] (screen_name, id_str, name)10% / 36%
cardcard14% / 35%Link preview card, null otherwise
quotedTweetquoted_tweet (full tweet object)7% / 4%Recurse with the same mapper
retweetedTweetretweeted_tweet (full tweet object)0% / 1%Search returns no retweets; timelines do
inReplyToTweetId, inReplyToUserinReplyToId, inReplyToUsername + inReplyToUserId; isReply16% / 0%0% on timelines because includeReplies defaults to false; set it to true for snscrape's behavior
coordinates, placeplace0% / 0%Empty object on almost every tweet
pinneddata.pin_tweet on the first last_tweets pagen/aTimeline-level, not per tweet
vibe, editState, conversationControlPolicynone (isLimitedReply is the nearest)n/aDo not migrate

Two readings of this table. The 18 core columns a typical snscrape DataFrame used (date, id, rawContent, user.username, the four counts, conversationId, lang) were populated on 100% of all 1,400 tweets in both collections, so a --jsonl-consuming pipeline needs renames, not null handling, for those. The conditional columns are where the two collections differ: links were on 40% of search tweets but 77% of organization timeline posts, mentions 10% versus 36%, and reply fields 16% versus 0% (the default includeReplies=false). Those are properties of what you query, not of the API.

Lollipop chart titled 'Which snscrape columns survive the migration?'. A green band lists 18 core snscrape fields (id, url, date, rawContent, user.username, user.id, user.displayname, user.followersCount, user.created, replyCount, retweetCount, likeCount, quoteCount, viewCount, bookmarkCount, conversationId, lang, sourceLabel) populated on 100% of 1,400 tweets. Below, eleven conditional fields with a blue dot for the search sample and an orange dot for the organization timelines: links to entities.urls 40% / 77%, media to extendedEntities.media 47% / 52%, hashtags 32% / 30%, card 14% / 35%, mentionedUsers 10% / 36%, inReplyToTweetId 16% / 0%, inReplyToUser 15% / 0%, quotedTweet 7% / 4%, retweetedTweet 0% / 1%, cashtags 0% / 0%, place 0% / 0%.
Share of tweets where the twitterapi.io equivalent of each snscrape field is populated: 800 tweets from a `python pandas` search and 600 from three organization timelines, collected in 2026. Core fields were present on every tweet; conditional fields depend on what you query.
06 — Section

What a migrated snscrape search returns today: pages, post mix, text length, pace

We ran the two most common snscrape jobs through their replacements and measured what came back. The twitter-search stand-in was advanced_search with query=python pandas and queryType=Latest, followed for 40 pages; the twitter-user stand-in was last_tweets for @github, @Docker and @ThePSF, 10 pages each with the default includeReplies=false. All 70 data requests went through a client capped at 5 requests per second.

CollectionTweets / pagesPer pageOriginal / reply / quote / retweetText chars: median / p90Over 280With media / linksDays spanned
Search python pandas (Latest)800 / 402077% / 16% / 7% / 0%292 / 1,50356%47% / 40%50
@github timeline200 / 102092% / 0% / 5% / 2%220 / 38933%56% / 60%118
@Docker timeline200 / 102098% / 0% / 2% / 0%289 / 31868%76% / 84%124
@ThePSF timeline200 / 102094% / 0% / 5% / 1%280 / 30748%24% / 86%353

Pages are full. Every one of the 40 search pages and all 30 timeline pages held exactly 20 tweets, so --max-results 500 is 25 requests and the cost math is simply tweets x 15 credits. The docs say "up to 20" because ads and non-tweet cards are filtered out; on these queries nothing was filtered. The last search page still reported has_next_page: true, so a --max-results-less snscrape loop would have kept going; the wrapper stops on your limit, an empty page, or has_next_page false.

Search is not a timeline. The search sample was 77% original posts, 16% replies and 7% quotes, with 0 retweets in 800 tweets, from 413 distinct authors over 50 days. The three organization timelines were 95% originals, 4% quotes and 1% retweets, with no replies because includeReplies was off. snscrape's twitter-user included replies, so pass includeReplies=true if you want its behavior; snscrape's twitter-search excluded retweets too, so that half matches.

Text is long now. snscrape-era code often assumed 280 characters. In the search sample the median text was 292 characters, the 90th percentile 1,503 and the longest 22,480 (an article-length post); 56% of search tweets and 50% of timeline posts exceeded 280. A VARCHAR(280) column or a fixed-width CSV reader will truncate; text holds the whole post, there is no second field to stitch.

Pace. The 71 requests we timed took 170 seconds, 2.4 s per page, through a client that spaces requests at least 0.2 s apart and was shared with other jobs on the same machine at the time; treat it as what a careful script sees, not as an API benchmark. New accounts are limited to 0.2 requests per second until the first payment (20 per second after), so on a free account the same 50 pages take about four minutes.

One account came back empty, and the profile call explained why. Our third planned timeline, @python_org, returned status: "success" with an empty data.tweets and has_next_page: false. GET /twitter/user/info?userName=python_org returned HTTP 200 with data.unavailable: true and unavailableReason: "Suspended" rather than an error status, so check data.unavailable yourself (the wrapper's twitter_profile() does). We replaced the account with @ThePSF (692,722 followers, 6,954 posts per user/info).

Small-multiples figure titled 'What a migrated snscrape job returns: post mix, text length, pace'. Four panels, each with a stacked bar of post kinds and six lines of numbers. Search python pandas: 77% original, 16% reply, 7% quote, 800 tweets over 40 pages, 20 per page, median text 292 characters, p90 1,503, 56% over 280, 47% with media, 40% with links, spans 50 days. @github timeline: 92% original, 200 tweets over 10 pages, median 220, p90 389, 33% over 280, 56% media, 60% links, 118 days. @Docker timeline: 98% original, median 289, p90 318, 68% over 280, 76% media, 84% links, 124 days. @ThePSF timeline: 94% original, median 280, p90 307, 48% over 280, 24% media, 86% links, 353 days. Footer: 71 timed requests in 170 s, 2.4 s per page through a rate-limited client with a 0.2 s floor; all 1,400 tweets cost $0.21 at 15 credits per tweet.
Post mix, page size, text length and pace for the four collections: a `python pandas` Latest search and three organization timelines, collected in 2026. Search excludes retweets; `last_tweets` without `includeReplies` excludes replies.
07 — Section

Cost: snscrape was free, so what does the same job cost, and what would the official X API charge?

snscrape cost nothing in money and, after June 2023, returned nothing. The replacement is metered, so here is the meter. Prices are from the twitterapi.io pricing page and the official X API pay-per-usage page; the ratios are arithmetic, not a benchmark. The study on this page is the first row: 1,400 tweets plus 2 profile lookups and 1 empty page cost $0.21; the same reads at the official rates would be $7.02.

Workload (the snscrape job you had)twitterapi.ioOfficial X API pay-per-usage
This page's study: 1,400 tweets (70 full pages), 2 profiles, 1 empty page$0.21 (21,051 credits)$7.02 (1,400 x $0.005 + 2 x $0.010)
Per unit15 credits per tweet = $0.15 per 1,000 tweets; 15-credit minimum per request; profiles 18 credits = $0.18 per 1,000$0.005 per post read; $0.010 per user read; 3 million post reads a month cap
One full search page (20 tweets)$0.003$0.10
--max-results 1000 twitter-search (50 pages)$0.15$5.00 (about 33x: $5.00 / $0.15)
twitter-user for one account, 200 posts (10 pages)$0.03$1.00
twitter-profile for 1,000 handles$0.18$10.00
10,000 tweets a day, every day (a scheduled snscrape job)$1.50 a day, $45 a month$50 a day, $1,500 a month
100,000 tweets a day$15 a day, $450 a month$500 a day, $15,000 a month, at the 3M-a-month cap
SetupAPI key from the dashboard; no X accountDeveloper account, approved app, OAuth, card on file
Rate0.2 requests/s before the first payment; 20 once paid; 50 on Scale; 500 on BusinessPer-endpoint limits set by X

The worked example most snscrape users care about is a scheduled job: 10,000 tweets a day is 10,000 x 15 = 150,000 credits = $1.50 a day at $1 per 100,000 credits, or $45 over 30 days; the official route for the same reads is 10,000 x $0.005 = $50 a day, $1,500 a month. New accounts that pass the automatic bot check get $1 of free credits (100,000 credits, no card; one per person or network; the credits expire after 31 days), which is about 6,600 tweets, enough to rerun your old snscrape query through the wrapper and diff the two JSON Lines files before paying anything. Subscriptions start at Starter $29 a month; the pricing page has the tiers and bonus credits.

One billing rule changes how you write the loop: you pay per tweet returned, so a narrow query is cheap and an over-broad one is not. A since:-bounded daily pull on a quiet topic costs the 15-credit minimum ($0.00015) on days it finds nothing and $0.003 per full page otherwise. snscrape users who used to pull everything and filter in pandas should move the filter into the query (lang:en, min_faves:, -filter:replies) where it reduces the bill.

08 — Section

Running the compatibility wrapper, and how we collected this

The main code block at the end of this page is snscrape_compat.py. It re-implements the four snscrape entry points most pipelines used, with the same CLI shape (--jsonl, --max-results, scraper name, argument) and classes with the same names and a get_items() generator, so both the shell one-liners and the module loops in old notebooks change by one import. Each item is a dict with snscrape's field names, built by to_snscrape() from the mapping table above; date is ISO 8601 in UTC as snscrape's --jsonl printed it, user is a nested object, and quotedTweet / retweetedTweet recurse. The pandas block shows that pd.read_json(lines=True) on the output reads the same way as before.

The wrapper does not emulate renderedContent, vibe, editState or conversationControlPolicy (nothing to map them to; code that read those columns gets a KeyError rather than a silently empty column), nor twitter-trends, twitter-community or twitter-list-posts, which are one-line additions following the table above.

How we collected this. Question: what does a migrated snscrape job return today, column by column, page by page? Query: python pandas, queryType=Latest, 40 pages of /twitter/tweet/advanced_search; accounts: @github, @Docker and @ThePSF, 10 pages each of /twitter/user/last_tweets with the default includeReplies=false; @python_org was planned, returned an empty timeline, and was replaced after /twitter/user/info reported it unavailable. Window: the most recent posts at collection time (the search sample spanned 50 days, the timelines 118 to 353 days). Sample: 1,400 tweets, 73 requests in total. Collected in 2026 through a read-only client capped at 5 requests per second. A field counts as populated when it is present and not null, not an empty string, list or object; counts of 0 are populated.

What the sample cannot show. One developer-topic query and three organization accounts are not all of X. Reply, media and link shares depend heavily on the query and the account type; organization accounts post more links and fewer replies than individuals, and a consumer topic would have a different language mix than the 77% English we saw. The pace figure reflects a shared rate limiter, not the API's ceiling. We did not test twitter-list-posts, twitter-community or twitter-trends; their rows in the table come from the endpoint reference, not from this run.

Aggregates only. Posts and accounts named on this page are the organization accounts listed above; search authors are not identified, and the raw search pages were not kept beyond the aggregates. The analysis script, results and the gzip-compressed raw timeline pages for the organization accounts are kept with the study.

python
# snscrape --jsonl ... > out.jsonl  ->  the same file from snscrape_compat.py, read the same way
import pandas as pd

df = pd.read_json("out.jsonl", lines=True)
df["date"] = pd.to_datetime(df["date"], utc=True)          # ISO 8601 strings, like snscrape's date
df["username"] = df["user"].apply(lambda u: u["username"])
df["is_reply"] = df["inReplyToTweetId"].notna()
df["is_quote"] = df["quotedTweet"].notna()
df["n_links"] = df["links"].apply(len)
print(df[["date", "username", "likeCount", "retweetCount", "replyCount", "quoteCount"]].describe())
# Columns snscrape had that will be empty here: renderedContent, vibe, editState, conversationControlPolicy.
# Rebuild renderedContent by replacing each t.co URL in rawContent with links[i]["url"].
python
"""snscrape_compat.py - snscrape's Twitter scrapers, re-implemented over twitterapi.io (reads only).

    pip install requests
    export TWITTERAPI_IO_KEY=...                      # key from twitterapi.io/dashboard

    # snscrape --jsonl --max-results 100 twitter-search "python pandas"
    python snscrape_compat.py --jsonl --max-results 100 twitter-search "python pandas"
    # snscrape --jsonl --max-results 200 twitter-user github
    python snscrape_compat.py --jsonl --max-results 200 twitter-user github
    # snscrape --jsonl twitter-profile ThePSF
    python snscrape_compat.py --jsonl twitter-profile ThePSF
    # snscrape --jsonl --max-results 100 twitter-hashtag python
    python snscrape_compat.py --jsonl --max-results 100 twitter-hashtag python

Each line is one JSON object with snscrape's field names (url, date, rawContent, id, user{...},
replyCount, retweetCount, likeCount, quoteCount, conversationId, lang, sourceLabel, links, media,
hashtags, mentionedUsers, inReplyToTweetId, quotedTweet, retweetedTweet), so a downstream
pd.read_json(..., lines=True) keeps working. No X account, no OAuth app.
Cost: 15 credits per tweet returned ($0.15 per 1,000); a full 20-tweet page is $0.003 and a page
that returns nothing is billed the 15-credit minimum ($0.00015). Profiles: 18 credits each.
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime

import requests

HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
CREATED_AT = "%a %b %d %H:%M:%S %z %Y"   # tweet createdAt: "Fri Oct 16 18:13:45 +0000 2026"


def _get(url, params):
    for attempt in range(5):
        r = requests.get(url, params=params, headers=HEADERS, timeout=30)
        if r.status_code == 429:               # 0.2 requests/s before the first payment, 20 after
            time.sleep(2 ** attempt)
            continue
        r.raise_for_status()
        body = r.json()
        if body.get("status") == "error":      # last_tweets and user/info wrap errors; msg has the reason
            raise RuntimeError(body.get("msg") or "twitterapi.io error")
        return body
    raise RuntimeError("rate limited after 5 attempts")


def _iso(s):
    """Classic createdAt -> ISO 8601 like snscrape's `date`. user/info already returns ISO; pass it through."""
    if not s:
        return None
    try:
        return datetime.strptime(s, CREATED_AT).isoformat()
    except ValueError:
        return s


def _user(a):
    """twitterapi.io author object -> the snscrape User fields that have an equivalent."""
    a = a or {}
    return {
        "username": a.get("userName"), "id": a.get("id"), "displayname": a.get("name"),
        "rawDescription": a.get("description"), "verified": a.get("isVerified"),
        "blue": a.get("isBlueVerified"), "created": _iso(a.get("createdAt")),
        "followersCount": a.get("followers"), "friendsCount": a.get("following"),
        "statusesCount": a.get("statusesCount"), "favouritesCount": a.get("favouritesCount"),
        "mediaCount": a.get("mediaCount"), "location": a.get("location"),
        "protected": a.get("protected"), "profileImageUrl": a.get("profilePicture"),
    }


def to_snscrape(t):
    """One twitterapi.io tweet object -> one snscrape Tweet-shaped dict."""
    ent = t.get("entities") or {}
    media = (t.get("extendedEntities") or {}).get("media") or []
    rt, qt = t.get("retweeted_tweet") or None, t.get("quoted_tweet") or None
    return {
        "url": t.get("url"),
        "date": _iso(t.get("createdAt")),
        "rawContent": t.get("text", ""),
        "id": t.get("id"),
        "user": _user(t.get("author")),
        "replyCount": t.get("replyCount", 0),
        "retweetCount": t.get("retweetCount", 0),
        "likeCount": t.get("likeCount", 0),
        "quoteCount": t.get("quoteCount", 0),
        "viewCount": t.get("viewCount"),
        "bookmarkCount": t.get("bookmarkCount"),
        "conversationId": t.get("conversationId"),
        "lang": t.get("lang"),
        "sourceLabel": t.get("source"),
        "links": [{"text": u.get("display_url"), "url": u.get("expanded_url") or u.get("url")}
                  for u in ent.get("urls") or []],
        "media": [{"type": m.get("type"), "url": m.get("media_url_https")} for m in media],
        "hashtags": [h.get("text") for h in ent.get("hashtags") or []],
        "cashtags": [s.get("text") for s in ent.get("symbols") or []],
        "mentionedUsers": [{"username": m.get("screen_name"), "id": m.get("id_str"), "displayname": m.get("name")}
                           for m in ent.get("user_mentions") or []],
        "inReplyToTweetId": t.get("inReplyToId"),
        "inReplyToUser": ({"username": t.get("inReplyToUsername"), "id": t.get("inReplyToUserId")}
                          if t.get("isReply") else None),
        "quotedTweet": to_snscrape(qt) if qt else None,
        "retweetedTweet": to_snscrape(rt) if rt else None,
    }


def _paginate(url, params, tweets_of, max_results):
    """Follow next_cursor like snscrape's get_items(); stop at --max-results or when has_next_page is false."""
    n, cursor = 0, ""
    while max_results is None or n < max_results:
        body = _get(url, {**params, "cursor": cursor})
        tweets = tweets_of(body)
        if not tweets:
            return
        for t in tweets:
            yield to_snscrape(t)
            n += 1
            if max_results is not None and n >= max_results:
                return
        if not body.get("has_next_page") or not body.get("next_cursor"):
            return
        cursor = body["next_cursor"]


class TwitterSearchScraper:
    """sntwitter.TwitterSearchScraper(query) -> /twitter/tweet/advanced_search.
    Operators (since:, until:, from:, lang:, min_faves:, -filter:replies) go inside the query string."""

    def __init__(self, query, top=False):
        self.query, self.top = query, top

    def get_items(self, max_results=None):
        return _paginate("https://api.twitterapi.io/twitter/tweet/advanced_search",
                         {"query": self.query, "queryType": "Top" if self.top else "Latest"},
                         lambda b: b.get("tweets") or [], max_results)


class TwitterUserScraper:
    """sntwitter.TwitterUserScraper(handle) -> /twitter/user/last_tweets (tweets nested in data.tweets).
    includeReplies=true restores the replies snscrape's twitter-user returned."""

    def __init__(self, username, include_replies=True):
        self.username, self.include_replies = username, include_replies

    def get_items(self, max_results=None):
        return _paginate("https://api.twitterapi.io/twitter/user/last_tweets",
                         {"userName": self.username, "includeReplies": str(self.include_replies).lower()},
                         lambda b: (b.get("data") or {}).get("tweets") or [], max_results)


class TwitterHashtagScraper(TwitterSearchScraper):
    """sntwitter.TwitterHashtagScraper(tag) -> the same search with the query "#tag"."""

    def __init__(self, hashtag):
        super().__init__("#" + hashtag.lstrip("#"))


def twitter_profile(username):
    """snscrape twitter-profile handle -> /twitter/user/info. Suspended or missing accounts come back
    with data.unavailable = true (HTTP 200), not an error status."""
    body = _get("https://api.twitterapi.io/twitter/user/info", {"userName": username})
    d = body.get("data") or {}
    if not d or d.get("unavailable"):
        raise RuntimeError(f"{username}: {d.get('message') or body.get('msg')} ({d.get('unavailableReason')})")
    return _user(d)


def main():
    ap = argparse.ArgumentParser(description="snscrape-compatible CLI over twitterapi.io")
    ap.add_argument("--jsonl", action="store_true", help="one JSON object per line (same as snscrape)")
    ap.add_argument("--max-results", type=int, default=None)
    ap.add_argument("--top", action="store_true", help="twitter-search: Top instead of Latest")
    ap.add_argument("scraper", choices=["twitter-search", "twitter-user", "twitter-hashtag", "twitter-profile"])
    ap.add_argument("argument", help="query, handle or hashtag")
    a = ap.parse_args()
    if a.scraper == "twitter-profile":
        print(json.dumps(twitter_profile(a.argument), ensure_ascii=False))
        return
    scraper = {"twitter-search": lambda: TwitterSearchScraper(a.argument, top=a.top),
               "twitter-user": lambda: TwitterUserScraper(a.argument),
               "twitter-hashtag": lambda: TwitterHashtagScraper(a.argument)}[a.scraper]()
    n = 0
    for item in scraper.get_items(a.max_results):
        n += 1
        print(json.dumps(item, ensure_ascii=False) if a.jsonl else item["url"])
    print(f"# {n} tweets, about ${n * 15 / 100_000:.4f} in API reads", file=sys.stderr)


if __name__ == "__main__":
    main()
09 — Questions

Questions readers ask

Is snscrape still working for Twitter in 2026?

No. Every Twitter scraper in snscrape ends in CRITICAL:snscrape.base:Errors: blocked (404), blocked (404), blocked (404), blocked (404) and a ScraperException. The maintainer's pinned issue #996 (opened mid-2023) says there is no known workaround and it is not known whether it is fixable; comments in 2026 report the same error. The last PyPI release is 0.7.0.20230622. The non-Twitter modules are a separate matter.

Why does snscrape say blocked (404)?

snscrape fetched a guest token from api.twitter.com/1.1/guest/activate.json and then called X's logged-out search and timeline endpoints with it. Since X's mid-2023 login wall those endpoints answer 404 to any client without a session; snscrape retries four times and gives up, which is the four blocked (404) lines. No proxy, retry flag or Python version changes that.

Is there a fix or a fork of snscrape that works?

Not one that keeps snscrape's no-account model. The maintainer closed "Support for authentication" (issue #270) as wontfix in 2021, so logged-in scraping is out of scope by design. Forks that patch the token step hit the same wall. Libraries such as twscrape or Twikit work by using X accounts you supply, with the account risk that implies; a hosted read API such as the one mapped on this page needs no X account.

What replaces sntwitter.TwitterSearchScraper?

GET https://api.twitterapi.io/twitter/tweet/advanced_search?query=<q>&queryType=Latest with an X-API-Key header. The query string takes the same operators snscrape passed through (since:, until:, from:, lang:, min_faves:, -filter:replies). The response is a top-level tweets array with has_next_page and next_cursor, up to 20 tweets per page; the snscrape_compat.py wrapper on this page exposes it as TwitterSearchScraper(query).get_items(max_results=N).

How do the snscrape field names map to the API?

rawContent is text, date is createdAt (classic format, parse with %a %b %d %H:%M:%S %z %Y), user.username is author.userName, user.followersCount is author.followers, likeCount / retweetCount / replyCount / quoteCount / viewCount / conversationId / lang keep their names, sourceLabel is source, links is entities.urls, media is extendedEntities.media, quotedTweet is quoted_tweet and inReplyToTweetId is inReplyToId. In our 2026 sample all 18 core fields were populated on 100% of 1,400 tweets. renderedContent, vibe and editState have no equivalent.

How do I get a user's tweets like snscrape twitter-user?

GET /twitter/user/last_tweets?userName=<handle>&includeReplies=true, following next_cursor until has_next_page is false. The tweets are nested at data.tweets (unlike search, where they are top-level), 20 per page, newest first, retweets included. snscrape's twitter-user included replies, so set includeReplies=true for the same behavior; the default is false.

How much does it cost to replace a snscrape job?

15 credits per tweet returned, $0.15 per 1,000 tweets; a full 20-tweet page is $0.003 and an empty page is billed the 15-credit minimum ($0.00015). Profiles are 18 credits each. A --max-results 1000 search is $0.15; the same 1,000 reads at the official X API's $0.005 per post are $5.00. New accounts that pass the automatic bot check get $1 of free credits (no card, one per person or network, expiring after 31 days).

Does the replacement cover the other networks snscrape scraped?

No. twitterapi.io reads X (Twitter) only. snscrape's Facebook, Instagram, Mastodon, Reddit, Telegram, VKontakte and Weibo modules are not replaced by this page; each has its own status and its own alternatives.

10 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key