Skip to content
sloop

Backups

Backups, retention and restoring

Take a copy, prove every row arrived, delete what is past its retention, and put one back. And know the difference between a run that finished and a run that finished checked.

sloop backup shop dumps the database, counts what arrived, and writes a manifest beside it. Everything else on this page is a variation on that sentence — more than one database, keeping fewer of them, or going the other way.

Showing Linux. Change it in the strip above and every path on this page follows.

The key comes first, once

Backups are encrypted, and the first one will not run until the key has been copied somewhere sloop cannot reach. This is not a nag that can be dismissed and forgotten — it is a refusal, and with no terminal to ask at it exits 2:

exit 2
$ sloop backup shop this registry has a new backup key age1q7v3mx0kz9rw2ld5htn8jc4pf6ys0ueg3a2xk9msv4rzq3ltdn6shw8cjp backups from here on are encrypted to it. The private half is on this machine and nowhere else, so if this machine is lost, every one of those backups is unreadable — there is no recovery, no reset and nobody to ask. `sloop key export > backup-key.txt` writes it out. Keep that somewhere else.error: the backup key has not been copied anywhere yet, and there is no terminal to ask at hint: run `sloop key export` once, then this run will go through

sloop key export once, and every run after it goes through. The backup key is the page for what that key is and what losing it costs; everything below assumes it has been done.

Taking one

one database
$ sloop backup shopshop postgres://app@127.0.0.1:5443/shop✓ Connected postgres 17.9, 2 tables, 10601 rows✓ Dumped 2 tables 67.7 kB, encrypted✓ Checked e68d1629c48f backed up to /home/you/projects/acme-api/.sloop/backups/postgres/shop/20260919T140939Z 66.1 KiB in 0.4s, sha256 e68d1629c48f taken 2026-09-19 20:09:39 +06:00 (2026-09-19T14:09:39Z)

Two timestamps, both deliberate. The directory is named in UTC so backups sort correctly and survive a daylight-saving change; every line a human reads is in local time. The sha256 is over the dump as written, and backups list --check compares it again later.

Every database, and the one that fails

--all is the one you schedule, and its rule is the important one: it never stops on a failure. One unreachable database in a set of thirty must not cost you the other twenty-nine.

three of four
$ sloop backup --allarchive postgres://app@127.0.0.1:5443/archive✓ Connected postgres 17.9, 0 tables, 0 rows✓ Dumped 0 tables 1.1 kB, encrypted✓ Checked 77d875ac2fcb backed up to /home/you/projects/acme-api/.sloop/backups/postgres/archive/20260919T140947Z 1.1 KiB in 0.3s, sha256 77d875ac2fcb gone postgres://app@127.0.0.1:5443/gone✗ gone: psql.exe failed against postgres://app@127.0.0.1:5443/gone: psql: error: connection to server at "127.0.0.1", port 5443 failed: FATAL: database "gone" does not exist hint: check the host, port, user and password sloop was given: `sloop db list` shows them and `sloop db test <name>` tries them ledger postgres://app@127.0.0.1:5443/ledger✓ Connected postgres 17.9, 1 table, 4210 rows✓ Dumped 1 table 23.2 kB, encrypted✓ Checked b9726982f168 22.7 KiB in 0.2s, sha256 b9726982f168 shop postgres://app@127.0.0.1:5443/shop✓ Connected postgres 17.9, 2 tables, 10601 rows✓ Dumped 2 tables 67.6 kB, encrypted✓ Checked 2698a4bdaec9 66.0 KiB in 0.2s, sha256 2698a4bdaec9 backed up 3 of 4 — 1 failed: gone

The run worked to the end, backed up the three that answered, and named the one that did not. The last line is the one to read in a log — zero, one or several, always counted and always named. The exit code is the failure's own: 3 here, because what went wrong was a connection.

A directory per run, or one kept current

--sequential is the default and is what you saw above: a directory per run, named for the moment it was taken, kept until retention removes it.

--replace keeps exactly one, at a fixed path ending in latest. For a database you want a current copy of rather than a history — and the path never changes, so something else can pick it up without being told where to look.

one copy, fixed path
$ sloop backup ledger --replaceledger postgres://app@127.0.0.1:5443/ledger✓ Connected postgres 17.9, 1 table, 4210 rows✓ Dumped 1 table 23.2 kB, encrypted✓ Checked 07daf4371ce1 backed up to /home/you/projects/acme-api/.sloop/backups/postgres/ledger/latest 22.7 KiB in 0.2s, sha256 07daf4371ce1 taken 2026-09-19 20:10:06 +06:00 (2026-09-19T14:10:06Z)

What is written to disk

Inside the registry the database belongs to — /home/you/projects/acme-api/.sloop/backups for this project, or /home/you/.sloop for the global one. Backups never leave the registry that took them.

