Nio databaser på 2 GB, och den utan server vann.
Ett fungerande system, en datamängd, en kodväg, nio lagringsmotorer och en proxy. Var och en fick en maskin på 2 GB med applikationen boende på den. SQLite tog sex av åtta lässcenarier och delade förstaplatsen med PostgreSQL. Två motorer kom inte i mål.
De flesta databasjämförelser mäter något ingen kör: likformigt genererade rader, smickrande frågor, ett direkt drivrutinsanrop, en siffra med ett X efter.
En applikation pratar inte med en drivrutin. Den pratar genom ett datalager, en behörighetskontroll, ett filter för mjukraderade poster, en sidbrytningshjälpare och en serialiserare.
Så vi behöll allt det och bytte bara ut lagringsmotorn. Samma data, samma frågor, samma index, samma kodväg: SQLite, PostgreSQL, MongoDB, dpdb, MariaDB, MariaDB bakom MaxScale, ScyllaDB, Cassandra, CockroachDB och CouchDB. Nio scenarier, tio omgångar var.
Det nya är maskinen. En tidigare körning gav varje motor en egen server på 32 GB och PostgreSQL vann sju av nio. Den här ger var och en en låda på 2 GB med jämförelseprocessen boende på den — vilket är hur ett av våra system faktiskt driftsätts, och det ändrar svaret.
Vinnaren
Det är oavgjort, och de två motorer som delar förstaplatsen kunde knappast vara mer olika. SQLite och PostgreSQL har båda snittplaceringen 1,78 över de nio scenarierna.
De tar sig dit på olika vägar. SQLite vinner sex av de åtta lässcenarierna; PostgreSQL vinner de två andra och blir tvåa i sju av nio. Räknar man bara läsningar har SQLite 1,50 mot PostgreSQLs 1,75.
MongoDB är trea på 3,11 och äger skrivkolumnen helt med 23 900 rader per sekund, 91 % före nästa motor. Den vann inget lässcenario — samma resultat som på de stora maskinerna.
Marginalen i toppen är inte subtil. SQLite läser en rad på dess id på 9,67 ms där PostgreSQL tar 80,8: åtta gånger snabbare över 200 sekventiella uppslag, det bredaste glappet den här jämförelsen någonsin producerat mellan två fungerande motorer. Den tar sig dit genom att inte vara en server. Det finns ingen port, ingen socket, ingen tur och retur och ingen andra process som slåss om den enda kärnan.
- SQLite — snittplacering 1,78, sex scenariovinster, alla läsningar
- PostgreSQL — 1,78, två vinster (djup sida, exakt räkning), tvåa i sju av nio
- MongoDB — 3,11, en vinst (massinläggning, och det är inte jämnt)
- MariaDB — 5,44
- dpdb — 5,67
- ScyllaDB — 5,89
- CockroachDB — 6,11, ur de sju omgångar av tio den orkade slutföra
- Cassandra — 6,56
- CouchDB — 8,67, sist på varje läsning den besvarade, vägrade två den inte besvarade, och sexa på skrivningen
Krymp maskinen till 2 GB och motorn som slutar vara en server vinner sex av åtta läsningar.
Uppställningen
250 000 genererade poster per motor, i en egen databas, seedade från tomt. Sex identiska sekundärindex överallt, på plats medan raderna skrevs.
Varje motor fick en egen maskin: en vCPU, 2 GB, 50 GB disk, i samma datacenter. Varje låda skapades för sin motor, mättes, och förstördes så snart dess siffror var i säkerhet.
Jämförelseprocessen kördes PÅ den lådan, bredvid motorn, genom hela varje omgång. Det är premissen snarare än en kompromiss — applikationen och dess databas delar en maskin, så mätningen bör göra det också.
Varje motor hölls till samma 1 GB hur många processer den än fördelar dem över: MariaDBs buffertpool, ScyllaDBs Seastar-allokering, dpdbs heap, MaxScale bredvid den MariaDB den står framför. Det är utjämning, inte trimning.
Tio fulla omgångar var — töm, seeda om, kör om. Publicerade siffror poolar varje råmätning i stället för att medelvärdesbilda omgångarnas medianer: 100 iterationer per lässcell, 500 styckmätningar per skrivcell.
Innan någon tid rapporterades måste varje motor rymma exakt 250 000 rader och bära exakt den deklarerade indexuppsättningen, kontrollerat mot dess egen katalog i början av varje omgång.
Tre motorer tog sig inte igenom det helskinnade, och var och en behandlas där det hände i stället för att döljas: CockroachDB slutförde sju omgångar av tio, CouchDB slutförde tio och vägrade två scenarier i var och en av dem, och FerretDB slutförde aldrig en enda seedning.
Att skriva 250 000 rader
En fyllning från tomt, femtio stycken om femtusen, alla sex index redan på plats.
MongoDB 23 900 rader per sekund — 12,8 sekunder för hela fyllningen, och den enda kolumn den vinner.
ScyllaDB 3 380, vilket är 81 sekunder för samma arbete. Motorerna byggda för att sprida skrivningar över ett kluster betalar mest för att få en nod och en kärna.
CockroachDB är sist på 1 140 rader per sekund — 286 sekunder per fyllning, tjugotvå gånger MongoDBs, och skälet till att den bara slutförde sju omgångar. Raft-konsensus och en serialiserbar transaktion på varje insättning är inte gratis på en kärna.
Att läsa en rad på dess id
Tvåhundra sekventiella uppslag på primärnyckel. Scenariot som skiljer fältet mest åt, och det skiljer det med en faktor 125.
SQLite 9,67 ms mot PostgreSQLs 80,8. CouchDB är sist på 1 210 och dpdb näst sist på 428.
Båda ändarna har samma orsak. SQLite svarar inuti processen som frågade, så ett uppslag är ett funktionsanrop; dpdb svarar över HTTP till en andra process, så ett uppslag är ett anrop. Tvåhundra sekventiella turer är tvåhundra tillfällen att betala för det, och det är den enda form där skillnaden är hela mätningen. CouchDB, som talar HTTP av konstruktion, sitter i andra änden av samma axel.
Alla mätvärden för en besiktning
Ett indexerat uppslag på en främmande nyckel som returnerar 494 rader — den vanligaste frågan den här applikationen ställer.
SQLite 3,41 ms, PostgreSQL 7,97, MongoDB 11,2. Cassandra är sist av motorerna som svarade, på 22,1.
CouchDB besvarade den inte alls. I alla tio omgångarna kom frågan tillbaka från servern som en timeout — CouchDB som vägrar köra den snarare än kör den långsamt, vilket är beteendet den dokumenterar och det enda på den här sidan som inte är en siffra.
Här ligger sidans jämnaste avgörande: dpdb 18,3 ms mot MariaDB 18,8, ett glapp på 2,7 % mellan två motorer som kördes på två olika maskiner. Behandla det paret som oavgjort.
Första sidan
Femtio rader sorterade på datum, fallande. Det en användare ser när hen öppnar en lista.
SQLite 2,88 ms och PostgreSQL 3,85 är snabba nog att nätverket ut till webbläsaren kostar mer än frågan.
ScyllaDB 28,2, Cassandra 39,8 och CockroachDB 46,6: en godtycklig sortering är inte vad CQL är byggt för, oavsett maskinstorlek, och en distribuerad SQL-motor på en nod får ingen kompensation för maskineriet.
Det här är CouchDBs andra vägran. En Mango-sortering behöver ett index som täcker den, och i stället för att degradera avstår motorn.
Sida tvåhundra
Samma femtio rader, tiotusen ner. PostgreSQLs första vinst, och det tydligaste den gör bättre än SQLite.
PostgreSQL 4,99 ms, MongoDB 11,6, SQLite 17,4. En djup offset är där en riktig frågeplanerare och en riktig buffertpool tjänar in sitt minne. Det här och den exakta räkningen är de enda två läsningar SQLite inte vinner, och den förlorar båda mot samma två motorer.
ScyllaDB 249 ms och Cassandra 201. CQL har ingen OFFSET, så djup sidbrytning emuleras.
CouchDB tar 20,4 sekunder — fyratusen gånger PostgreSQL, och det bredaste enskilda glappet någonstans i den här jämförelsen.
Att filtrera på ett fält ingen indexerat
Tiotusen rader som matchar ett booleskt fält utan index på någon motor. Alla läser igenom, så det här mäter genomsökningshastighet och inget annat.
SQLite 75,1 ms, PostgreSQL 141. ScyllaDB 418 och MariaDB 405 — att söka igenom är det CQL är minst villigt att göra, och InnoDB är inte mycket gladare över det här.
CouchDB 4,7 sekunder, och dess p95 är 10,5: ungefär ett anrop av tjugo kostar mer än dubbelt det typiska, vilket är sidans bredaste svans.
Ett intervall inom en relation
Ett indexerat uppslag och en numerisk jämförelse tillsammans — frågan en rapport byggs av.
SQLite 2,03 ms, PostgreSQL 3,95, MongoDB 5,83. Sidans snabbaste siffror, på de minsta maskiner den här jämförelsen använt.
CockroachDB 23,5 och CouchDB 315 avslutar den.
Att räkna samlingen
Hur många rader finns det. Den enklaste frågan här, PostgreSQLs andra vinst, och den enda läsning där SQLite slås med klar marginal — 99 ms mot 437.
MongoDB 156 ms. MariaDB 1,75 sekunder, därför att InnoDB inte håller något exakt radantal och COUNT(*) läser igenom ett index för att få fram det — samma beteende den visade på en maskin med 32 GB, i samma multipel.
ScyllaDB och Cassandra kan inte besvara den som ett enda anrop alls, så deras siffra är en sidbruten genomsökning: 1,62 och 2,79 sekunder.
CouchDB 32,4 sekunder. Den håller en databas per samling, vilket borde göra en exakt räkning till en metadataläsning; på den här maskinen är den inte det.
Att läsa in allt
Alla 250 000 rader, genom applikationen, serialiserade.
SQLite 2,37 sekunder, PostgreSQL 3,02, ScyllaDB 3,72, CockroachDB 4,10. dpdb 7,64, och CouchDB 56,4 — tjugofyra gånger vinnaren.
ScyllaDB är värd en andra blick här: näst sist på den djupa sidan, tredje snabbast på att strömma hela samlingen, och den passerar MongoDB på vägen. Sekventiella massläsningar är åtkomstmönstret den byggdes kring, och det här är sidans enda scenario som ber om dem.
Vad en proxy kostar
MariaDB kördes två gånger: direkt, och genom MaxScale på samma maskin med samma rader och samma inloggning. Porten är den enda skillnaden, så varje glapp mellan de två kolumnerna är proxyn.
Det är ingen platt skatt, och den går inte ens åt ett håll. Punktläsningen — 200 sekventiella turer — kostar 31 % mer genom MaxScale. Allt som returnerar en bunt kommer tillbaka snabbare: relationsuppslaget med 28 %, det oindexerade filtret med 27 %, hela inläsningen av 250 000 rader med 28 %.
Den exakta räkningen och massinläggningen är identiska med den rapporterade siffran.
Den tidigare körningen mätte samma två riktningar på maskiner med 32 GB och en helt annan processor: 18,7 % långsammare på punktläsningen, 29 % snabbare på hela inläsningen. Att få tillbaka båda halvorna på en sextondel av minnet och en åttondel av kärnorna är ett starkare påstående än någondera körningen gör ensam.
Den troliga orsaken till vinsten på bunten är att proxyn frikopplar läsningen från motorn från skrivningen till klienten. Det är en hypotes; mätningen är det inte.
Motorerna som inte kom i mål
Två motorer besegrades av maskinen snarare än av varandra, och båda är värda mer än placeringen de hamnar på.
CockroachDB slutförde sju omgångar av tio. Två dog med att uppkopplingen bröts mitt i en seedning, och en hängde sig hela takgränsen på tre timmar utan att producera något alls. Dess kolumn är poolad ur de sju som överlevde, vilket smickrar den: omgångarna den förlorade är de där den hade problem.
FerretDB har ingen kolumn alls, och det är dess resultat snarare än en lucka i vårt.
Den slutförde aldrig en enda seedning på den här hårdvaran. Kärnans OOM-dödare tog den vid 783 MB med 50 100 av 250 000 rader skrivna, och den tog den igen vid varje uppdelning av minnesbudgeten vi försökte med.
Det som dödar den är formen på en översättande proxy: den svarar genom att materialisera en hel samling inuti processen som översätter, så dess minnesbehov följer datamängdens storlek snarare än sidans. På 32 GB är det osynligt och den slutförde varje scenario. På 2 GB är det fyndet.
Siffran som hade fått den att rymmas är skälet till att den inte har någon. Varje annan kolumn hålls till samma 1 GB, och en motor som mätts med mer är inte mätt mot dem.
CouchDB hör bara halvvägs hemma här. Den slutförde alla tio omgångarna och vägrade sedan två av de åtta lässcenarierna i var och en av dem — relationsuppslaget och första sidan, båda returnerade av servern som timeouts. De sex den besvarar, besvarar den sist.
Hur mycket av det här är mätfel
Nog för att spela roll i botten av tabellen och inte på långa vägar nog för att spela roll i toppen.
Varje motor kördes på en egen maskin. De är samma produkt i samma datacenter, men den här flottan registrerade ingen kalibrering mellan maskiner — flottan med 32 GB gjorde det, och fann en spridning på 4,2 % mellan nominellt identiska servrar. Anta något i den storleksordningen här och läs allt inom några procent som oavgjort.
Två avgöranden ligger innanför det: dpdbs relationsuppslag på 18,3 ms mot MariaDBs 18,8, och ScyllaDBs intervallsökning på 11,7 ms mot dpdbs 12,2. Ingetdera flyttar en placering ovanför femte.
Inget nära toppen är i fara. SQLites punktläsning är åtta gånger nästa motors och dess smalaste vinst är 34 %; ingen rimlig skillnad mellan två lådor av en produkt når någondera.
CockroachDBs kolumn bär ett andra slags fel de övriga inte har: 70 mätvärden per cell i stället för 100, hämtade ur omgångarna den överlevde. Det är en välvillig uppskattning av en motor som inte kom i mål, och den ska läsas som en.
Proxyjämförelsen är den enda på sidan utan någon maskin i sig alls — MaxScale och MariaDB är en server på en låda, och porten är hela skillnaden.
Varje anrop var sekventiellt och varje motor körde en nod. Inget här beskriver beteende under samtidighet eller över ett kluster, vilket är där ScyllaDB, Cassandra och CockroachDB är byggda för att löna sig — och en enkärnig låda är den minst representativa maskin som finns för någotdera.
Vad det här betyder om ni ska välja en
Rangordningen står ovan och den är verklig. Den är verklig för den här maskinen, vilket är poängen med att köra den på den här maskinen.
- En applikation, en liten låda, en process som läser — SQLite, och marginalen är inte jämn. Den vann sex av åtta läsningar genom att inte ha något nätverksprotokoll alls
- Samma egenskap är skälet att titta någon annanstans: SQLite har inget att erbjuda en andra applikationsserver, serialiserar sina skrivare, och dess övertag här ligger lika mycket i driftsättningen som i motorn
- Ad hoc-frågor, djup sidbrytning, exakta räkningar, åtkomstmönster som kommer att ändras efter lansering — PostgreSQL, som är tvåa nästan överallt och etta där en frågeplanerare spelar roll
- MongoDB är den snabbaste skrivaren med bred marginal och den bästa allroundmotorn efter de två, precis som på maskiner sexton gånger så stora
- Skrivtungt med åtkomstmönster kända i förväg — CQL-motorerna tjänar in sina begränsningar, men inte på en kärna: ScyllaDB skriver i en sjundedel av MongoDBs takt här
- MariaDB är konkurrenskraftig på skrivningar och på punktläsningen, och dess exakta räkning är 18 gånger PostgreSQLs
- En proxy framför en databas är ingen fast omkostnad och inte alltid en kostnad — mät den på ert eget åtkomstmönster
- En motor som svarar över HTTP betalar för varje tur och retur, så räkna era turer innan ni läser en punktläsningssiffra som en dom över dess lagring
- CockroachDBs skrivkostnad köper serialiserbara distribuerade transaktioner över noder; på en liten nod köper den ingenting och kostar tre omgångar av tio. Sätt den inte på en låda av den här storleken
- CouchDB är byggd kring förberäknade vyer, och ingen av de här frågorna är en. Den är sist på varje läsning den besvarade och vägrar de två mest ordinära en applikation ställer
Siffrorna
Varje siffra ovan är läst ur tabellen nedan i stället för inskriven i den här artikeln. Byt körning — flottan med 32 GB finns kvar — byt basmotor, jämför vilka två som helst.
Stating MongoDB against PostgreSQL, median figures. Narrowing the groups dims rows it does not cover — nothing is removed from the page.
Run H — one 2 GB box per engine, the application on it too
Drop-in comparisonAgainst PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Reference engine — every factor on this page is stated against it. Its own record above is against the fastest other engine on each of 9 scenarios, median figures.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 7 scenarios, median figures. No overall factor is stated: the spread is the result.
| Scenario | Rows | SQLite | Postgres | MongoDB | dpdb | MariaDB | MaxScale | ScyllaDB | Cassandra | Cockroach | CouchDB | Factor | Relative | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | ||||
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place. Writes | 250 000 | 10 400 rows/s | 7 490 rows/s | 12 500 rows/s | 10 900 rows/s | 23 900 rows/s | 10 500 rows/s | 6 860 rows/s | 2 730 rows/s | 11 700 rows/s | 9 890 rows/s | 11 700 rows/s | 10 300 rows/s | 3 380 rows/s | 2 280 rows/s | 4 920 rows/s | 2 450 rows/s | 1 140 rows/s | 462 rows/s | 5 930 rows/s | 5 200 rows/s | MongoDB 1.91× | |
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 9.7 ms | 33 ms | 81 ms | 109 ms | 129 ms | 205 ms | 428 ms | 572 ms | 88 ms | 135 ms | 116 ms | 166 ms | 240 ms | 309 ms | 125 ms | 246 ms | 218 ms | 434 ms | 1.21 s | 1.76 s | Postgres 1.6× | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 3.4 ms | 6.3 ms | 8.0 ms | 10 ms | 11 ms | 15 ms | 18 ms | 25 ms | 19 ms | 33 ms | 14 ms | 29 ms | 16 ms | 20 ms | 22 ms | 78 ms | 17 ms | 52 ms | — | — | Postgres 1.41× | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 2.9 ms | 3.9 ms | 3.9 ms | 5.0 ms | 7.2 ms | 10 ms | 31 ms | 75 ms | 22 ms | 25 ms | 21 ms | 26 ms | 28 ms | 36 ms | 40 ms | 113 ms | 47 ms | 143 ms | — | — | Postgres 1.86× | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 17 ms | 21 ms | 5.0 ms | 6.2 ms | 12 ms | 24 ms | 59 ms | 115 ms | 72 ms | 78 ms | 71 ms | 84 ms | 249 ms | 326 ms | 201 ms | 378 ms | 85 ms | 164 ms | 20.4 s | 21.3 s | Postgres 2.32× | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 75 ms | 87 ms | 141 ms | 192 ms | 221 ms | 246 ms | 244 ms | 325 ms | 405 ms | 457 ms | 296 ms | 353 ms | 418 ms | 495 ms | 347 ms | 538 ms | 315 ms | 1.08 s | 4.70 s | 10.5 s | Postgres 1.57× | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 2.0 ms | 2.9 ms | 4.0 ms | 5.0 ms | 5.8 ms | 7.4 ms | 12 ms | 21 ms | 14 ms | 17 ms | 13 ms | 15 ms | 12 ms | 15 ms | 18 ms | 57 ms | 24 ms | 102 ms | 315 ms | 367 ms | Postgres 1.48× | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 437 ms | 478 ms | 99 ms | 171 ms | 156 ms | 171 ms | 1.42 s | 1.56 s | 1.75 s | 1.86 s | 1.75 s | 1.80 s | 1.62 s | 1.79 s | 2.79 s | 3.17 s | 1.14 s | 3.02 s | 32.4 s | 33.3 s | Postgres 1.58× | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 2.37 s | 2.49 s | 3.02 s | 3.78 s | 6.10 s | 6.43 s | 7.64 s | 10.9 s | 6.68 s | 6.98 s | 4.80 s | 4.96 s | 3.72 s | 3.94 s | 4.60 s | 5.00 s | 4.10 s | 8.02 s | 56.4 s | 57.5 s | Postgres 2.02× | |
Method
- One engine per machine. A STARTER-1xCPU-2GB in se-sto1 per engine — 1 vCPU, 2 GB, 50 GB disk — created for its engine, measured, and destroyed as soon as its results were off it
- The benchmark process runs ON the box, beside the engine, for the whole of every round. That is the premise rather than a compromise: a dp system deploys the application and its database on one machine, and this measures that machine
- Every engine held to the same 1 GB, however many processes it spends it across — MariaDB's InnoDB pool, ScyllaDB's Seastar allocation, dpdb's --ram-mb, MaxScale beside the MariaDB it fronts, FerretDB together with its backing PostgreSQL. The cap is levelling rather than tuning: an engine whose default cache is a fixed small number would otherwise be the only one unable to reach the machine
- Ten full rounds per engine. Every round drops the index set, wipes, re-seeds 250 000 rows from empty and re-runs every scenario, so the write column is ten independent fills and the read columns are ten independent processes
- Published figures pool the RAW SAMPLES across all ten rounds — 100 iterations per read cell, 500 chunk timings per write cell — and report one median and one p95 over the whole set. Not an average of ten medians, which is the median of nothing and is dragged by exactly the outliers a median resists
- 10 iterations per round per scenario, 2 warmups discarded, 200 sequential point reads per iteration
- Every READ issued through the application's data service, query handler, adapter loader and data layer — never through the driver
- The write column enters one level lower. The seeder calls the engine adapter's create_many directly, so bulk_insert is adapter plus engine without the data service above it — dp code either way and the same depth on every engine, but not the same depth as the read columns
- Writes measured as a fill from empty with the six parity indexes already in place, in 50 chunks of 5 000 rows per round
- A round that produced no measurements is a FAILED round, asserted rather than trusted. Every step of a round can exit 0 having measured nothing, and an earlier fleet reported eighty such rounds as collected before anyone opened a result file
- Row counts asserted EXACTLY before any timing. An engine holding 250 001 rows is answering a different question from one holding 250 000, and no timing it produces would show that
- Engine versions queried from each server's own interface rather than written down: MongoDB 8.0.29, PostgreSQL 17.11, MariaDB 11.4.12, MaxScale 23.08 readwritesplit, ScyllaDB 6.2.3, Cassandra 5.0.9, SQLite 3.53.3, dpdb built from 0531553347dc, CockroachDB v24.3.5, CouchDB 3.4.3
- A 120-second per-iteration ceiling — an engine that exceeds it is recorded as a timeout rather than left blank
Index parity
- Every engine: secondary indexes on _type, _besiktning_id, _system_id, _ventil_id, _room_id and _anmarkning_id
- The index SET comes from one declaration in the codebase, read by every adapter, so no engine can drift from another
- Present on every engine while the rows were written, not added afterwards, in every one of the ten rounds
- Verified from each engine's own catalog immediately before the first measurement of every round, on every box independently. The run refuses to report a number when an engine carries an index the parity set does not declare
- SQLite indexes an EXPRESSION directly — json_extract(doc, '$.field') — with no generated column and no schema change. It is the only engine here for which the parity set costs nothing structural
- MariaDB cannot index an expression at all, so its six are indexes on GENERATED columns holding the extraction
- dpdb derives index names server-side from the fields they cover, so it is the one engine whose parity set cannot be matched by NAME. It is verified by FIELDS instead — a distinction the check had to learn, having previously reported an engine carrying exactly the right six indexes as carrying none
What this run does not establish
- CockroachDB completed SEVEN of its ten rounds and its column is pooled from those seven — 70 samples per read cell where every other column has 100. Read it as a favourable estimate rather than as a like-for-like figure: the three lost rounds are the ones where the engine was in trouble, so what survived is its good behaviour. Round 7 and round 10 both died with the client reporting a connection terminated mid-seed, and round 8 hung for the full three-hour ceiling without producing anything. Ten rounds were attempted on this engine exactly as on the others.
- CouchDB REFUSED two of the eight read scenarios in all ten rounds. The relation lookup and the first page both came back from POST /_find as a server-side timeout — CouchDB declining to run the query rather than running it slowly — and those two cells carry the refusal instead of a number. Its other six are ten full rounds and are directly comparable.
- FerretDB is a REFUSAL, not a timeout and not a blank. It never completed a seed on this hardware: the kernel's oom-killer took it at 783 MB with 50 100 of 250 000 rows written, at every memory split tried, and giving it what it wanted would have meant exceeding the 1 GB every other column is held to. The number that would have made it fit is the reason it does not.
- This run and Run G answer DIFFERENT QUESTIONS and neither supersedes the other. Run G measures a database with a 32 GB machine to itself; this measures a database sharing 2 GB with the application. Read against each other they say how much of an engine's standing is its own and how much was the hardware — which is the most useful thing either of them says.
- SQLite's win is a win at THIS shape and does not generalise past it. Every request here is sequential and single-process; SQLite serialises writers, has no network protocol to multiplex, and the in-process advantage that makes its point read eight times faster is the same property that gives it nothing to offer a second application server. The scenario list is what it won, not the deployment.
- Every request was sequential. Nothing here says anything about behaviour under concurrency, which is where ScyllaDB, Cassandra and CockroachDB are designed to pay off — and it is the axis on which a 1 vCPU box is least representative of anything.
- One node each. These are single-node figures for engines whose whole design premise is more than one node, on a machine smaller than several of them document as a minimum.
- The write column is a fill from EMPTY. Only runs measuring the same shape can be read against each other.
- MaxScale is a PROXY, not a storage engine, and its column is only meaningful read against MariaDB's. The two are the same server on the same machine differing by the port, so every gap between those columns is the proxy. Reading MaxScale against PostgreSQL compares a proxied engine to a direct one and answers nothing.
- The proxy is not a flat tax, and that is the finding rather than a caveat. The point read — 200 sequential round trips — costs 31% more through MaxScale. load_all, one request returning 250 000 rows, comes back 28% FASTER, and the unindexed filter 27% faster. Run G measured the same two directions on 32 GB machines with an entirely different processor, which is a stronger claim than either run makes alone.
- MaxScale was configured with enable_root_user, because the direct run connects as root and the pair has to differ by the port alone. That is a benchmark decision, not a deployment recommendation.
- Deep page and unindexed filter are windows over an UNORDERED set — no sort is requested, so which rows come back is the engine's own natural order. Every engine returned the same COUNT on both; the row sets legitimately differ.
- ScyllaDB and Cassandra still cannot serve COUNT(*) over 250 000 rows as a single request, so their count is a paged scan. That is the adapter doing what a production implementation has to do.
- Three defects in dp's own adapters were found by this run rather than by the application, and all three are fixes to the DELETE path that a benchmark exercises harder than anything else: MariaDB matched nothing on a boolean filter, CouchDB abandoned a bulk delete on a row that was already gone, and dpdb returned from one with a tenth of the rows still present. The first two are fixed; the third is a known defect the fleet works around. None of them affects a figure below — they broke rounds rather than skewing them, which is the failure mode to prefer.
- Run G and the laptop runs before it are still on this page and their numbers are not wrong. They are correct measurements of different machines.
Run G — one machine per engine, ten rounds pooled
Drop-in comparisonAgainst PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Reference engine — every factor on this page is stated against it. Its own record above is against the fastest other engine on each of 9 scenarios, median figures.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
| Scenario | Rows | MongoDB | Postgres | Cockroach | ScyllaDB | Cassandra | CouchDB | FerretDB | MariaDB | MaxScale | Factor | Relative | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | ||||
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place. Writes | 250 000 | 31 800 rows/s | 28 000 rows/s | 14 400 rows/s | 13 500 rows/s | 2 610 rows/s | 1 850 rows/s | 17 900 rows/s | 15 900 rows/s | 28 300 rows/s | 17 100 rows/s | 10 600 rows/s | 10 100 rows/s | 6 170 rows/s | 5 850 rows/s | 12 600 rows/s | 11 400 rows/s | 12 400 rows/s | 11 200 rows/s | MongoDB 2.21× | |
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 100 ms | 125 ms | 69 ms | 76 ms | 228 ms | 285 ms | 62 ms | 90 ms | 80 ms | 105 ms | 679 ms | 705 ms | — | — | 60 ms | 78 ms | 72 ms | 89 ms | Postgres 1.44× | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 8.3 ms | 9.1 ms | 3.4 ms | 4.7 ms | 50 ms | 67 ms | 7.1 ms | 7.8 ms | 12 ms | 19 ms | 130 ms | 161 ms | 33.8 s | 35.8 s | 8.8 ms | 14 ms | 9.2 ms | 13 ms | Postgres 2.43× | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 3.3 ms | 3.6 ms | 2.1 ms | 2.4 ms | 60 ms | 82 ms | 13 ms | 17 ms | 21 ms | 27 ms | 243 ms | 1.57 s | 40.2 s | 42.2 s | 13 ms | 18 ms | 13 ms | 14 ms | Postgres 1.52× | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 11 ms | 11 ms | 4.2 ms | 4.3 ms | 29 ms | 37 ms | 269 ms | 287 ms | 138 ms | 148 ms | 3.46 s | 3.64 s | 4.23 s | 4.35 s | 74 ms | 75 ms | 73 ms | 74 ms | Postgres 2.6× | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 151 ms | 163 ms | 51 ms | 61 ms | 101 ms | 123 ms | 393 ms | 404 ms | 262 ms | 271 ms | 1.34 s | 7.47 s | 3.60 s | 3.69 s | 156 ms | 165 ms | 146 ms | 154 ms | Postgres 2.94× | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 4.4 ms | 4.9 ms | 2.1 ms | 3.4 ms | 49 ms | 65 ms | 6.3 ms | 6.7 ms | 11 ms | 11 ms | 115 ms | 124 ms | 33.7 s | 34.6 s | 9.9 ms | 10 ms | 10 ms | 11 ms | Postgres 2.1× | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 157 ms | 161 ms | 38 ms | 39 ms | 524 ms | 576 ms | 2.76 s | 2.84 s | 2.26 s | 2.36 s | 14.7 s | 15.2 s | 30.3 s | 31.8 s | 1.81 s | 1.82 s | 1.81 s | 1.82 s | Postgres 4.12× | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 3.46 s | 3.52 s | 1.11 s | 1.18 s | 1.49 s | 1.55 s | 5.26 s | 5.38 s | 3.32 s | 3.39 s | 19.3 s | 19.9 s | 55.7 s | 57.3 s | 3.81 s | 3.87 s | 2.71 s | 2.81 s | Postgres 3.12× | |
Method
- One engine per machine. Seven m5d.2xlarge in eu-north-1a, 8 vCPU, 32 GB and a local NVMe each, one engine installed per box and nothing else resident on it
- No memory limit, no CPU limit, no co-tenant. Every engine had the whole machine, which is the environment a database is actually deployed into and the opposite of Run F
- Ten full rounds per engine. Every round drops the engine, re-seeds 250 000 rows and re-runs every scenario, so the write column is ten independent fills and the read columns are ten independent processes
- No engine rotation, because there is no order to rotate. Run F rotated to cancel the effect of seven engines sharing one disk; one machine per engine removes the effect rather than correcting for it
- Published figures pool the raw samples across all ten rounds, 100 iterations per read cell and 500 chunk timings per write cell, and report one median and one p95 over the whole set
- A scenario that timed out contributes no samples and stays unanswered. FerretDB's point read timed out in all ten rounds and is published as a timeout, not as a blank
- 10 iterations per round per scenario, 2 warmups discarded, 200 sequential point reads per iteration
- Every READ issued through the application's data service, query handler, adapter loader and data layer — never through the driver
- The write column enters one level lower. The seeder calls the engine adapter's create_many directly, so bulk_insert is adapter plus engine without the data service above it — dp code either way and the same depth on every engine, but not the same depth as the read columns
- Row IDENTITY compared ACROSS MACHINES. Each box digested the _id set of every scenario it ran and wrote it to disk; the seven digest files were compared afterwards and required to match. Engines that never coexist still have to return the same rows
- Index parity asserted at measurement time on every box, every round, and the run refuses to report a number when an engine carries an index the parity set does not declare
- Writes measured as a fill from empty with the six parity indexes already in place, in 50 chunks of 5 000 rows per round
- Every machine calibrated before and after its rounds — a single-core sha256 pass and a direct-IO disk probe — so a slow box is visible as a slow box rather than as a slow engine
- Engine versions queried from each server's own interface: MongoDB 8.0.28, PostgreSQL 17.10, CockroachDB v24.3.5, ScyllaDB 6.2.3, Cassandra 5.0.8, CouchDB 3.4.3, FerretDB v1.24.0, MariaDB 11.4.12, MaxScale 23.08.13
- MariaDB and MaxScale ran on a NINTH machine of the same type, one after the other rather than at once, so each still had the box to itself while it was being measured. Same harness, same ten rounds, same 250 000 rows, same parity set
- The proxy pair differs by the PORT and nothing else — same server, same adapter, same credentials, same dataset. 3306 direct, 4006 through MaxScale's readwritesplit listener
- A 120-second per-iteration ceiling — an engine that exceeds it is recorded as a timeout rather than left blank
Index parity
- All nine engines: secondary indexes on _type, _besiktning_id, _system_id, _ventil_id, _room_id and _anmarkning_id
- The index SET comes from one declaration in the codebase, read by every adapter, so no engine can drift from another
- Present on every engine while the rows were written, not added afterwards, in every one of the ten rounds
- Verified from each engine's own catalog immediately before the first measurement of each round, on each of the seven machines independently
- FerretDB's six parity indexes were created, acknowledged, and confirmed present as b-trees in its own backing PostgreSQL — and never used once. Its point read is a full collection scan per lookup, which is why it times out
- MariaDB cannot index an expression at all, so its six are indexes on GENERATED columns holding the extraction. Which makes the query text decisive rather than cosmetic: EXPLAIN on the seeded table gives type=ref, key=dpx__besiktning_id, 4 rows when the column is referenced, and type=ALL with no key when the identical filter is written as the inline extraction. MariaDB does not rewrite one into the other the way MySQL 8 does
What this run does not establish
- The machines were not identical. Turbo clocks ranged 3105 to 3215 MHz and the single-core sha256 calibration ranged 1310 to 1365 ms, a 4.2% spread; the disks were indistinguishable at 138-140 MB/s write and 131 MB/s read. Per box: FerretDB 1310, CouchDB 1314, Cassandra 1315, ScyllaDB 1333, MongoDB 1353, PostgreSQL 1353, MariaDB 1353, CockroachDB 1365.
- The closest ranking call on the page is MariaDB's 60.4 ms point read against ScyllaDB's 62.2, a 3.0% margin. It survives correction and then some: MariaDB's box calibrates 1.5% SLOWER, so normalising moves it to 59.5 ms and widens the gap to 4.5%. It is still the one figure here where the round-to-round spread is worth reading beside the median — MariaDB ranged 55.1 to 72.7 ms across its ten rounds against ScyllaDB's 59.3 to 64.6, winning eight of ten and losing two badly.
- Every engine ran in Docker with a published port, so each round trip crossed a veth pair and a kernel DNAT — roughly 20-50 microseconds. Host networking would have removed it for free and was not used. It is a constant per scenario, identical for every engine, so it compresses the gaps slightly and cannot reorder anything; the 200-round-trip point read carries the most of it.
- Containers rather than native installs, deliberately. Seven native installs mean seven different config surfaces — ScyllaDB's setup script alone retunes the IO scheduler and hugepages — and the vendor images are the closest thing to equal treatment that exists.
- Every request was sequential. Nothing here says anything about behaviour under concurrency, which is where ScyllaDB, Cassandra and CockroachDB are designed to pay off.
- One node each. These are single-node figures for engines whose whole design premise is more than one node.
- The write column is a fill from EMPTY. Only runs measuring the same shape can be read against each other.
- Deep page and unindexed filter are windows over an UNORDERED set — no sort is requested, so which rows come back is the engine's own natural order. All seven returned the same COUNT on both; the row sets legitimately differ, and the cross-machine verification names them rather than hiding them.
- ScyllaDB and Cassandra still could not serve COUNT(*) over 250 000 rows as a single request, so their count is a paged scan. That is the adapter doing what a production implementation has to do.
- CouchDB got faster nearly everywhere once uncapped — its point read went 2 970 ms to 679 ms — and three times SLOWER on the deep page, 1 090 ms to 3 460 ms. Being handed more memory is not uniformly good news for it.
- MaxScale is a PROXY, not a storage engine, and its column is only meaningful read against MariaDB's. The two are the same server on the same machine, differing by the port — so every gap between those columns is the proxy. Reading MaxScale against PostgreSQL or MongoDB compares a proxied engine to a direct one and answers nothing.
- The proxy is not a flat tax, and that is the finding rather than a caveat. The point read — 200 sequential round trips — costs 18.7% more through MaxScale. load_all — one request returning 250 000 rows — comes back 29% FASTER, 3 811 ms direct against 2 706 ms proxied, with the ten rounds on each side not overlapping at all. The likeliest reason is that the proxy decouples backend read from client parse so the two overlap, but that is a hypothesis about a mechanism we did not instrument, and the measurement stands without it.
- MaxScale was configured with enable_root_user, because the direct run connects as root and the pair has to differ by the port alone. That is a benchmark decision, not a deployment recommendation.
- MariaDB's buffer pool was set to 24G rather than left at its 128 MB default. InnoDB opens its data files O_DIRECT — confirmed on the box — so the OS page cache does not back it, and the default really would have been its entire cache while every other engine here reaches most of the machine. That is levelling to match the others, not tuning past them: no redo log sizing, no flush tuning, no thread pool.
- Run F is still on this page and its numbers are not wrong. They are correct measurements of seven engines sharing a laptop, one of them storing its data inside another, taken five times.
Run F — one index set, no adapter extras, five rounds pooled
Drop-in comparisonAgainst PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Reference engine — every factor on this page is stated against it. Its own record above is against the fastest other engine on each of 9 scenarios, median figures.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.
| Scenario | Rows | MongoDB | Postgres | Cockroach | ScyllaDB | Cassandra | CouchDB | FerretDB | Factor | Relative | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | ||||
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place. Writes | 250 000 | 46 900 rows/s | 27 700 rows/s | 19 300 rows/s | 12 000 rows/s | 3 720 rows/s | 2 430 rows/s | 12 000 rows/s | 3 900 rows/s | 42 200 rows/s | 13 300 rows/s | 9 430 rows/s | 1 820 rows/s | 9 480 rows/s | 6 620 rows/s | MongoDB 2.43× | |
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 71 ms | 104 ms | 105 ms | 150 ms | 346 ms | 528 ms | 118 ms | 147 ms | 162 ms | 292 ms | 2.97 s | 3.04 s | — | — | MongoDB 1.49× | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 5.0 ms | 6.2 ms | 2.1 ms | 5.8 ms | 40 ms | 82 ms | 5.7 ms | 11 ms | 21 ms | 146 ms | 261 ms | 308 ms | 18.9 s | 24.8 s | Postgres 2.38× | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 1.6 ms | 2.2 ms | 1.3 ms | 2.1 ms | 37 ms | 78 ms | 9.2 ms | 18 ms | 30 ms | 112 ms | 523 ms | 647 ms | 22.7 s | 28.6 s | Postgres 1.28× | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 5.2 ms | 6.6 ms | 46 ms | 76 ms | 43 ms | 176 ms | 233 ms | 350 ms | 91 ms | 402 ms | 1.09 s | 1.21 s | 2.67 s | 4.12 s | MongoDB 8.83× | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 88 ms | 127 ms | 48 ms | 119 ms | 102 ms | 283 ms | 448 ms | 837 ms | 144 ms | 318 ms | 2.20 s | 3.35 s | 2.39 s | 3.30 s | Postgres 1.85× | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 2.9 ms | 4.3 ms | 1.5 ms | 8.8 ms | 35 ms | 76 ms | 4.7 ms | 21 ms | 9.3 ms | 45 ms | 252 ms | 297 ms | 18.5 s | 24.9 s | Postgres 1.91× | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 74 ms | 100 ms | 28 ms | 95 ms | 331 ms | 1.40 s | 3.16 s | 4.60 s | 1.14 s | 2.73 s | 28.8 s | 31.8 s | 12.8 s | 15.7 s | Postgres 2.64× | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 1.77 s | 2.24 s | 554 ms | 1.47 s | 748 ms | 1.06 s | 5.52 s | 10.0 s | 1.70 s | 3.60 s | 31.0 s | 36.3 s | 28.8 s | 33.8 s | Postgres 3.19× | |
Method
- Five full rounds. Every round drops all seven engines, re-seeds 250 000 rows into each and re-runs every scenario, so the write column is five independent fills and the read columns are five independent processes rather than five samples of one
- The engine ORDER rotates by one position per round. A fixed order pairs every engine with the same machine state every time — first on an idle box, last after six engines have hammered one disk — and that drift is otherwise indistinguishable from the engine
- Published figures pool the raw samples across all five rounds, 50 iterations per read cell and 250 chunk timings per write cell, and report one median and one p95 over the whole set
- A scenario that timed out or was refused contributes no samples and stays unanswered. An engine that fails four rounds and manages the fifth has not earned a median
- 10 iterations per round per scenario, 2 warmups discarded, 200 sequential point reads per iteration
- Every READ issued through the application's data service, query handler, adapter loader and data layer — never through the driver
- The write column enters one level lower. The seeder calls the engine adapter's create_many directly, so bulk_insert is adapter plus engine without the data service above it — dp code either way and the same depth on every engine, but not the same depth as the read columns
- The benchmark writes into its own database, fb_bench, so all seven engines hold exactly 250 000 rows and none carries production data
- Row IDENTITY compared before any timing: every scenario replayed on every engine, the _id set digested, and the digests required to match — not merely the counts
- Index parity asserted at measurement time on every engine, every round, and the run refuses to report a number when an engine carries an index the parity set does not declare
- The measuring process issues no DDL at all, so nothing it does can change the index set it is measuring against
- Writes measured as a fill from empty with the six parity indexes already in place, in 50 chunks of 5 000 rows per round
- Engine versions queried from each server's own interface rather than written down by hand
- A 120-second per-iteration ceiling — an engine that exceeds it is recorded as a timeout rather than left blank
Index parity
- All seven engines: secondary indexes on _type, _besiktning_id, _system_id, _ventil_id, _room_id and _anmarkning_id
- The index SET comes from one declaration in the codebase, read by every adapter, so no engine can drift from another
- Present on every engine while the rows were written, not added afterwards, in every one of the five rounds
- No engine carries an index another does not. Every engine went into every scenario holding its primary key and those six, verified from its own catalog immediately before the first measurement of each round
- This is the correction to Run E. PostgreSQL and CockroachDB carried five adapter indexes there, an inverted GIN over the whole document among them, and the write column was measuring that
- FerretDB's six parity indexes were created, acknowledged, and confirmed present as b-trees in its backing PostgreSQL — and never used once
What this run does not establish
- The write column is a fill from EMPTY, matching Run C and Run E rather than Run D's append onto a populated collection. Only runs measuring the same shape can be read against each other.
- Every request was sequential. Nothing here says anything about behaviour under concurrency, which is where ScyllaDB, Cassandra and CockroachDB are designed to pay off.
- One node each, developer settings, all six containers resident on one machine alongside a native MongoDB. No engine was given an isolated host.
- MongoDB runs native on loopback while the other six cross a container boundary under WSL2. That favours MongoDB and it is not corrected for.
- CockroachDB is by far the least repeatable engine here. Its exact count ranged from 175 ms to 710 ms across the five rounds and its point read from 248 ms to 505 ms, with no relation to its slot. Its median is a median of a genuinely wide distribution, and its p95 is the number to read.
- Cassandra's tail is the other one worth reading. Its relation lookup medians 21.4 ms and p95s 146 ms, and its first page medians 30 ms and p95s 112 ms — roughly a fifth of its requests cost several times the typical one.
- Deep page and unindexed filter are windows over an UNORDERED set — no sort is requested, so which 50 rows are page 200 and which 10 000 rows the cap returns is the engine's own natural order. Every engine returned the same COUNT on both; the row sets legitimately differ, and the verification step names them rather than hiding them.
- ScyllaDB and Cassandra could not serve COUNT(*) over 250 000 rows as a single request — Cassandra refused it with READ_FAILURE and ScyllaDB with READ_TIMEOUT — so their count is a paged scan. That is the adapter doing what a production implementation has to do, and it is why their count column is seconds rather than milliseconds.
- Run E's numbers are still on this page. They are not wrong measurements; they are correct measurements of a setup where two engines carried five indexes the other five did not, taken once.
Run E — seven engines, equal terms, own database
Drop-in comparisonAgainst PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Reference engine — every factor on this page is stated against it. Its own record above is against the fastest other engine on each of 9 scenarios, median figures.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.
| Scenario | Rows | MongoDB | Postgres | Cockroach | ScyllaDB | Cassandra | CouchDB | FerretDB | Factor | Relative | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | ||||
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place. Writes | 250 000 | 62 500 rows/s | 43 100 rows/s | 20 200 rows/s | 15 200 rows/s | 285 rows/s | 226 rows/s | 25 300 rows/s | 16 600 rows/s | 48 500 rows/s | 21 200 rows/s | 10 900 rows/s | 8 980 rows/s | 11 400 rows/s | 8 870 rows/s | MongoDB 3.09× | |
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 50 ms | 68 ms | 105 ms | 109 ms | 242 ms | 283 ms | 137 ms | 147 ms | 165 ms | 179 ms | 3.00 s | 3.05 s | — | — | row counts differ | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 3.5 ms | 4.0 ms | 2.0 ms | 2.1 ms | 11 ms | 12 ms | 6.9 ms | 7.6 ms | 21 ms | 34 ms | 249 ms | 254 ms | 16.0 s | 16.4 s | Postgres 1.76× | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 1.5 ms | 2.0 ms | 1.1 ms | 1.4 ms | 10 ms | 12 ms | 13 ms | 15 ms | 48 ms | 63 ms | 250 ms | 254 ms | 19.4 s | 20.1 s | Postgres 1.31× | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 5.4 ms | 6.1 ms | 42 ms | 44 ms | 20 ms | 26 ms | 7.58 s | 10.6 s | 1.51 s | 1.63 s | 29.4 s | 29.6 s | 2.49 s | 2.59 s | MongoDB 7.72× | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 67 ms | 111 ms | 36 ms | 45 ms | 42 ms | 140 ms | 5.09 s | 5.52 s | 1.44 s | 1.51 s | 26.6 s | 26.8 s | 2.25 s | 2.34 s | Postgres 1.87× | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 2.3 ms | 2.9 ms | 1.3 ms | 1.5 ms | 9.8 ms | 20 ms | 3.8 ms | 4.7 ms | 7.7 ms | 8.0 ms | 240 ms | 252 ms | 15.4 s | 16.1 s | Postgres 1.7× | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 71 ms | 74 ms | 24 ms | 26 ms | 195 ms | 1.28 s | 5.07 s | 5.16 s | 1.44 s | 1.49 s | 29.3 s | 29.4 s | 11.4 s | 11.5 s | Postgres 2.95× | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 1.89 s | 2.01 s | 488 ms | 508 ms | 533 ms | 567 ms | 5.10 s | 5.18 s | 1.45 s | 1.48 s | 29.4 s | 29.7 s | 24.9 s | 25.7 s | Postgres 3.87× | |
Method
- Median of 10 iterations, 2 warmups discarded, p95 reported alongside
- 200 sequential point reads per iteration
- Every READ issued through the application's data service, query handler, adapter loader and data layer — never through the driver
- The write column enters one level lower. The seeder calls the engine adapter's create_many directly, so bulk_insert is adapter plus engine without the data service above it — dp code either way and the same depth on every engine, but not the same depth as the read columns
- The benchmark writes into its own database, fb_bench, so all seven engines hold exactly 250 000 rows and none carries production data
- Row counts compared across all seven engines on every scenario before any timing was reported
- Writes measured as a fill from empty with the six parity indexes already in place, in 50 chunks of 5 000 rows — so the write column has a median and a p95 like every other row, not a single wall-clock average
- Engine versions queried from each server's own interface rather than written down by hand
- A 120-second per-iteration ceiling — an engine that exceeds it is recorded as a timeout rather than left blank
Index parity
- All seven engines: secondary indexes on _type, _besiktning_id, _system_id, _ventil_id, _room_id and _anmarkning_id
- The index SET comes from one declaration in the codebase, read by every adapter, so no engine can drift from another
- Present on every engine while the rows were written, not added afterwards
- PostgreSQL and CockroachDB additionally carry five adapter-created indexes, GIN included — verified by EXPLAIN to be unusable for every scenario here, so they are a write cost and not a read advantage
- FerretDB's six parity indexes were created, acknowledged, and confirmed present as b-trees in its backing PostgreSQL — and never used once
What this run does not establish
- The write column is a fill from EMPTY. Run D measured an append onto a populated collection and Run C measured a fill. Only runs measuring the same shape can be read against each other, and this one matches Run C's shape rather than Run D's.
- Every request was sequential. Nothing here says anything about behaviour under concurrency, which is where ScyllaDB, Cassandra and CockroachDB are designed to pay off.
- One node each, developer settings, all six containers resident on one machine alongside a native MongoDB. No engine was given an isolated host.
- MongoDB runs native on loopback while the other six cross a container boundary under WSL2. That favours MongoDB and it is not corrected for.
- ScyllaDB's COUNT(*) exceeded its own server read timeout (code 4608) on this dataset, so its count is a paged scan rather than a server-side aggregate. Cassandra's did the same in Run D and does here.
- CockroachDB's count() returned a string rather than a number, because its ::int is INT8 and node-postgres hands int8 back as text. The row counts matched; the types did not. Fixed in the adapter after the run — the numbers below were measured with the defect present, which cost nothing since only the parity check read the value.
Run D — seven engines, strict index parity
Drop-in comparisonAgainst PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Reference engine — every factor on this page is stated against it. Its own record above is against the fastest other engine on each of 9 scenarios, median figures.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.
| Scenario | Rows | MongoDB | Postgres | Cockroach | ScyllaDB | Cassandra | CouchDB | FerretDB | Factor | Relative | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | Median | p95 | ||||
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place. Writes | 250 000 | 44 238 rows/s | — | 11 155 rows/s | — | 536 rows/s | — | 13 305 rows/s | — | 31 328 rows/s | — | 10 001 rows/s | — | 10 399 rows/s | — | MongoDB 3.97× | |
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 62 ms | 75 ms | 98 ms | 106 ms | 222 ms | 270 ms | 103 ms | 112 ms | 150 ms | 171 ms | 3.06 s | 3.08 s | — | — | MongoDB 1.59× | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 3.8 ms | 4.2 ms | 1.9 ms | 2.5 ms | 11 ms | 12 ms | 4.6 ms | 5.3 ms | 8.9 ms | 24 ms | 261 ms | 266 ms | 16.7 s | 18.8 s | Postgres 2.01× | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 1.6 ms | 1.7 ms | 1.1 ms | 1.3 ms | 9.5 ms | 11 ms | 9.7 ms | 11 ms | 16 ms | 17 ms | 251 ms | 263 ms | 20.5 s | 20.7 s | Postgres 1.45× | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 5.5 ms | 6.1 ms | 4.6 ms | 5.1 ms | 11 ms | 15 ms | 5.15 s | 5.20 s | 1.57 s | 1.66 s | 30.0 s | 30.7 s | 2.50 s | 2.56 s | Postgres 1.18× | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 72 ms | 104 ms | 35 ms | 41 ms | 39 ms | 55 ms | 5.09 s | 5.13 s | 1.49 s | 1.54 s | 27.7 s | 28.5 s | 2.29 s | 2.32 s | Postgres 2.05× | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 2.5 ms | 2.9 ms | 1.4 ms | 1.4 ms | 10 ms | 13 ms | 3.9 ms | 4.4 ms | 7.5 ms | 8.2 ms | 252 ms | 261 ms | 16.8 s | 17.3 s | Postgres 1.76× | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 72 ms | 73 ms | 26 ms | 28 ms | 169 ms | 214 ms | 5.11 s | 5.15 s | 1.49 s | 1.53 s | 29.8 s | 34.0 s | 11.6 s | 11.9 s | row counts differ | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 1.55 s | 1.64 s | 522 ms | 653 ms | 531 ms | 688 ms | 5.12 s | 5.21 s | 1.50 s | 1.54 s | 29.7 s | 29.9 s | 25.8 s | 26.2 s | row counts differ | |
Method
- Median of 10 iterations, 2 warmups discarded, p95 reported alongside
- 200 sequential point reads per iteration
- Every READ issued through the application's data service, query handler, adapter loader and data layer — never through the driver
- The write column enters one level lower. The seeder calls the engine adapter's create_many directly, so bulk_insert is adapter plus engine without the data service above it — dp code either way and the same depth on every engine, but not the same depth as the read columns
- Row counts compared across all seven engines before any timing was reported
- Reads measured at 250 000 rows; writes measured afterwards by appending a second 250 000 on top, so every engine writes into a populated, indexed collection
- Engine versions queried from each server's own interface rather than written down by hand
- A 120-second per-iteration ceiling — an engine that exceeds it is recorded as a timeout rather than left blank
Index parity
- All seven engines: secondary indexes on _type, _besiktning_id, _system_id, _ventil_id, _room_id and _anmarkning_id
- The index SET comes from one declaration in the codebase, read by every adapter, so no engine can drift from another
- Present on every engine while the rows were written, not added afterwards
- godkand and varde left unindexed on purpose — but see the caveat: PostgreSQL and CockroachDB carry additional adapter-created indexes, including a GIN index on the whole document
What this run does not establish
- PostgreSQL and CockroachDB are NOT running unindexed on the scan scenarios. The dp adapter creates idx_fb_matning_doc_gin — a GIN index over the entire JSONB document — alongside the six parity indexes, so their filtered-scan and range-scan numbers are index-assisted where the other five engines are scanning.
- The write column is an APPEND into a collection already holding 250 000 rows. Run C measured a fill into an empty one. The two write comparisons measure different work and must not be read against each other.
- MongoDB's row count is 250 007, not 250 000 — it also holds seven real Franska Bukten measurement rows. The seven-row difference is left in rather than deleted from a live collection.
- Every request was sequential. Nothing here says anything about behaviour under concurrency, which is where ScyllaDB, Cassandra and CockroachDB are designed to pay off.
- One node each, developer settings, all seven containers resident on one machine. Idle CPU was under 1.5% per container during the run, but no engine was given an isolated host.
- FerretDB's indexed lookups came out slower than its full scans, which suggests the parity indexes were accepted and then not used. That is unexplained and is reported as measured rather than interpreted.
Run C — strict index parity, indexes present during the insert
Drop-in comparisonAgainst PostgreSQL, on 0 scenarios, median figures. No overall factor is stated: the spread is the result.
Against PostgreSQL, on 0 scenarios, median figures. No overall factor is stated: the spread is the result.
| Scenario | Rows | MongoDB | ScyllaDB | Factor | Relative | ||
|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | ||||
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place. Writes | 250 000 | 11 028 rows/s | — | 17 147 rows/s | — | — | |
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 68 ms | 83 ms | 115 ms | 122 ms | — | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 4.5 ms | 6.3 ms | 6.1 ms | 6.9 ms | — | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 1.9 ms | 2.5 ms | 12 ms | 13 ms | — | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 6.0 ms | 6.5 ms | 6.74 s | 9.63 s | — | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 80 ms | 147 ms | 6.23 s | 6.56 s | — | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 2.5 ms | 3.7 ms | 4.9 ms | 5.7 ms | — | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 76 ms | 78 ms | 6.34 s | 7.93 s | — | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 1.95 s | 2.38 s | 6.43 s | 7.15 s | — | |
Method
- Median of 10 iterations, 2 warmups discarded, p95 reported alongside
- 200 point reads per iteration
- Every READ issued through the application's data service, query handler, adapter loader and data layer — never through the driver
- The write column enters one level lower. The seeder calls the engine adapter's create_many directly, so bulk_insert is adapter plus engine without the data service above it — dp code either way and the same depth on every engine, but not the same depth as the read columns
- Row counts compared across engines before any timing was reported, and they matched on every scenario
- The whole production database migrated first: 41 collections, zero row-count mismatches
Index parity
- Both engines: secondary indexes on _type, _besiktning_id, _system_id, _ventil_id, _room_id and _anmarkning_id
- Verified by reading each engine's own index catalog, not by trusting the setup script
- Present on both engines while the 250 000 rows were written
- godkand and varde left unindexed on both sides on purpose, so the scan scenarios are genuinely unindexed work for everyone
What this run does not establish
- That MongoDB does key lookups faster in general — the point read was sequential, and ScyllaDB crossed a network boundary MongoDB did not.
- Anything about concurrency. Every request in this run was sequential; ScyllaDB's architecture is built to pay off under load, and load was not applied.
- Anything about multi-node behaviour, replication or failover. One node, developer mode.
- How well either engine could perform after being redesigned around it — that is a different test, and it is not this one.
Run B — parity indexes created after the seed
Drop-in comparison| Scenario | Rows | MongoDB | ScyllaDB | Factor | Relative | ||
|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | ||||
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 56 ms | 66 ms | 116 ms | 119 ms | — | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 4.5 ms | 5.2 ms | 4.9 ms | 6.8 ms | — | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 1.6 ms | 2.2 ms | 9.7 ms | 21 ms | — | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 5.1 ms | 6.0 ms | 5.51 s | 5.62 s | — | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 81 ms | 89 ms | 5.44 s | 5.54 s | — | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 2.8 ms | 8.4 ms | 4.6 ms | 6.3 ms | — | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 66 ms | 68 ms | 5.45 s | 5.47 s | — | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 1.99 s | 2.50 s | 5.48 s | 5.56 s | — | |
Method
- Median of 10 iterations, 2 warmups discarded
- 200 point reads per iteration
- MongoDB seeded first, indexed second — the defect
- No write measurement taken, because a valid one was not possible
Index parity
- MongoDB: six secondary indexes, created after the seed
- ScyllaDB: the same six, created with the table and present during the insert
- Field parity, but not lifecycle parity
What this run does not establish
- Any statement about write throughput. There is none in this run.
- That an index created after a bulk load behaves identically to one maintained during it.
Run A — no index parity
Drop-in comparison| Scenario | Rows | MongoDB | ScyllaDB | Factor | Relative | ||
|---|---|---|---|---|---|---|---|
| Median | p95 | Median | p95 | ||||
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place. Writes | 250 000 | 27 105 rows/s | — | 21 077 rows/s | — | — | |
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each. Key access | 200 | 51 ms | 61 ms | 114 ms | 124 ms | — | |
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key. Key access | 494 | 88 ms | 93 ms | 5.2 ms | 6.5 ms | — | |
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first. Pagination | 50 | 86 ms | 88 ms | 9.4 ms | 18 ms | — | |
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for. Pagination | 50 | 5.9 ms | 6.6 ms | 6.12 s | 20.8 s | — | |
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them. Scans and filters | 10 000 | 85 ms | 93 ms | 5.76 s | 7.36 s | — | |
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value. Scans and filters | 165 | 94 ms | 103 ms | 4.8 ms | 8.1 ms | — | |
Exact count of the whole collectionHow many records are there. Not an estimate. Whole-collection work | 250 000 | 75 ms | 78 ms | 5.61 s | 6.01 s | — | |
Load the whole collectionEvery record, deserialized into the application. Whole-collection work | 250 000 | 2.30 s | 2.85 s | 5.63 s | 5.94 s | — | |
Method
- Median of 10 iterations, 2 warmups discarded
- 200 point reads per iteration
- Every READ issued through the application's data service, not the driver
- The write column enters one level lower. The seeder calls the engine adapter's create_many directly, so bulk_insert is adapter plus engine without the data service above it — dp code either way and the same depth on every engine, but not the same depth as the read columns
Index parity
- MongoDB: the default identifier index only
- ScyllaDB: six secondary indexes, created automatically by the adapter
- Not parity — this is the defect the run exists to demonstrate
What this run does not establish
- Nothing. No comparison in this table is valid.
- Adding the six matching MongoDB indexes moved the inspection lookup from 88 ms to 4.5 ms and the sorted first page from 86 ms to 1.6 ms.
- All three ScyllaDB wins disappeared once the index sets matched.
The engines
MongoDB
Document store- Version
- 8.2.3
- Driver
- mongodb 7.2.0
Built for
- Flexible document structures
- General secondary indexes
- Ad hoc filtering and sorting
- Range queries and aggregation
- Query patterns that change after launch
How it was deployed
- Docker container from image mongo:8.0, alone on an m5d.2xlarge — 8 vCPU, 32 GB, local NVMe
- No memory limit, no CPU limit, no co-tenant, nothing else installed on the machine
- Through Run F it was the ONE engine running native on loopback with no cap while six others shared the same laptop in containers. That was the friendliest deployment in the comparison and it is gone: it won two read scenarios there and none here
ScyllaDB
Wide-column store- Version
- 6.2.3-0.20250119.bff9ddde1283, CQL 3.3.1 (Cassandra-compat 3.0.8)
- Driver
- cassandra-driver 4.9.0, consistency LOCAL_ONE, fetch size 5000
Built for
- High write throughput
- Predictable low latency under concurrency
- Horizontal scaling across nodes
- Partition-key reads
- Data models designed around known queries
How it was deployed
- Docker container from image scylladb/scylla:6.2, alone on an m5d.2xlarge
- --smp 8 --memory 24G --overprovisioned 0 --developer-mode 1 — every core on the box, against two shards and 4 GB in Run F
- Seastar reserves headroom above whatever --memory asks for, and refuses to start when the sum exceeds what the machine actually has: 24G of 32 is the ceiling that boots
- Took the point read outright here at 62.2 ms, a scenario it placed third in when it had two shards
- Its COUNT(*) exceeds the server's own read timeout on 250 000 rows (code 4608), so the adapter counts by paged scan — the same fallback Cassandra needs
PostgreSQL
Relational- Version
- 17.10 (Debian 17.10-1.pgdg13+1)
- Driver
- pg 8.22.0
Built for
- Relational integrity and transactions
- Complex joins and aggregation
- Mixed read and write workloads on one node
How it was deployed
- Docker container from image postgres:17, alone on an m5d.2xlarge, no memory limit
- Through Run F it ran capped at 2 GB and SHARED that buffer pool with FerretDB, which stored its own copy of all 250 000 documents in the same server. Giving it the machine took its deep page from 46.1 ms to 4.16 ms — eleven times faster on a slower processor
- Documents stored as JSONB, indexed fields as expression indexes over doc ->> 'field'
- Through Run E it carried five additional adapter-created indexes on top of the six parity ones, a GIN index over the whole document among them. From Run F it carries the six and its primary key, like every other engine
- Those five bought it nothing on reads either way. EXPLAIN puts the unindexed filter on a Parallel Seq Scan: dp emits doc ->> 'field', and a GIN jsonb_ops index answers containment, not text extraction. They were a write cost only
Apache Cassandra
Wide-column store- Version
- 5.0.8, CQL 3.4.7
- Driver
- cassandra-driver 4.9.0, consistency LOCAL_ONE, fetch size 5000
Built for
- High write throughput
- Multi-datacenter replication
- Query-driven table design
How it was deployed
- Docker container from image cassandra:5.0, alone on an m5d.2xlarge, no memory limit and the JVM sized against the whole 32 GB
- Ran at 3.686 GB against a 4 GB cap in Run F, under real garbage-collection pressure the entire time. Removing the cap did not make it faster in absolute terms — its write rate fell from 42 200 rows per second to 28 300 — but every engine slowed on this hardware, and it held second place on writes in both runs
- Shares the entire CQL adapter with ScyllaDB, so anything that differs between the two is the runtime and nothing else
- Its COUNT(*) exceeded the server's own 5-second read timeout, so the adapter counts by paged scan instead
Couchbase
Document storeBuilt for
- Low-latency key-value reads from cache
- SQL-style querying over documents
- Scaling read and index services independently
How it was deployed
- Not deployed yet.
CockroachDB
Distributed relational- Version
- CCL v24.3.5 (built 2025-02-03, go1.22.8)
- Driver
- pg 8.22.0 — the PostgreSQL driver, unchanged
Built for
- Horizontal scale without giving up SQL
- Serializable distributed transactions
- Surviving the loss of a node or a region
How it was deployed
- Docker container from image cockroachdb/cockroach:v24.3.5, single node, --insecure, alone on an m5d.2xlarge, no memory limit
- Shares the entire PostgreSQL adapter and the same expression indexes
- Carried the same five extra adapter-created indexes as PostgreSQL through Run E, GIN included. Removing them for Run F moved its write throughput by a factor of thirteen — its schema changes are asynchronous, so it had been building an inverted index over 250 000 documents underneath the measurement
- Returns its row counts as strings where every other engine returns numbers
- It was the least repeatable engine in the set on the shared laptop — its exact count ranged from 175 ms to 710 ms across Run F's five rounds. Alone on a machine it tightened to a p95 within 1.1x of its median on that same scenario, which suggests the spread was contention rather than the engine
- Still writes slower than anything else here by a wide margin: 2 610 rows per second, and 100.8 seconds to complete a fill MongoDB finishes in 8.4
- On a 2 GB single-core box it could not complete the run: seven rounds of ten finished, two died with the client reporting the connection terminated mid-seed, and one hung for the full three-hour ceiling. Its fill there runs at 1 140 rows per second — 286 seconds, against MongoDB's 12.8 — and its published column is pooled from the seven that survived
Apache CouchDB
Document store- Version
- 3.4.3 (e12b967d7)
- Driver
- none — CouchDB's whole interface is HTTP and JSON, so fetch is the client
Built for
- Multi-master replication, including to offline clients
- Crash-only durability
- Reading through precomputed views
How it was deployed
- Docker container from image couchdb:3.4, alone on an m5d.2xlarge, no memory limit
- The engine that gained most from being uncapped and lost on one scenario anyway: point read 2 970 ms to 679 ms, count 28.8 s to 14.7 s, first page 523 ms to 243 ms — and deep page 1 090 ms to 3 460 ms, three times slower
- Owns the widest tail in Run G. Its first page medians 243 ms and p95s 1 570 ms, and its unindexed filter medians 1 340 ms and p95s 7 470 ms — roughly one request in twenty costs six times the typical one
- One CouchDB database per dp collection, which makes an exact count a metadata read rather than a scan
- dp's underscore-prefixed fields are ILLEGAL here — CouchDB reserves that prefix and answers doc_validation: Bad special document member: _type. Every field is renamed on write and back on read.
- A Mango index is required to sort: this is the one engine that refuses a query rather than degrading, so the adapter sorts in the application when no index covers the field
- On a 2 GB single-core box it finished all ten rounds and then refused two scenarios in every one of them: the relation lookup and the first page came back from POST /_find as a server-side timeout, the engine declining the query rather than running it slowly
FerretDB
Document layer over PostgreSQL- Version
- v1.24.0, presenting MongoDB wire 7.0.42
- Driver
- mongodb 7.2.0 — the MongoDB driver, unchanged
Built for
- Keeping MongoDB drivers and queries on an open-source stack
- Reusing PostgreSQL operations, backups and tooling
- Migrating away from a document store without rewriting queries
How it was deployed
- Docker container from image ghcr.io/ferretdb/ferretdb:1.24.0, alone on an m5d.2xlarge with its own PostgreSQL 17.10 container beside it, no memory limit on either
- THROUGH RUN F ITS BACKING STORE WAS THE POSTGRESQL BEING MEASURED. Its data — a second full copy of all 250 000 documents — lived in the buffer pool of the engine in the next column, capped at 2 GB. No index-parity check catches that, and nothing in Run F's output shows it
- Separating them cost FerretDB about half its speed across the board and made PostgreSQL eleven times faster on the deep page. The sharing was never helping FerretDB; it was destroying PostgreSQL
- Needed no adapter code at all — dp's MongoDB queries arrive unchanged
- No change streams, so dp subscriptions cannot be served: the one capability gap here that is not a performance question
- Its indexed lookups measure SLOWER than its full scans, and its backing PostgreSQL says why: 302 sequential scans, 67 750 000 tuples read, idx_scan = 0 on every index including the one on _id. The six parity indexes exist there as real b-trees and were never used once.
MariaDB
Relational- Version
- 11.4.12-MariaDB-ubu2404
- Driver
- mysql2 3.23.1
Built for
- Relational integrity and transactions
- Complex joins and aggregation
- Drop-in replacement for MySQL deployments
- Mixed read and write workloads on one node
How it was deployed
- Docker container from image mariadb:11.4, alone on an m5d.2xlarge
- innodb-buffer-pool-size=24G. This is LEVELLING, not tuning: InnoDB opens its data files O_DIRECT — confirmed on the box, @@innodb_flush_method = O_DIRECT — so the OS page cache does not back it and the 128 MB default really would be the whole cache. Every other engine here reaches most of the machine by default, and leaving MariaDB at 128 MB would have made it the only one that could not
- max_allowed_packet=256M, because the seeder writes in chunks of five thousand documents and each chunk arrives as one multi-row INSERT. A protocol requirement of the harness, not a performance knob
- Nothing else is set. No redo log sizing, no flush tuning, no thread pool
- utf8mb4_bin throughout. The default utf8mb4_general_ci is case-INSENSITIVE, which would have made `equals` match rows PostgreSQL, MongoDB and CQL all reject — a different row count from the same filter, and a digest mismatch that would have looked like an adapter bug
- MariaDB CANNOT index an expression. PostgreSQL indexes doc ->> 'field' directly and MySQL 8 has functional indexes; MariaDB has neither, so each of the six parity fields carries a generated column and the index sits on that column
- Which makes the query text decisive rather than cosmetic. EXPLAIN on this table: referencing the generated column gives type=ref, key=dpx__besiktning_id, 4 rows. The identical query written as the inline extraction gives type=ALL, key=NULL, full scan. MariaDB does not rewrite one into the other
dpdb
Document store- Version
- built from 0531553347dc, --ram-mb 700
- Driver
- none — fetch over HTTP for queries, a websocket for subscriptions
Built for
- Running beside the application on a small machine
- A memory ceiling that does not follow the dataset
- Push subscriptions as a first-class feature
- dp's own query shapes with no translation layer
How it was deployed
- A single compiled binary under systemd on a STARTER-1xCPU-2GB in se-sto1 — not a container, because a container is not how dpdb is deployed anywhere
- The only engine on this page with no image to pull and no daemon to configure beyond a unit file and a data directory
- Its memory ceiling is an argument (--ram-mb 700) rather than a cgroup limit, which is the difference the row above is claiming: the engine bounds itself instead of being bounded
SQLite
Embedded relational- Version
- SQLite 3.53.3
- Driver
- node:sqlite, built into Node v24.19.0 — there is no package to install
Built for
- Deploying a database by shipping no database
- Read-heavy workloads on one machine
- Embedded and edge installs with no operator
- Zero operational surface — no port, no user, no daemon
How it was deployed
- A STARTER-1xCPU-2GB in se-sto1, like every other engine here, and the only one where nothing was installed on it
- The whole install step is `mkdir /var/lib/dp-sqlite`. The fleet bootstrap puts Node on the box, and Node already contains the database — that emptiness is the finding, not an oversight
- No port, no user, no daemon, no container and no memory limit, because there is no second process to limit. The engine runs inside the benchmark process on the benchmark's own thread
MariaDB behind MaxScale
Proxy over MariaDB- Version
- MaxScale 23.08.13 (readwritesplit) in front of MariaDB 11.4.12
- Driver
- mysql2 3.23.1 — the same driver, pointed at port 4006
Built for
- Connection pooling and load balancing in front of a cluster
- Read/write splitting without application changes
- Failover that the application never sees
- Query filtering, rewriting and logging in one place
How it was deployed
- Docker container from image mariadb/maxscale:23.08, on the SAME m5d.2xlarge as the MariaDB it fronts
- One machine and not two on purpose. The proxy is the only variable, so the processor, the disk, the buffer pool, the dataset and the adapter have to be the same physical thing rather than the same specification
- readwritesplit, not readconnroute. readconnroute forwards bytes without understanding them and would have measured a TCP hop; readwritesplit PARSES every statement to decide where it goes, which is what a real MaxScale deployment does and where its cost actually lives
- One backend server, marked Master by mariadbmon. Adding replicas would have changed the engine as well as the path to it, and then nothing separable would be left to measure
- Zero lines of adapter code. dp's MariaDB queries arrive unchanged — the connection string is the whole difference in the codebase, which is exactly what makes the pair a measurement of the proxy