Two README statements were false rather than merely incomplete. "--dry-run takes no lock at all, so it can always be run against an export that is currently in progress" contradicted the paragraph four lines above it warning that two clients sharing one session is a corruption hazard: dry-run opens the session file like any other run. Both places now say what is true - no *export* lock, but a session lock, so an alongside dry-run needs its own --session. The exit table promised 1 for a bad argument. argparse exits 2 on its own, before main()'s try block can map anything, so a bad --limit collided with the dry-run SHORT verdict. Documented as the shared code it is instead of a code the tool never returns. Also notes that both locks are flock-based, and that flock is advisory or per-client on NFS, so single-instance enforcement is not guaranteed there. Setup gains the venv creation step it assumed. Phase 7 gains step 12b: measure the sweep cost under a date filter before deciding whether to optimize it. With reverse=True and no offset_date, telethon starts the sweep at message id 1 - verified in the pinned wheel - so --since walks the whole history discarding messages in keep(), and --until never terminates early. Neither fix is free: offset_date can start the sweep mid-album, which would take a post_id that is not the album's lowest id and break filter-invariant identity, and an --until break assumes dates rise with ids, false for imported history. The swept-to-kept ratio decides whether either risk is worth taking. Reports from the review pass are recorded under plans/reports/, including the findings left unfixed: resolve_entity catching only ValueError, _find_in_dialogs being the one network loop outside a flood primitive, SystemExit in session.py routing around the Abort contract, and completed_at being cleared before any work begins.
telegram-exporter
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 it
Kill it at any point and re-run the same command; it resumes without re-downloading and without gaps.
Why the Bot API cannot do this
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.
Install
Python 3.12+.
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -e .
.venv/bin/tg-export --help
requirements.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.
Credentials
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 +a
First run and the session file
The 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.
Output layout
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.
Resume semantics
.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.
messages.jsonl
{"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.
Flags
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.
Flood waits have no ceiling by default
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.
Why downloads are sequential
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/.
Disk space
--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 export lock, creates no directories, and mutates nothing. It does open the
session file, so running it alongside an export in progress needs its own
--session — see Concurrent runs.
Concurrent runs
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 export lock, so it never contends for an export root. It
does open the session file and therefore takes the session lock: to run it
alongside an export in progress, give it its own --session.
Both locks are flock-based. On NFS and some network filesystems flock is
advisory-only or silently per-client, so single-instance enforcement is not
guaranteed there — keep the session file and the export root on local storage.
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | unexpected error |
| 2 | --dry-run says it will not fit, or a bad/missing argument — argparse exits 2 too |
| 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 |
Development
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -e ".[dev]"
.venv/bin/python -m pytest # 214 tests, ~3 s
The 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.