analysis

We ran one application on seven databases. The benchmark found a bug in our own code.

Most database benchmarks measure workloads nobody runs. So we replaced the storage engine underneath a live system seven times, kept the data, the queries and the indexes, and measured what actually happened. The most valuable result was not a timing.

Written by:Adrian Rosca

Database benchmarks are usually very precise measurements of something nobody does. Generate ten million uniform rows, pick a handful of flattering queries, call the driver directly, publish a number with an X after it.

The number is often true. It is also close to useless, because your application does not talk to a driver. It talks through an ORM, an abstraction layer, an access check, a pagination helper and a serializer, and every one of those costs something.

So we asked a different question. What happens when you replace the database underneath a working system and keep everything else — the data, the queries, the indexes, the code path?

We ran it on Franska Bukten, a ventilation inspection system we build and operate. Then we did it seven times: MongoDB, PostgreSQL, CockroachDB, ScyllaDB, Cassandra, CouchDB and FerretDB.

The headline

PostgreSQL won seven of the eight read scenarios. MongoDB won the eighth and won the write throughput outright. Everything else lost, and two of them lost by margins that change what you could promise a client.

But the result worth the whole exercise was not a timing. Comparing seven engines on the same data exposed a correctness bug in our own PostgreSQL adapter — one that had been shipping, returning wrong rows, invisible to every test we had.

A benchmark that only produces a ranking has wasted most of its value.

What we actually measured

The benchmark does not call the database driver. It calls the application, and the application decides how to reach storage. A read goes through the data service, the query handler, the adapter loader, the data layer and only then the engine.

That means every number includes the real cost of query translation, adapter behaviour, driver behaviour, serialization, the access check, the soft-delete filter, the account filter, alias normalization and pagination.

You could call that unfair to the database. We call it Tuesday. A migration does not happen in a benchmark harness, it happens inside an application, and the application is where the cost shows up.

The setup, stated plainly, including the parts that favour one engine over another:

  • 250 000 generated measurement records from a seeded generator, so every engine holds byte-identical rows
  • Median of ten iterations, two warmups discarded, p95 reported alongside, 200 sequential point reads per iteration
  • The same six secondary indexes on every engine, from one declaration in the codebase that every adapter reads
  • Indexes present while the rows were written, not added afterwards
  • Row counts compared across all seven engines before any timing was reported
  • MongoDB native on loopback; the other six in Docker under WSL2, which disadvantages them
  • One node each, developer settings, all seven resident on one machine at under 1.5% idle CPU apiece
  • A 120-second per-iteration ceiling — an engine that exceeds it is recorded as a timeout, never as a blank
A benchmark that bypasses your application is measuring a system you do not have.

Three adapters served seven engines

Before any measurement, a finding about migration cost that surprised us. Four of the five new engines needed no query code at all.

  • Needed nothing but a connection string
    • CockroachDB — speaks the PostgreSQL wire protocol and dialect
    • FerretDB — speaks the MongoDB wire protocol
    • Cassandra — speaks the CQL that ScyllaDB reimplemented
    Zero lines of adapter code. One branch in the engine switch.
  • Needed a real adapter
    • CouchDB — HTTP and JSON, Mango selectors, no partial update, revision-based writes
    • Couchbase — still unmeasured, because its SDK is a native dependency we would not add casually
    CouchDB took an adapter, a query planner and a field-name translation layer.

That is worth holding onto, because wire compatibility is the part of a migration everybody quotes and the part that costs least. Cassandra and ScyllaDB share every line of our CQL adapter, and they still differ by a factor of three on scans. CockroachDB shares every line of our PostgreSQL adapter, and it writes 21 times slower.

The driver was never the hard part. The query model is.

The bug the benchmark found

One scenario asks for measurements above a threshold: a range filter on an unindexed number. Five engines returned 165 rows. PostgreSQL returned 210. CockroachDB returned 207.

Row-count parity across engines is the check that makes any timing comparable, and it is the reason this was caught at all. A fast query returning the wrong rows is not an optimization, it is an efficient defect — and a single-engine test has nothing to compare against.

Every numeric range filter on the PostgreSQL engine had been returning wrong rows. Greater-than, less-than, between, and their negations. Any dp system running on that engine had been getting confidently wrong answers, and the wrongness scaled with how many single-digit values the data contained.

The fix casts inside a guard rather than casting blindly, because a schemaless column can hold a non-numeric string and a bare cast aborts the whole query on the first one. A guarded cast yields null instead, which fails to match — exactly what every other engine does with a mistyped field.

