tg-export downloads all media from a Telegram group to local disk, one
folder per logical post (albums collapsed), with a messages.jsonl sidecar that
links every file back to its message. Resumable, filterable, with a dry-run size
estimate.
export TG_API_ID=... TG_API_HASH=...
tg-export --group @mygroup --out ./exports --dry-run # what would it fetch?
tg-export --group @mygroup --out ./exports # fetch itKill it at any point and re-run the same command; it resumes without re-downloading and without gaps.
This is the first question most people ask, so: a bot cannot read a group's message history at all, and the Bot API caps file downloads at 20 MB. Both limits are structural, not configuration. Exporting history therefore requires MTProto with a user account, which is what Telethon provides.
Python 3.12+.
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -e .
.venv/bin/tg-export --helprequirements.txt pins the exact Telethon version the API probes were verified
against. Telethon 2.0 is alpha with a renamed API surface, hence the <2 bound.
cryptg is optional and not installed. It is a native extension sitting in
Telethon's AES path, so it sees the auth key and every plaintext byte. It ships an
aarch64 wheel (no build step), and the reason to trust it if you adopt it is
that the Telethon author maintains it — not that it is "CPU-side only". Do not add
a supply-chain root for a throughput problem you have not measured.
Create an app at https://my.telegram.org → API development tools.
| Credential | Source | Why |
|---|---|---|
api_id, api_hash |
env TG_API_ID / TG_API_HASH only |
needed every run; revocable |
| phone | prompt; TG_PHONE optional |
PII, not a secret |
| login code | prompt only | single-use, 5-minute TTL |
| 2FA password | getpass only — no env, no file, no flag |
once per session lifetime |
.env may contain TG_API_ID, TG_API_HASH, TG_PHONE. It must not
contain the 2FA password, a login code, or session data. The app does not read
.env — your shell already does that:
set -a; . ./.env; set +aThe first run prompts for your phone, the login code Telegram sends you, and your 2FA password if you have one. Later runs are non-interactive.
The session file defaults to
${XDG_STATE_HOME:-~/.local/state}/tg-export/default.session — outside the
repo, so the safe location needs no opt-in. It and every SQLite sibling are mode
0600, enforced by a umask(0o077) set before anything can create a file, plus a
post-login assertion. A chmod after connecting would be too late: SQLite writes
the auth key during login.
If you point --session or --out inside a git repo at a path that is not
gitignored, the run refuses with an explanation. Deleting a session file does
not revoke it server-side — use Telegram → Settings → Devices for that.
exports/g-1001234567890/ <- directory name is the chat id, never the group title
title.txt <- the human-readable name lives here, as data
1042/ <- one album (shared grouped_id); folder = first member id
1042_photo.jpg <- filename keyed on message id, not a positional index
1043_photo.jpg
1044_video.mp4
1047/ <- a standalone message
1047_document.pdf
messages.jsonl
.export-state.json
.export.lock
The directory is g<chat_id> and not the group title because the title is
server-supplied and editable by any admin. A title of .. or a/b would be a
path-traversal vector that no filename sanitizer could catch, since the escape
happens in a parent component. Naming the directory after an int makes that
structurally impossible — and also means a mid-export group rename cannot orphan
your download. The cost is that ls exports/ shows numbers.
Filenames are keyed on the message id rather than a position in the album, because a positional index shifts when a member message is deleted between runs, silently orphaning files already on disk.
.export-state.json holds one cursor, and it means:
every post with
max_message_id <= cursor_idhas been handled under these filters.
Consequences worth knowing:
-
The cursor is written only after a post is completely handled, and never from an error path. It can never point past in-flight work.
-
Filters are stored verbatim. Changing them refuses the run and prints exactly what changed, because the cursor's meaning depends on them:
filter mismatch: types: ['photo', 'video'] -> ['video'] max_size: 100000000 -> (none) re-run with the original filters, or --reset-state to start over -
--reset-statediscards the cursor and rotatesmessages.jsonltomessages-<utc-timestamp>.jsonl. It does not delete downloaded files — dedupe reclaims them on the next run, and deleting 30 GB to change a filter would be hostile. -
--limit Nis not part of the filter set. It changes when we stop, not what a post contains, so a--limit 20test run is a genuine resumable partial export that does not poison later full runs. -
completed_atis set only when a run exhausts the history cleanly. It is the only way to answer "did my 20-hour export actually finish?". -
A file that fails is recorded and skipped, and the cursor moves past it. The run continues and the exit summary names the count (
N files failed (see messages.jsonl)). Media that was deleted or expired is genuinely gone, but a file that exhausted its retries is not re-attempted by a later resume — greperrorsinmessages.jsonlto find them, and use--reset-stateto re-drive that range if it matters.
{"message_id":1043,"post_id":1042,"grouped_id":88,"date":"2026-07-04T12:00:00+00:00",
"sender_id":777,"sender_name":"Alice","caption":"beach trip","reply_to":1001,
"files":[{"path":"1042/1043_photo.jpg","size":1234567,"name":"photo.jpg",
"mime":"image/jpeg","kind":"photo","declared_size":1234999}],
"errors":[]}Each record is an append-only event: "the files written for this message in this
run." So the correct way to read the sidecar is the union of all records for a
message_id, never last-write-wins — after a filter change, the most recent
record deliberately describes a narrower slice than what is on disk.
sizeis bytes on disk;declared_sizeappears only when the server's number differs (see the photo note below).- Paths are relative to the export root.
sender_nameis filled in only when the sender was already cached. Resolving it otherwise would mean one extra RPC per message — 100k RPCs and a flood ban on a large group, in exchange for a display string.- A partial final line from a host crash is truncated and warned about at startup.
- With
--include-text, text-only messages get the same record shape with"files": []. Distinguish media from context byfilesbeing empty, not by a record type.
tg-export --group <id|@username|link> --out DIR
[--dry-run] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
[--types photo,video,document,audio,voice] [--max-size 100MB]
[--include-text] [--limit N] [--session PATH] [--reset-state]
[--min-free 2GiB] [--max-flood-wait SECONDS] [--verbose]
--include-textis off by default. When set, text-only messages are recorded in the sidecar with an emptyfiles[]so captions and replies keep their surrounding conversation. They never create a folder and never download anything. It is part of the stored filter set — it changes what a post contains — so toggling it requires--reset-state.--verboseraises the log level for this tool only. Thetelethonlogger is pinned atWARNINGunconditionally, because its request logging carriesapi_id,phone_numberandphone_code_hash. There is deliberately no--debug-telethon.- A link preview is not media: a message containing a URL is not treated as an attachment, so preview thumbnails are never downloaded.
Telegram throttles by telling the client to wait, and those waits escalate from
seconds to hours if you keep pushing. By default tg-export sleeps as long as
Telegram demands, so an unattended export completes rather than failing fast.
Every wait is logged at WARNING with its duration and its computed wake
time:
2026-08-11 07:42:11 WARNING flood wait 3600s - sleeping until 2026-08-11T08:42:11+00:00
A long sleep is a sleep, not a hang. Read the log before assuming otherwise. Pass
--max-flood-wait SECONDS to exit cleanly and resumably (code 6) instead.
Telethon does not parallelize a single transfer, and running several at once
mainly accelerates flood-wait escalation — a 7-second wait becomes a 4-hour
lockout. There is deliberately no --workers flag, and an AST test in the suite
fails if gather, create_task, to_thread or a thread pool ever appears in
src/.
--dry-run reports remaining posts, files and bytes by media type against free
space, and exits 2 if it will not fit.
That verdict is advisory. The enforcement is a per-file check before each
download, comparing free space against this file's size plus the --min-free
reserve. So a SHORT verdict does not mean the run is unsafe — it means the run
will stop partway with a clean, resumable cursor.
Photo totals are labelled approximate because a photo's declared .size
describes the size variant Telethon selected. Documents, video and audio carry an
exact size. Media reporting no size at all is downloaded anyway and counted on its
own unknown size line rather than as zero, so the estimate never hides an
unbounded error.
--dry-run reads the cursor and measures from where a real run would start, so
tg-export --dry-run && tg-export works on a partially completed export. It takes
no lock, creates no directories, and mutates nothing — you can run it while a real
export is in progress.
One run per export root. A second run against the same --out exits 3 and names
the holder's pid, host and start time. flock releases on process death, kill -9
included, so there is no stale lock to clean up.
Exporting two different groups at the same time works, but each needs its own
--session as well as its own --out: the session file is a SQLite database and
is locked too, because two Telethon clients sharing one session is a corruption
hazard Telethon itself warns about.
--dry-run takes no lock at all, so it can always be run against an export that
is currently in progress.
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | unexpected error, or a bad argument |
| 2 | --dry-run says it will not fit |
| 3 | disk exhausted, or another run holds the lock |
| 4 | session invalidated — re-login |
| 5 | lost access to the group |
| 6 | flood wait exceeded --max-flood-wait (only when you set it) |
| 7 | filter or chat mismatch against the existing state file |
| 130 | interrupted — re-run to resume |
.venv/bin/pip install -r requirements.txt -e ".[dev]"
.venv/bin/pytestThe whole suite runs offline against synthetic message stubs — no credentials, no
network. Note that messages.jsonl contains message text and sender names from
other people; the export tree is gitignored and the runtime check refuses an
un-ignored path inside a repo, but check git status before any commit.