Which database dumps are byte-identical twice in a row
Dump a database. Change nothing. Dump it again.
If those two files are byte-identical, a great deal becomes easy. You can skip the upload. You can content-address your backups and store one copy instead of thirty. You can tell, cheaply and with certainty, whether anything changed between Tuesday and Wednesday.
If they are not identical, all of that quietly stops working, and nothing tells you. Your backups still run. Your monitoring still goes green. You just pay for storage you did not need and lose the ability to answer "did this change" without a full comparison.
We needed the answer across every engine we run, so we measured it. Here is the table, and more usefully, the reason behind each verdict, because the reasons repeat and you can go look for them in whatever engine you care about.
The results
Read the verdict column as being about the bytes we fingerprint, which are not always the raw dump. A row that says "after normalization" produced different raw bytes on every run, and what we hash is the normalized form. Every other row is the raw output, hashed as it came out.
| Engine | Fingerprint identical twice? | What differs, and why |
|---|---|---|
| PostgreSQL | Yes, after normalization | A \restrict token in pg_dump 18 output, plus a timestamp inside the PGDMP archive header |
| MySQL | Yes, after normalization | A trailing -- Dump completed on <date> comment line |
| MariaDB | Yes, after normalization | Same trailer as MySQL, measured separately |
| Redis | Yes, after normalization | RDB header carries ctime and used-mem, plus a trailing CRC64 over the whole file |
| Valkey | Yes, after normalization | Same as Redis |
| ClickHouse | Yes, natively | Byte-identical with no normalization needed |
| QuestDB | Yes, once fixed at the source | We build the tar, and tar records mtime |
| TypeDB | Yes, once fixed at the source | Same tar problem |
| CockroachDB | Yes | Stable across repeated dumps; a verified insert moves it |
| SurrealDB | Yes | Stable across repeated dumps; a verified insert moves it |
| CouchDB | Yes | Stable across repeated dumps; a verified insert moves it |
| Weaviate | Yes | Stable across repeated dumps; a verified insert moves it |
| FerretDB | No, and not fixable | Document order is not guaranteed, so the same content is re-serialized in a different order |
| Meilisearch | No, and not fixable | Taking a dump is itself a task, and the task queue is inside the dump |
| Qdrant | No, and not fixable | Builds its own tar, with the creation time embedded in member filenames |
| SQLite, DuckDB | No, by choice | We copy a live database file; page content churns |
| libSQL, InfluxDB | No, by choice | We tar a live data directory |
The three shapes of non-determinism
Every fixable case fell into one of three categories, and knowing which one you have tells you what to do about it.
A volatile line of text. The easiest. MySQL and MariaDB append -- Dump completed on with a timestamp. pg_dump 18 emits a \restrict token that varies. These are single lines in an otherwise stable text stream, and a targeted pattern removes them before hashing.
A volatile binary field. Harder, because you cannot regex your way through it. PostgreSQL's custom-format archive carries a timestamp in the PGDMP header. Redis and Valkey RDB files carry ctime and used-mem in the header, and then a CRC64 over the entire file, so changing one byte at the start changes eight bytes at the end. Both need a format-aware mask that knows where those fields live.
A generated artifact. The interesting one, because it is the case where the problem is yours. QuestDB and TypeDB back up as a directory that we tar ourselves, and tar records an mtime at byte offset 136 of every member header plus a checksum at 148. Two tars of identical files differ.
That third category deserves its own rule.
Fix determinism at the source, not on read
For QuestDB and TypeDB we had a choice. We could write a masker that walks every 512-byte tar member header and blanks the mtime and checksum fields. Or we could pass --mtime=@0 --sort=name when creating the tar and be done.
The flags are obviously simpler, but simplicity is not the real argument. A masker is a parser, and every parser is a chance to over-match. A masker that is slightly too aggressive will make two genuinely different dumps hash to the same value, and the consequence of that is not a wasted upload. It is a backup you needed and did not take, discovered later.
So the rule we settled on: mask only formats you merely receive. When you generate the artifact, make it reproducible instead. You get a stronger guarantee from less code.
That rule also tells you where to be careful. The engines above where we deliberately do not normalize (SQLite, DuckDB, libSQL, InfluxDB) are cases where we copy live files. The bytes that differ are unnamed page content, so any mask broad enough to cover them would be broad enough to hide a real change. Those correctly get a fresh copy every time.
The three that cannot be fixed
Each is impossible for a different and instructive reason.
FerretDB re-orders your data. Two dumps of an unchanged database are the same size, and contain an identical multiset of strings. The content is merely in a different order. FerretDB stores documents in Postgres and the dump tool reads them without a guaranteed ordering, so serialization order oscillates between runs. This is not maskable, because nothing is added or removed. Real content has moved.
That one carries a measurement lesson worth stealing. Because the order oscillates between two states, any two dumps match about half the time. Three samples taken a minute apart produced a confident and completely wrong verdict of "it settles." When a difference appears and disappears, sample more and faster, and compare the sorted multiset of strings rather than the files, which is what distinguishes reordered content from changed content.
Meilisearch records itself. Its dump contains a task queue, and taking a dump is a task. So every dump includes the record of the previous dumps, and the file grows a little each time. Self-referential, and no amount of masking gets you out of it.
Qdrant embeds time in filenames. It builds its own tar, so our tar flags do not apply, and the member names contain the snapshot creation timestamp. The bytes that differ are structural, not incidental.
Two traps that produced wrong answers
We got this wrong twice before we got it right, both times in ways that would repeat for anyone doing the same work.
Two dumps in the same second prove nothing. If the volatile field is a timestamp with one-second resolution, back-to-back dumps render the same value, and a genuinely unstable engine reports as stable. Take the two samples more than sixty seconds apart so the minutes field moves too.
An empty test database does not exercise the format. Fields like AUTO_INCREMENT= only appear once there are rows. Seed real data, or you are testing a header rather than a dump.
Why you might care
The practical payoff is that a stable dump lets you skip work safely. Ours skips the upload when the fingerprint matches the last one, which is the difference between storing one copy of a rarely-changing database and storing one per scheduled backup.
But there is a second-order benefit that took us longer to appreciate. Once dumps are deterministic, "did anything change" becomes a hash comparison rather than a restore-and-diff. That is a cheap question you can ask constantly, and it turns out to be useful for far more than storage.
The catch, and the reason this post exists: you cannot tell whether your dumps are deterministic by looking at your backup monitoring. A backup that uploads a redundant copy is indistinguishable from one that had to. You have to go and measure it, per engine, with the traps above in mind.
We run all of the engines in that table at Layerbase Cloud, which is why we had to measure the whole set rather than the one we cared about. If you want to run your own comparison, the method is in this post and none of it depends on our tooling.
Keep reading
- From PGlite to Production PostgresPGlite is a real Postgres compiled to WASM, so graduating a prototype to a hosted database is a dump and a restore, not a rewrite. Here is the whole path, start to finish.
- Your Go app passes in dev and fails in production behind PgBouncerpgx through a transaction-mode pooler fails with prepared statement "stmtcache_1" already exists. Not on the first connection, which is what makes it dangerous. Here is the measurement, the wrong diagnosis we published first, and the server-side fix.
- We tested sslmode against 8 Postgres clients. Only one value works everywhere.sslmode=require is the only value that connects on every client we tested. verify-full fails on all stock libpq. sslrootcert=system crashes node-postgres before it opens a socket. Here is the measured matrix, with exact versions and the real error strings.
- prisma dev, PGlite, and the one-connection ceilingprisma dev runs a local Postgres on PGlite with no Docker and no config, and it is right about the problem it picked. It also takes one connection at a time and only speaks Postgres. Here is what to run when your stack needs more than that.