After the fix: 165 rows on all seven engines.

We went looking for a faster database and found out our own adapter had been lying to us.

Reads: PostgreSQL took it

Seven of eight, and not narrowly. The full grid of medians and p95 figures is on the benchmark page; what follows is the shape of it.

  • PostgreSQL
    • First sorted page: 1.1 ms
    • Relation lookup, 494 rows: 1.9 ms
    • Deep page 200: 4.6 ms
    • Exact count of 250 000: 26 ms
    • Load the whole collection: 522 ms
    Won every read scenario except the point read. Caveat below.
  • MongoDB
    • 200 point reads: 62 ms — the only read it won
    • Bulk insert: 44 238 rows/s, the fastest in the set
    • Deep page 200: 5.45 ms
    • Load the whole collection: 1.55 s
    The best-rounded engine here, and the one the application was designed against.
  • Cassandra and ScyllaDB
    • Deep page: 1.57 s against 5.15 s
    • Unindexed filter: 1.49 s against 5.09 s
    • Exact count: 1.49 s against 5.11 s
    • Bulk insert: 31 328 against 13 305 rows/s
    Cassandra beat ScyllaDB on every one. Same adapter, same CQL, different runtime.
  • CockroachDB
    • Reads competitive: 4.6 ms to 531 ms across the set
    • Point read the slowest of the SQL engines: 222 ms
    • Bulk insert: 536 rows/s
    466 seconds to write what MongoDB wrote in 5.7. Serializable distributed transactions.
  • CouchDB
    • Anything hitting a Mango index: 251–261 ms
    • Anything scanning: 27.7–30.0 s
    • 200 point reads: 3.06 s, or 15 ms each
    Splits cleanly in two. Every operation is an HTTP round trip by design.
  • FerretDB
    • 200 sequential point reads: did not finish inside 120 seconds
    • Relation lookup: 16.7 s
    • First sorted page: 20.5 s
    • Unindexed full scan: 2.29 s — faster than its indexed lookups
    Indexed access costing seven times an unindexed scan. Unexplained.

Eight scenarios is not a leaderboard and the margins are not one kind of fact. A 1.18-times gap on a deep page and a 21-times gap on writes describe different situations, and averaging them would produce a headline that describes neither.

What the spread tells you is which queries fit an engine and which ones fight it.

The caveat that costs PostgreSQL its clean sweep

PostgreSQL and CockroachDB were not running unindexed on the scan scenarios, and we are not going to present them as if they were.

Our PostgreSQL adapter creates a GIN index over the entire JSONB document, alongside the six parity indexes. So where the other five engines are genuinely scanning, those two are index-assisted. Their 35 ms and 39 ms against MongoDB's 72 ms is not a like-for-like number.

We could have dropped that index for the run. We did not, because it is what dp actually creates on PostgreSQL, and a benchmark of a configuration nobody runs answers a question nobody asked. Declaring the asymmetry is the honest option; hiding it would have been the flattering one.

Cassandra beat ScyllaDB, and that is the cleanest result here

ScyllaDB is a C++ reimplementation of Cassandra with a shard-per-core architecture, and it is usually the faster of the two. Here it lost every scenario.

This is the most controlled comparison in the whole benchmark. The two share every line of the CQL adapter, the same query planner, the same in-application filtering, the same read-modify-write update path, the same six indexes, the same 250 000 rows. Nothing above the storage engine differs.

So the gap is the runtime and the node's state, and nothing else. Cassandra was sitting at 3.686 GB of its 4 GB heap cap during the run, under real garbage-collection pressure, and still won. We do not have an explanation for that, and an unexplained result stays unexplained rather than getting a story attached to it.

Deep pagination is still where CQL breaks

Page 200 with 50 rows: 4.6 ms on PostgreSQL, 5.45 ms on MongoDB, 11 ms on CockroachDB. Then 1.57 s on Cassandra, 5.15 s on ScyllaDB, 30 s on CouchDB.

CQL has no OFFSET. There is no cheap way to skip forward to row ten thousand, so reaching that page means scanning the token range and slicing. A SQL engine walks an index and stops.

A production CQL implementation would not do this. It would use paging-state tokens, cursor pagination, clustering-key continuation or precomputed page boundaries — all of which work well, and none of which give you arbitrary numbered pages.

That is the actual finding. If your API promises page 200, moving to a CQL engine does not change a query. It changes what you can promise the client.

