You need: a provisioned environment, and Cloudflare credentials for anything but dev.
Three ways to get a D1 back, in the order to reach for them. pithy backup and pithy restore are the commands. This covers D1 only — KV, R2 and Durable Object storage are not in any of it.
First: D1 Time Travel
Time Travel is the backup you already have. Cloudflare keeps every D1’s history and restores it to any minute inside the window — 30 days on a paid Workers plan, 7 on Workers Free. Nothing to turn on, nothing to schedule, no extra charge.
wrangler d1 time-travel info <database> --timestamp=<when>
wrangler d1 time-travel restore <database> --timestamp=<when>Reach for it first when the database is still there and something wrote over it — a bad migration, a script run against the wrong environment, a delete with no WHERE. It works in place, it is consistent to one moment, and a restore can itself be undone.
What it cannot be is an offsite copy. It lives in the same account as the database it protects, it ends at the window, and it goes when the database does.
An offsite copy: pithy backup
pithy backup --env prodThat writes one folder per D1 the environment’s Workers bind, under backups/prod-<timestamp>/. Each holds the rows (data.sql), what no migration makes (schema.sql), the migrations the database had applied (migrations.json), and a manifest.json that counts every table and says whether D1 can take the files back. The schema is not dumped: the migrations make it again on restore, so the copy is the data plus the record of which schema it belongs to.
It backs up a database holding an FTS5 index, which a plain wrangler d1 export cannot. The index, its shadow tables and the triggers that feed it are left out, found from the schema, and rebuilt on restore — support’s from pithy_support_messages, so search answers what it answered before.
Three things to know before you schedule it:
- It is not a snapshot. Tables are read in pages, one after another, so a write during the run can leave a child row without the parent it was read after. For a copy consistent to one moment, Time Travel is the answer. Run
pithy backupwhen writes are quiet. - It holds every row — emails, session tokens, OAuth tokens, password hashes. The files are
0600and the folders0700, and a project scaffolded bypithy initignoresbackups/. Where the copy goes next is yours or CI’s to decide; the command uploads nothing. - A row over D1’s 100,000-byte statement limit is kept, and the run exits 1. The files are still a complete copy
sqlite3can import, but D1 cannot take that database back, and the manifest says so.
Bringing it back: pithy restore
pithy restore backups/prod-2026-10-27T12-00-00Z --env prod --binding DB --database recoveryIt only ever writes into an empty database. --database recovery finds or creates <project>-prod-recovery and restores into it. Point the binding’s database_id at it in wrangler.jsonc and deploy, once you have looked at what came back. Overwriting a live database is Time Travel’s job, not this one’s.
It refuses a backup whose migrations differ from this CLI’s, in either direction, naming both kit versions and the keys only one side has. A dump is only good against the schema it came from, so restoring an old backup means running pithy restore from the kit version that took it.
Then it migrates, makes the tables no migration owns, loads the rows with every trigger out of the way, puts the triggers back and rebuilds support’s index. A virtual table of your own comes back empty, and the output names it: only you know how to refill it.
Without the CLI: the bare wrangler route
Possible, and easy to get wrong. A plain wrangler d1 export of a database holding an FTS5 table fails outright, so the usual answer is to export table by table with --table, leaving the virtual ones out. Done by hand against a Pithy database, that goes wrong in six places:
- Shadow tables. Support’s FTS5 table is
pithy_support_search, and its shadow tables —pithy_support_search_data,_idx,_content,_docsize,_config— are ordinary tables. A filter on a name pattern exports them, and a restore then fights the index it rebuilds. - Cloudflare’s own tables.
_cf_*tables are not yours. Leave them out. - Triggers that write into an index. Support’s three triggers sit on
pithy_support_messagesand insert intopithy_support_search. Restore them without the index and every message write fails. - Order. Schema, then data, then indexes and triggers — and parents before the tables that reference them — or the import fails part way on a foreign key.
- Version. Rows from one release loaded into tables a later migration reshaped fail, or land in the wrong columns. Record which migrations the database had applied, and restore into exactly those.
- The statement limit. An export writes each row as one
INSERT, and D1 refuses a statement of 100,000 bytes or more, so one long row makes a file D1 cannot import.
And one that is not about Pithy at all: wrangler d1 defaults to local. Without --remote, an export reads .wrangler/state, reports success, and copies nothing of production.