# Get tweets from a Twitter/X account with Python (2026)

> Python code that downloads every tweet an X account still shows: one page, all pages, a CSV file and a daily update that only fetches new tweets. Node.js version included.

HTML page: https://www.twittrapi.com/blog/get-tweets-from-twitter-account-python
Published 2026-10-11.

This guide downloads the tweets of one X account with Python: first a single page, then the whole timeline, then a CSV file and a script you can run every day that only asks for tweets it has not seen. It uses TwittrAPI, a third-party API for public X data, so you need an API key from us but no X developer account and no X login. The same steps are shown for Node.js at the end.

What you get back is what X shows on the profile, newest first. Replies are left out unless you ask for them, and deleted or withheld tweets are not returned.

## Step 1: Get an API key

Create a key on [/start](https://www.twittrapi.com/start). New accounts get $0.10 of test credit for 14 days, which is about 1,100 calls. Put the key in an environment variable so it stays out of your code:

```bash
export TWITTRAPI_API_KEY="your-key"
```

Every request sends it in the `X-API-KEY` header. One request is one billed call, whatever it returns; the [pricing page](https://www.twittrapi.com/pricing) has the rates, from $0.065 per 1,000 calls.

## Step 2: Fetch the first page of tweets

The endpoint is [`GET /user/{userId}/tweets`](https://www.twittrapi.com/docs/reference/user-userid-tweets). It takes a numeric user ID, but you can pass `-1` as the ID and the handle as `username` instead. With `format=simple` you get flat JSON with a fixed set of fields ([simple format](https://www.twittrapi.com/docs/simple-format)).

With nothing but `requests`:

```python
import os

import requests

API = "https://api.twittrapi.com"
HEADERS = {"X-API-KEY": os.environ["TWITTRAPI_API_KEY"]}

r = requests.get(
    f"{API}/user/-1/tweets",
    headers=HEADERS,
    params={"username": "NASA", "count": 20, "format": "simple"},
    timeout=30,
)
r.raise_for_status()
page = r.json()

for t in page["data"]:
    if t.get("unavailable"):
        continue
    print(t["createdAt"], t["counts"]["likes"], t["text"][:100])

print("more pages:", page["hasNextPage"])
```

Each tweet has `id`, `url`, `text`, `createdAt`, `lang`, `author`, `counts` (likes, retweets, replies, quotes, bookmarks, views), `media`, `urls`, `hashtags`, `replyTo`, `quoted` and `retweetOf`. The [field table](https://www.twittrapi.com/docs/simple-format#tweet) lists them all.

`count` is a hint. X decides how many tweets a page really holds, so do not rely on getting exactly 20.

## Step 3: Follow the cursor to get all pages

Every page carries `nextCursor`. Send it back as `cursor` to get the next page. When `hasNextPage` is `false`, you have everything X will return.

Figure: paging through tweets with a cursor. Call /user/-1/tweets, pass the returned cursor back as `cursor` for the next page, and stop at the last page. Every page is one call ($0.065 to $0.09 per 1,000 calls).

```python
def all_tweets(username: str, max_pages: int = 200):
    """Yield every tweet on the profile, newest first."""
    cursor = None
    for _ in range(max_pages):
        params = {"username": username, "count": 100, "format": "simple"}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(f"{API}/user/-1/tweets", headers=HEADERS, params=params, timeout=30)
        r.raise_for_status()
        page = r.json()
        for t in page["data"]:
            if not t.get("unavailable"):
                yield t
        if not page["hasNextPage"]:
            return
        cursor = page["nextCursor"]
```

`max_pages` is a safety limit: every page is a billed call, so a typo in a loop should not run forever.

How far back this goes is up to X. Profile timelines stop after a few thousand tweets; scraper projects such as [twscrape](https://github.com/vladkens/twscrape) report about 3,200. For older tweets, use [search](https://www.twittrapi.com/blog/search-twitter-without-developer-account) with `from:NASA until:2020-01-01`, which walks back by date instead.

## Step 4: Use the Python SDK instead (optional)

The [`twittrapi` package](https://www.twittrapi.com/docs/integrations#sdks) wraps the same calls. It reads the key from `TWITTRAPI_API_KEY`, retries 429 and 5xx responses with backoff, and `paginate` follows the cursor for you:

```bash
pip install twittrapi
```

```python
from twittrapi import TwittrAPI

client = TwittrAPI(response_format="simple")

tweets = []
for page in client.paginate(client.users.tweets, username="NASA", count=100, max_pages=50):
    tweets += [t for t in page["data"] if not t.get("unavailable")]

print(len(tweets), "tweets")
```

`max_pages` defaults to 10. Pass `None` to fetch until the cursor runs out. If you also want the account's replies, use `client.users.tweets_and_replies` ([`GET /user/{userId}/tweetsAndReplies`](https://www.twittrapi.com/docs/reference/user-userid-tweetsandreplies)); for photos and videos only, `client.users.media` ([`GET /user/{userId}/media`](https://www.twittrapi.com/docs/reference/user-userid-media)).

## Step 5: Save the tweets to CSV

Nested fields such as `counts` need to be flattened into columns. Keep the ID as text: spreadsheet apps round 19-digit numbers.

```python
import csv

FIELDS = ["id", "createdAt", "text", "likes", "retweets", "replies", "quotes", "views", "lang", "url", "isReply", "media"]

with open("nasa_tweets.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=FIELDS)
    w.writeheader()
    for t in tweets:
        c = t["counts"]
        w.writerow({
            "id": t["id"],
            "createdAt": t["createdAt"],
            "text": t["text"],
            "likes": c["likes"],
            "retweets": c["retweets"],
            "replies": c["replies"],
            "quotes": c["quotes"],
            "views": c["views"],
            "lang": t["lang"],
            "url": t["url"],
            "isReply": t["replyTo"] is not None,
            "media": " ".join(m["url"] for m in t["media"]),
        })
```

`encoding="utf-8"` matters: tweets contain emoji and non-Latin text. If Excel shows garbled characters, open the file through Data > From Text/CSV and pick UTF-8, or write it with `encoding="utf-8-sig"`.

## Step 6: Fetch only new tweets on later runs

For a daily job you do not need the whole timeline again. Timelines are newest first, so stop as soon as you reach a tweet you already have. A pinned tweet sits at the top of the first page regardless of its date, so skip it when deciding where to stop.

```python
import json
from pathlib import Path

STATE = Path("nasa_last_id.json")
last_id = int(json.loads(STATE.read_text())["last_id"]) if STATE.exists() else 0

new = []
done = False
for page in client.paginate(client.users.tweets, username="NASA", count=100, max_pages=20):
    for t in page["data"]:
        if t.get("unavailable") or t["isPinned"]:
            continue
        if int(t["id"]) <= last_id:
            done = True
            break
        new.append(t)
    if done:
        break

if new:
    STATE.write_text(json.dumps({"last_id": max((t["id"] for t in new), key=int)}))
print(len(new), "new tweets")
```

Tweet IDs grow over time, so comparing them as integers is a safe way to tell old from new. A run with nothing new costs one call.

## Node.js version

The [`twittrapi` npm package](https://www.twittrapi.com/docs/integrations#sdks) has the same methods (Node 18 or later):

```bash
npm install twittrapi
```

```js
import { TwittrAPI } from "twittrapi";

const client = new TwittrAPI({ format: "simple" }); // key from TWITTRAPI_API_KEY

const tweets = [];
for await (const page of client.paginate(
  client.users.tweets,
  { username: "NASA", count: 100 },
  { maxPages: 50 },
)) {
  for (const t of page.data.data) {
    if (!t.unavailable) tweets.push(t);
  }
}

console.log(tweets.length, "tweets");
```

Without the SDK, use `fetch`:

```js
const res = await fetch(
  "https://api.twittrapi.com/user/-1/tweets?username=NASA&count=20&format=simple",
  { headers: { "X-API-KEY": process.env.TWITTRAPI_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const page = await res.json();
console.log(page.data.length, page.hasNextPage, page.nextCursor);
```

## Errors you may see

| Status | Meaning | What to do |
| --- | --- | --- |
| 401 | Missing or wrong key (not charged) | Check the `X-API-KEY` header |
| 402 | Balance used up or trial expired (not charged) | Top up in the dashboard |
| 404 | X says the account does not exist (charged) | Check the handle with [`GET /user/by/username/{username}`](https://www.twittrapi.com/docs/reference/user-by-username-username) |
| 429 | Over your requests per second (not charged) | Wait and retry |
| 5xx | Failure on our side or between us and X (refunded) | Retry with backoff |

The full list is in [error handling](https://www.twittrapi.com/docs/helpful-infos/error-handling). Protected accounts return no tweets: the API only reads what a logged-out visitor could see.

## Frequently asked questions

### Can I get tweets from a Twitter account in Python without the official API?

Yes. A third-party API such as TwittrAPI returns public tweets of any account over plain HTTPS with its own key, so you do not need an X developer account or app. Scraping the website yourself also works at small scale, but X's terms forbid it without consent and it breaks often.

### How many tweets can I get from one account?

As many as X still shows on the profile timeline, which ends after a few thousand tweets. For older tweets, search with from:handle and a since: and until: date range, one window at a time.

### How much does it cost to download 3,000 tweets?

A page holds up to about 100 tweets when you ask for count=100, often fewer. 3,000 tweets take roughly 30 to 150 calls, which is about one cent or less at TwittrAPI's per-call prices.

### Does this include replies and retweets?

The tweets endpoint returns the account's posts and retweets, without replies. Use the tweets-and-replies endpoint to include replies. Retweets have a retweetOf field with the original tweet.

### Is twint or snscrape still an option in 2026?

Not for X. Both relied on logged-out access that X closed in 2023; their issue trackers have been full of "blocked" reports since then. Our post on [scraping X in 2026](https://www.twittrapi.com/blog/how-to-scrape-twitter-x-data) covers what still works.

## Sources

- [User tweets reference](https://www.twittrapi.com/docs/reference/user-userid-tweets) and [simple format](https://www.twittrapi.com/docs/simple-format) (TwittrAPI docs)
- [twscrape README](https://github.com/vladkens/twscrape), on the timeline limit
- [snscrape issue #996](https://github.com/JustAnotherArchivist/snscrape/issues/996), "All Twitter scrapes are failing"

TwittrAPI is not affiliated with X Corp. It reads only public data.
