All articlesanalysis

We swapped the database under a real application. MongoDB won.

Most database benchmarks measure workloads nobody runs. So we replaced the storage engine underneath a live system, kept the data, the queries and the indexes, and measured what actually happened.

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. MongoDB against ScyllaDB. The answer was not the one the marketing pages predict.

The headline

With equivalent secondary indexes present on both engines while the rows went in, ScyllaDB wrote 1.55 times faster than MongoDB. MongoDB then won all eight read scenarios, by margins from 1.36 times to more than eleven hundred.

That is not a statement that MongoDB is faster than ScyllaDB. It is a statement about one application's read model, measured honestly.

The distinction is the whole point of the exercise.

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 below 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 environment matters just as much when reading the numbers, so here it is in full. MongoDB had the friendlier deal, and we are not going to pretend otherwise.

  • MongoDB: native process on 127.0.0.1:27017, loopback, whole machine available
  • ScyllaDB 6.2.3-0.20250119.bff9ddde1283, CQL 3.0.8, image scylladb/scylla:6.2
  • ScyllaDB ran in Docker under WSL2 Ubuntu 26.04, reached through WSL port forwarding
  • ScyllaDB was allocated two CPU shards and 4 GB of memory, with --overprovisioned 1 --developer-mode 1
  • Driver cassandra-driver@4.9.0, consistency LOCAL_ONE, fetch size 5000
  • Median of ten iterations, two warmups discarded, p95 reported, 200 point reads per iteration

A benchmark that bypasses your application is measuring a system you do not have.

The run we threw away

The first run had ScyllaDB winning three read scenarios by 9 to 19 times. It looked like a story. It was a bug in the test.

Our Scylla adapter creates its declared secondary indexes automatically the first time it touches a collection. MongoDB has no equivalent step, so the collection sat there with nothing but its default identifier index while Scylla had six. We were timing an indexed store against an unindexed one and calling it an engine comparison.

Adding the six matching MongoDB indexes moved the inspection lookup from 88 milliseconds to 4.5, and the sorted first page from 86 milliseconds to 1.6. All three Scylla wins evaporated.

The timings in that first run were real. They just answered the wrong question.

There is a second, sneakier version of the same trap. Indexes have to exist while the rows are being written, not be added afterwards. Our second attempt seeded MongoDB first and indexed it after, which quietly handed MongoDB an index-free write benchmark. Only the third run had both engines carrying all six indexes through the insert, and only that run's write comparison means anything.

Comparing indexed queries on one engine against unindexed queries on another does not measure databases. It measures your setup script.

Index parity, stated exactly

Both engines ended up carrying secondary indexes on the same six fields — _type, _besiktning_id, _system_id, _ventil_id, _room_id and _anmarkning_id — verified by reading each engine's own catalog rather than trusting the setup code.

Two fields, godkand and varde, were left unindexed on both sides on purpose, so the scan scenarios below are genuinely unindexed work for everyone.

The migration

Before any measurement, the whole database moved. Every collection, every document, verified by comparing row counts per collection on both sides.

This part went well, and it is worth saying so. The migration script is idempotent, a rerun tops the target up rather than duplicating, and it exits non-zero on any mismatch. Nothing needed hand-holding.

Writes: ScyllaDB takes it

Both engines inserted 250 000 records with all six secondary indexes already in place. MongoDB took 22.7 seconds, ScyllaDB took 14.6.

That is 11 028 rows per second against 17 147. ScyllaDB won by 1.55 times, and this is the result that reflects what it was built for: append-heavy writes into a log-structured store, where maintaining six indexes costs less than it does against B-tree pages.

The interesting number is the one next to it. MongoDB managed 27 105 rows per second with no secondary indexes at all, so carrying those six through the insert cost it a factor of 2.5.

Those indexes are not waste. The read results are what you bought with them.

Reads: MongoDB takes everything

Eight scenarios, median of ten iterations, p95 alongside. Row counts matched on every one, which is the precondition that makes any of it comparable — a fast query returning the wrong rows is not an optimization, it is an efficient defect.

Point read by identifier, 200 requests
  • MongoDB: 68 ms median, 83 ms p95
  • ScyllaDB: 115 ms median, 122 ms p95
MongoDB 1.69 times faster. 200 rows returned.
All measurements for one inspection
  • MongoDB: 4.5 ms median, 6.3 ms p95
  • ScyllaDB: 6.1 ms median, 6.9 ms p95
MongoDB 1.36 times faster. 494 rows. Closest result in the set.
First sorted page, 50 rows
  • MongoDB: 1.9 ms median, 2.5 ms p95
  • ScyllaDB: 12 ms median, 13 ms p95
MongoDB 6.32 times faster.
Deep page, page 200 of the collection
  • MongoDB: 6.0 ms median, 6.5 ms p95
  • ScyllaDB: 6.74 s median, 9.63 s p95
MongoDB 1 123 times faster. 50 rows.
Filtered scan on an unindexed boolean
  • MongoDB: 80 ms median, 147 ms p95
  • ScyllaDB: 6.23 s median, 6.56 s p95
MongoDB 78 times faster. 10 000 rows.
Range scan on an unindexed number
  • MongoDB: 2.5 ms median, 3.7 ms p95
  • ScyllaDB: 4.9 ms median, 5.7 ms p95
MongoDB 1.96 times faster. 165 rows.
Exact count of the whole collection
  • MongoDB: 76 ms median, 78 ms p95
  • ScyllaDB: 6.34 s median, 7.93 s p95
MongoDB 83 times faster. 250 006 rows.
Load the whole collection
  • MongoDB: 1.95 s median, 2.38 s p95
  • ScyllaDB: 6.43 s median, 7.15 s p95