two files per backup
$ cd /home/you/projects/acme-api/.sloop/backups$ find . -type f./postgres/archive/20260919T140947Z/dump.age./postgres/archive/20260919T140947Z/manifest.json./postgres/ledger/20260919T140948Z/dump.age./postgres/ledger/20260919T140948Z/manifest.json./postgres/shop/20260919T140939Z/dump.age./postgres/shop/20260919T140939Z/manifest.json./postgres/shop/20260919T140948Z/dump.age./postgres/shop/20260919T140948Z/manifest.json

Two files, and the second is why any of this can be checked later. The manifest is plain JSON and holds everything needed to judge the dump without opening it:

manifest.json
$ cat manifest.json{ "version": 1, "sloop": "0.1.5", "label": "shop", "engine": "postgres", "source": "postgres://app@127.0.0.1:5443/shop", "database": "shop", "server": { "version": "17.9", "tls": false }, "taken": { "utc": "2026-09-19T14:09:39Z", "local": "2026-09-19T20:09:39+06:00", "offset": "+06:00", "offset_seconds": 21600, "unix": 1789826979 }, "took_seconds": 0.534, "dump_seconds": 0.402, "dump": { "file": "dump.age", "bytes": 67650, "sha256": "e68d1629c48fce316f2e0cff63e81ceb77d268390265...", "encryption": { "format": "age", "recipient": "age1q7v3mx0..." } }, "rows": 10601, "tables": [ { "schema": "public", "name": "customers", "rows": 1284 }, { "schema": "public", "name": "orders", "rows": 9317 } ]}

Per-table row counts are the part that matters. They are what a restore checks itself against, and what makes it finished and it is correct two different statements rather than one. Note what is not here: no password, no key, nothing that could not be pasted into a public issue. The recipient is the public half of the key.

Seeing what is there

sizes only
$ sloop backups listprojectledger postgres · 2 backups, 45.4 KiB 2026-09-19 20:10:06 +06:00 22.7 KiB, encrypted · 4210 rows · replace 2026-09-19 20:09:48 +06:00 22.7 KiB, encrypted · 4210 rowsshop postgres · 2 backups, 132.0 KiB 2026-09-19 20:10:22 +06:00 66.0 KiB, encrypted · 10601 rows 2026-09-19 20:10:20 +06:00 66.0 KiB, encrypted · 10601 rows 4 backups, 177.4 KiB · sizes checked, --check hashes them

That reads sizes and manifests — fast, and enough for a glance. --check does the expensive thing instead: it hashes every dump on disk and compares each one with what its manifest recorded. One byte of the oldest archive dump was overwritten before this run:

every dump hashed
$ sloop backups list archive --checkprojectarchive postgres · 4 backups, 25.3 MiB · 1 damaged 2026-09-19 20:12:01 +06:00 6.3 MiB, encrypted · 2000000 rows 2026-09-19 20:11:47 +06:00 6.3 MiB, encrypted · 2000000 rows 2026-09-19 20:11:28 +06:00 6.3 MiB, encrypted · 2000000 rows 2026-09-19 20:09:47 +06:00 dump.age hashes to a25f8c3cb5c6 and the manifest says 77d875ac2fcb 4 backups, 25.3 MiB · 1 damaged · every dump hashed $ echo exit $?exit 6

It names both hashes rather than saying "corrupt", and the listing exits 6. A listing that returned success over a backup that cannot be restored would be worse than no listing at all — so the exit code carries it, and a scheduled --check can act on it.

Retention

backups prune deletes what is past a limit, after showing you which. Two limits, and they can be combined: --keep <N> for the newest N, and --older-than <AGE> for 12h, 30d, 6w.

rehearsal, then the real thing
$ sloop backups prune shop --keep 2 --dry-run remove 2026-09-19 20:10:18 +06:00 66.0 KiB, encrypted · 10601 rows 20260919T141018Z remove 2026-09-19 20:09:48 +06:00 66.0 KiB, encrypted · 10601 rows 20260919T140948Z remove 2026-09-19 20:09:39 +06:00 66.1 KiB, encrypted · 10601 rows 20260919T140939Z 2 backups kept by the policy 3 backups would go, freeing 198.1 KiB--dry-run: nothing was removed. $ sloop backups prune shop --keep 2 --yes … the same three, named againremoved 3 backups, freed 198.1 KiB

--dry-run is a real rehearsal: it does every bit of reading and deciding and stops where it would delete. The list it prints is the list the real run removes.

by age
$ sloop backups prune --older-than 30d --yes keep 2026-09-19 20:10:06 +06:00 replace-mode: one copy at a fixed path, so retention skips it 4 backups kept by the policynothing to prune — the policy removes none of what is there

A --replace copy is skipped by retention and says so. There is one of it, at a fixed path, and deleting it on age would leave a database with no current copy at all — which is the opposite of what a retention rule is for. --include-broken widens the sweep to unfinished and damaged directories, which are otherwise left alone so that a failure is still there to look at.

