Skip to content

Which database dumps are byte-identical twice in a row

7 min readDatabasesPostgreSQLDeveloper Tools

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.

EngineFingerprint identical twice?What differs, and why
PostgreSQLYes, after normalizationA \restrict token in pg_dump 18 output, plus a timestamp inside the PGDMP archive header
MySQLYes, after normalizationA trailing -- Dump completed on <date> comment line
MariaDBYes, after normalizationSame trailer as MySQL, measured separately
RedisYes, after normalizationRDB header carries ctime and used-mem, plus a trailing CRC64 over the whole file
ValkeyYes, after normalizationSame as Redis
ClickHouseYes, nativelyByte-identical with no normalization needed
QuestDBYes, once fixed at the sourceWe build the tar, and tar records mtime
TypeDBYes, once fixed at the sourceSame tar problem
CockroachDBYesStable across repeated dumps; a verified insert moves it
SurrealDBYesStable across repeated dumps; a verified insert moves it
CouchDBYesStable across repeated dumps; a verified insert moves it
WeaviateYesStable across repeated dumps; a verified insert moves it
FerretDBNo, and not fixableDocument order is not guaranteed, so the same content is re-serialized in a different order
MeilisearchNo, and not fixableTaking a dump is itself a task, and the task queue is inside the dump
QdrantNo, and not fixableBuilds its own tar, with the creation time embedded in member filenames
SQLite, DuckDBNo, by choiceWe copy a live database file; page content churns
libSQL, InfluxDBNo, by choiceWe 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.