MongoDB 3.3 times faster. 250 006 rows.

Eight out of eight is a clean sweep, and it would be easy to average those factors into one triumphant number. Do not. A 1 123-times pagination result and a 1.36-times lookup are not the same kind of fact, and blending them produces a headline that describes nothing.

Every factor above is computed from the medians printed beside it, so all of them can be checked with a calculator. That is deliberate. A factor a reader cannot reproduce from the numbers on the page is an assertion.

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

Deep pagination is where it gets ugly

Page 200 with 50 rows: 6 milliseconds against 6.74 seconds. This is the biggest gap in the benchmark and also the least surprising one.

CQL has no OFFSET. There is no cheap way to skip forward to row ten thousand, so reaching that page means scanning a quarter of a million rows across the token range and then slicing. MongoDB walks an index cursor and stops.

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

And that is the actual finding. If your API promises page 200, migrating to ScyllaDB does not just change a query. It changes what you can promise the client.

Counting and scanning cost real money

An exact count of 250 006 rows took MongoDB 76 milliseconds and ScyllaDB 6.34 seconds. A distributed store cannot treat a whole-dataset total as a metadata lookup; it has to go and look.

The unindexed boolean filter tells a similar story at 80 milliseconds against 6.23 seconds. Neither engine had an index on that field, so both were scanning — but MongoDB scanned natively while our adapter shipped every row over the wire in 5 000-row pages and filtered them in the application.

Both are fixable on Scylla. Counts become maintained counters or an aggregation table. The filter becomes a dedicated table partitioned by approval status.

Both fixes mean duplicate data, extra writes, more schema objects and consistency work you now own. Denormalization is a normal Scylla technique, not a defect. It is also not free.

The point read I do not fully trust

MongoDB did 200 identifier lookups in 68 milliseconds, ScyllaDB in 115. Primary-key access is supposed to be Scylla's home turf, so a 1.69-times loss there deserves suspicion rather than a victory lap.

The requests were sequential, not concurrent. Scylla was crossing a WSL network boundary and a coordinator hop against a loopback MongoDB, on two CPU shards, in developer mode. Any of that could dominate a per-query round trip.

The number is honest for this setup. It is not evidence that MongoDB does key lookups faster in general, and we are not going to present it as such.

The next iteration measures this at 1, 10, 50, 100 and 500 concurrent requests, because that is where Scylla's architecture is supposed to start paying off.

Why ScyllaDB lost the reads

None of this is a flaw in ScyllaDB. The two engines are built around different assumptions about who decides the query shape, and we handed one of them an application designed for the other.

ScyllaDB is built for
  • High write throughput
  • Predictable low latency under concurrency
  • Horizontal scaling across nodes
  • Partition-key reads
  • Data models designed around known queries
Queries influence the table design. That is the deal.
MongoDB is built for
  • Flexible document structures
  • General secondary indexes
  • Ad hoc filtering and sorting
  • Range queries and aggregation
  • Query patterns that change after launch
Indexes absorb new queries without a redesign.

Franska Bukten was designed against the second column. Swapping the storage engine did not convert its access patterns into partition-key reads, and no adapter can do that for you — the gaps are properties of CQL, not tuning knobs.

So the benchmark answers one narrow, genuinely useful question. Is ScyllaDB a faster drop-in engine for this MongoDB application?

No.

Drop-in is not the same test as optimized

Everything above is a drop-in comparison. It measures migration feasibility, adapter compatibility, how the existing queries behave and how much code you would have to change. That is the question a team asks before committing.

An optimized comparison is a different test entirely: one table per major query pattern, denormalized records, cursor pagination, maintained counters, explicit partition design. It asks how well the application could perform after being rebuilt around the target engine.

Both are worth running. Mixing them into one ranking is not, because then the winner is simply whichever engine got the most engineering attention.

What we would tell a team facing this decision

For this application and this read model, the conclusion is unambiguous, and it is narrower than a leaderboard.

  • MongoDB is the stronger read engine here, on every scenario measured
  • ScyllaDB gives higher indexed write throughput, by 1.55 times
  • ScyllaDB is not a beneficial drop-in replacement for this system
  • Deep offset pagination would have to be redesigned, including the client-facing contract
  • Exact counts, broad scans and flexible filtering each need a dedicated strategy
  • A Scylla migration would add schema objects, duplicate data and consistency work
  • The upside is most likely to appear at higher concurrency and multi-node scale, neither of which this benchmark tested

The current data does not justify replacing MongoDB with ScyllaDB for performance. That could change the day the binding constraint becomes sustained write throughput, multi-region distribution, or predictable tail latency under heavy concurrency.

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

PostgreSQL, CockroachDB, Couchbase, Cassandra and the rest can all go through the same harness. The rules stay fixed, because the moment they bend for one engine the whole comparison is worthless.

  1. Same application the real data-access path, not a driver script written for the occasion.
  2. Same data the production database migrated in full, plus a generated set built from a seeded generator so every engine holds identical rows.
  3. Same scenarios the same functional queries, expressed through the application rather than hand-tuned per engine.
  4. Verify results first row counts must match before a single timing is reported.
  5. Equivalent indexes verified from each engine's own catalog, and present while the data is written.
  6. Document the environment including the parts that disadvantage one side, as this run's WSL deployment disadvantaged ScyllaDB.
  7. Separate the modes drop-in results and engine-optimized results never share a table.
  8. Publish the bad runs the invalid first attempt taught more about benchmarking than the valid one 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 themselves 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.

Both invalid runs are 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.