Putting one back

restore replaces what is in the database now. It is destructive, so it is typed rather than clicked — and from a scheduled run, where there is nobody to type, it refuses:

exit 2
$ sloop restore shop # from a scheduled runerror: replacing a database's contents needs its name typed, and there is no terminal to type at hint: pass --confirm shop to say it up front
the newest backup
$ sloop restore shoprestoring /home/you/projects/acme-api/.sloop/backups/postgres/shop/20260919T141022Z taken 2026-09-19 20:10:22 +06:00 (2026-09-19T14:10:22Z) 66.0 KiB of postgres 17.9, encrypted, 10601 rows, sha256 2657e422a4af into shop — postgres 17.9 replacing what is there now — 2 tables, 10601 rows? type shop to destroy it, or anything else to stop: shop cleared 2 tables loaded in 0.2s checking it against the counts the manifest recorded✓ Verified exact count(*) on both sides verifying — exact count(*) on both sides public.customers 1284 → 1284 public.orders 9317 → 9317 2 of 2 tables matched

With no --from it takes the newest. Everything after loaded in is the part that makes this a restore rather than a file copy: it reads the per-table counts out of the manifest and counts the database it has just written, table by table, and only then says the two matched.

a particular one
$ sloop restore shop --from 20260919T141020Z … the same shape, from the backup you named $ sloop restore shop --from 20200101T000000Zerror: shop has no backup called 20200101T000000Z hint: `--from` takes the directory's own name, as `sloop backups list` shows it

--from takes the directory's own name — the UTC stamp, exactly as backups list shows it. A name that is not there is an error before anything is touched, not halfway through.

One lockfile per database

A backup that overruns must not race the next scheduled run. Each database has one lock, and a run that finds it held takes nothing and exits 7. Two backups of the same 2,000,000-row table, started at the same moment — this is what the one that lost printed:

exit 7
$ sloop backup archive # while another run of it is goingerror: another sloop run is working on archive: `sloop backup`, process 24424, since 20260919T141146Z hint: exit 7 means this run did not start rather than that it failed. The lock is at /home/you/projects/acme-api/.sloop/locks/archive.lock, and it goes when that run does

It names the command holding the lock, its process id, when it started, and the file. And the hint says the thing that matters to whatever is reading the exit code: 7 means this run did not start, not that your backup failed. The data is exactly as it was.

Verification is exact, not estimated

Every table is counted on both sides with count(*) — one query_to_xml statement against one snapshot for PostgreSQL, one UNION ALL for the MySQL family. Not a sample, not the catalogue's opinion.

Why an estimate is not proof

n_live_tup is a planner statistic, and it has been observed reporting double the true count on a table this project was looking at. It also reports zero for every table of a freshly restored database until something analyses it — so a verification built on estimates would call a perfect restore a catastrophe.

The estimate is still available, for a table too large to count on every run. SLOOP_VERIFY=fast asks for it, and sloop labels it wherever it appears:

a glance, and it says so
$ SLOOP_VERIFY=fast sloop restore ledger --confirm ledger … loaded in 0.1s checking it against the counts the manifest recorded✓ Verified estimates from the engine's own statistics — not proof verifying — estimates from the engine's own statistics — not proof public.entries 4210 → 4210 1 of 1 tables matched — from estimates, so this is a glance and not a proof

a glance and not a proof is sloop's own wording, printed every time. And under fast mode a count can never fail a run on its own — only a table that is genuinely not there, which is a fact from the catalogue rather than from a statistic.

Drift is not loss

A source that is still being written to gains and loses rows while a dump is being taken, so two sides differing by a few rows is the normal state of a live database, not a fault. sloop says so in those words rather than calling it an error.

What is not normal, and what does fail: a table that arrived empty when the source had rows, or a table that did not arrive at all. Those are the two things a dump silently losing data looks like, and both are failures.

Exit 0 against exit 6

Both mean the work completed. Only one of them means the work was proved, and automation that treats them the same is automation that will one day keep a backup nobody can restore.

exit 0

It finished, and it was checked

Every table the manifest names arrived, and the counts on both sides agree. The copy is one you can act on.

Nothing to do.

exit 6

It finished, and then the numbers disagreed

The work completed — nothing crashed, nothing timed out. What failed was the proof afterwards: a table arrived empty, or did not arrive, or a dump no longer hashes to what its manifest says.

Neither success nor a crash, so automation has to treat it as its own case.

So 6 is neither success nor a crash, which is exactly why it has its own number. A script that checks if exit == 0 and nothing else will treat a backup whose dump no longer matches its manifest as a good night's work.

Every flag on this page, and every one it did not need, is on the command reference — generated from --help rather than written beside it.

sloop — a database operations CLI for PostgreSQL, MySQL and MariaDB. MIT or Apache-2.0.