CouchDB is the one engine that says no

Every other engine in this set degrades when you ask for something awkward. CouchDB refuses. A Mango query that sorts on a field with no index is rejected outright rather than answered slowly, which meant the adapter had to detect that case and sort in the application instead of failing a caller.

It also would not store our documents at all. dp prefixes every foreign key with an underscore — _besiktning_id, _system_id, _ventil_id — and CouchDB reserves that prefix for its own members. The first write came back as a validation error naming the field.

So the adapter renames every field on the way in and back on the way out, in the selectors and the index declarations too. Not a performance characteristic. The one engine here where dp documents are illegal.

What it gets in exchange is the fastest exact count in the set by architecture — one database per collection makes a whole-collection total a metadata read. We measured 29.8 s instead, because our own count implementation was reading a figure that includes CouchDB's index design documents. That was our bug too.

FerretDB is the result we trust least

FerretDB presents the MongoDB wire protocol over PostgreSQL storage, which makes it the only candidate in the set that needs no adapter work whatsoever. Our queries arrive unchanged.

Its numbers are the worst here by a wide margin, and one of them is incoherent: indexed lookups measured slower than full scans. A relation lookup on an indexed foreign key took 16.7 s while an unindexed scan of the same collection took 2.29 s. Index access costing seven times a scan is not a performance profile, it is a sign the indexes were accepted and then not used.

We created them through the MongoDB index commands, the same way we did for MongoDB itself, and FerretDB acknowledged them. Whether they reached PostgreSQL as usable indexes, we have not verified.

So the FerretDB column is published as measured and flagged as suspect. The next run checks it with query plans before anybody draws a conclusion from it.

What we would tell a team facing this decision

For this application and this read model, the conclusions are narrower than a leaderboard and more useful than one.

  • PostgreSQL is the strongest read engine here, with the caveat that its scan results are index-assisted
  • MongoDB is the best-rounded, and the fastest writer at 44 238 rows/s
  • Cassandra is the pick of the two CQL engines on this workload, which is not what its reputation predicts
  • CockroachDB gives competitive reads and 536 rows/s on writes — a 21-times penalty against PostgreSQL for distribution nobody in this test needed
  • CouchDB fits an application built around precomputed views, and this application is not one
  • FerretDB needs no migration work and currently costs more than any migration would save
  • Deep offset pagination has to be redesigned on any CQL engine, including the client-facing contract
  • No engine here was tested under concurrency or across nodes, which is where three of them are designed to pay off

Nothing in this data justifies moving Franska Bukten off MongoDB for performance. PostgreSQL winning most read scenarios is real, and it is not the same thing as a migration being worth it — the wins are milliseconds on queries that were already fast, and the cost is a schema migration plus a GIN index doing quiet work.

Database selection follows workload shape. It does not follow brand reputation, and it certainly does not follow benchmark charts built by whoever is selling the database.

How the next engine gets tested

Couchbase is next, and the rules do not move for it. They did not move for any of the six that went before, which is the only reason the comparison means anything.

  1. 1.
    Same application
    the real data-access path, not a driver script written for the occasion.
  2. 2.
    Same data
    a seeded generator, so every engine holds byte-identical rows.
  3. 3.
    Same scenarios
    the same functional queries, expressed through the application rather than hand-tuned per engine.
  4. 4.
    Verify results first
    row counts must match across every engine before a single timing is reported. This is the rule that found the bug.
  5. 5.
    Equivalent indexes
    from one declaration every adapter reads, present while the data is written.
  6. 6.
    Query the versions
    ask each server what it is rather than writing it down, because a hand-typed version is wrong the first time anyone upgrades a container.
  7. 7.
    Document the asymmetries
    including the GIN index that helps PostgreSQL and the loopback connection that helps MongoDB.
  8. 8.
    Publish the bad runs
    two invalid runs are still on the benchmark page, labelled. They taught more about benchmarking than the valid ones did.

The goal is not to crown a permanent winner. Nobody needs another ranking of databases in the abstract.

The goal is to answer the only question a team actually has: given the system we already run, what would really happen if we migrated it?

Where the numbers live

This article is a snapshot. The measurements have their own page, which is where new engines land as they get measured: pick any two, switch the factor between median and p95, narrow it to the query shapes your own application performs.

Every invalid run is on that page too, in full, labelled as invalid. That is the part most benchmark pages leave out, and it is the part that decides whether you should believe the rest.