Both copy one database into another. The difference is what happens to the rows already in the destination — and it is the whole of the difference, so it is first.
mirror — the destination ends up identical
Both commands are destructive to the destination, so both are typed rather than clicked, and neither will run unattended without --confirm:
$ sloop mirror live --to staging # from a scheduled runerror: replacing a database's contents needs its name typed, and there is no terminal to type at hint: pass --confirm staging to say it up front--dry-run does all the reading and all the counting and stops where it would write. It is the cheapest way to find out what a copy is about to cost:
$ sloop mirror live --to staging --dry-runmirroring postgres://app@127.0.0.1:5445/live → postgres://app@127.0.0.1:5445/staging postgres 17.9 → postgres 17.9 copying 3 tables, 10841 rows replacing what the destination has now — 3 tables, 1001 rowsdry run would replace the contents of postgres://app@127.0.0.1:5445/staging — nothing was changed10,841 rows in, 1,001 rows going. That second number is the one to read — it is what the destination is about to lose, and mirror loses all of it, including the one row in this example that exists nowhere else.
$ sloop mirror live --to stagingmirroring postgres://app@127.0.0.1:5445/live → postgres://app@127.0.0.1:5445/staging postgres 17.9 → postgres 17.9 copying 3 tables, 10841 rows replacing what the destination has now — 3 tables, 1001 rows? type staging to destroy it, or anything else to stop: staging cleared 3 tables copied in 0.3s✓ Verified exact count(*) on both sides verifying — exact count(*) on both sides public.audit_log 240 → 240 public.customers 1284 → 1284 public.orders 9317 → 9317 3 of 3 tables matched It finishes by counting both sides, table by table, with exact count(*) — the same verification a restore does, for the same reason. A copy that finished is not the same statement as a copy that arrived.
sync — the destination ends up holding both
Same source, same destination, same starting state as the block above — and a different command. Watch what happens to the row that only staging has:
$ sloop sync live --to staging --confirm stagingsyncing postgres://app@127.0.0.1:5445/live → postgres://app@127.0.0.1:5445/staging postgres 17.9 → postgres 17.9 merging 2 tables, parents first skipping public.audit_log — it has no primary key, so there is no way to tell which row is which the destination has 3 tables public.customers 1284 in, 684 new, 600 replaced, 1 kept that the source has no row for public.orders 9317 in, 8917 new, 400 replaced merged in 0.3s public.customers_id_seq now hands out 99002 next public.orders_id_seq now hands out 9318 next 2 tables merged: 9601 new, 1000 replaced, 1 kept1 kept that the source has no row for is the sentence that separates these two commands.mirror called that row part of what the destination has now and removed it. sync counted it and left it alone.
The rules sync states as rules
A merge has more ways to go subtly wrong than a replacement does, so sloop is loud about each of them. Four of the five below are visible in the block above.
The fifth is a refusal rather than a warning, because there is no half-right answer to give:
$ sloop sync loop_demo --to loop_dest --confirm loop_destsyncing postgres://app@127.0.0.1:5445/loop_demo → postgres://app@127.0.0.1:5445/loop_dest postgres 17.9 → postgres 17.9error: these tables' foreign keys point at each other, so there is no order that loads them: public.people, public.teams hint: sync refuses rather than loading half of them. `sloop mirror` replaces the destination outright and has no such ordering to get right And the hint points at the command that can do it — mirror replaces the destination outright, so it has no load order to get right in the first place.
Copying part of a database
--table narrows either command to the tables you name. It is repeatable, it takes a schema in front, and it takes a glob:
$ sloop mirror live --to staging --table public.nope --confirm stagingerror: --table public.nope matches no table in the source hint: a name, or a glob like `audit_*`, optionally with a schema in front. `sloop db test <name>` says what is thereNaming a table without naming what it points at does not work, and sloop says so before it touches anything rather than failing halfway through a load:
$ sloop mirror live --to staging --table public.orders --confirm stagingmirroring postgres://app@127.0.0.1:5445/live → postgres://app@127.0.0.1:5445/stagingerror: 1 table is named, and it points at 1 table: public.customers hint: rows cannot be loaded into a table whose parents are not there. Name them too, or pass --with-references to pull them in$ sloop mirror live --to staging --table public.orders --with-references \$ --confirm stagingmirroring postgres://app@127.0.0.1:5445/live → postgres://app@127.0.0.1:5445/staging only 2 tables: public.customers, public.orders those tables are dropped and recreated; everything else in the destination is left exactly as it is, so it will not end up identical to the source postgres 17.9 → postgres 17.9 copying 2 tables, 10601 rows cleared 2 tables copied in 0.4s 2 of 2 tables matched Note the second line of that block. A scoped mirror drops and recreates the tables you named and leaves the rest of the destination exactly as it is — so the destination does not end up identical to the source, which is the one thing an unscoped mirror guarantees. sloop says that out loud every time, because a flag that quietly changes what a command promises is worse than one that does not exist.
--safe, for a copy you would rather not have to start again
--safe dumps the source to a file before loading it, so a copy that fails partway can be retried from the dump instead of from the source. The trade is stated where it happens:
$ sloop mirror live --to staging --safe --confirm stagingmirroring postgres://app@127.0.0.1:5445/live → postgres://app@127.0.0.1:5445/staging copying 3 tables, 10841 rows cleared 3 tables--safe: dumping to /tmp/sloop-mirror-23020-20260919T143745Z/dump first — it is not encrypted, and it goes when the counts agree dumped 66.7 KiB copied in 0.8s 3 of 3 tables matchedThat file is not encrypted and sloop says so rather than leaving it to be discovered. It is removed once the counts agree, so a successful run leaves nothing behind; a failed one leaves the dump on purpose, which is the point of the flag.
Making the destination first
--create replaces --to when the destination does not exist yet. It makes the database and a role that owns it, registers the result, and copies into it in one run:
$ sloop mirror live --create rehearsal \$ --superuser postgres --superuser-password-command "op read op://vault/pg/root" \$ --role-password-stdincreating postgres://rehearsal@127.0.0.1:5445/rehearsal as postgres, whose password is used once and kept nowherecreated rehearsal role rehearsal created granted USAGE, CREATE ON SCHEMA public TO rehearsal registered as rehearsal in the project registrymirroring postgres://app@127.0.0.1:5445/live → postgres://rehearsal@127.0.0.1:5445/rehearsal copying 3 tables, 10841 rows the destination is empty copied in 0.3s 3 of 3 tables matchedthe destination is empty rather than replacing what the destination has now — there was nothing there a moment ago. The superuser password is used once and kept nowhere; the new role's password is the one that gets stored.
What both commands refuse
Two refusals, and both exist because the alternative is losing data quietly.
$ sloop mirror live --to live --confirm liveerror: live and live are the same database — postgres://app@127.0.0.1:5445/live hint: a mirror drops the destination's contents, so this would destroy the source A mirror clears the destination first. If the destination is the source, that clears the source — so the check happens before anything is contacted.
$ sloop mirror live --to mysql_side --confirm sessionserror: live is postgres and mysql_side is mysql hint: copying between engines is not built: the type systems do not map cleanly, and a copy that quietly rounds or truncates is worse than one that refuses. Mirror between two databases on the same engineBoth commands are same-engine only. PostgreSQL to MySQL is refused rather than attempted, and the reason is in the message: the type systems do not map cleanly, and a copy that quietly rounds, truncates or reinterprets your data is worse than one that does not run. restore refuses across engines for the same reason — a dump is written in its own engine's dialect.
Picking one
If you want the destination to match the source, use mirror. If the destination has rows of its own that matter, use sync — and read the kept count it prints, because those are the rows the source would have removed.
Every flag either command takes is on the command reference, generated from --help.