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.

Written by:Adrian Rosca

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.

Massinläggning, 250 000 poster
Higher is better · rader per sekund
MongoDB
23 900 rows/s
Postgres
12 500 rows/s
MariaDB
11 700 rows/s
MaxScale
11 700 rows/s
SQLite
10 400 rows/s
dpdb
6 860 rows/s
CouchDB
5 930 rows/s
Cassandra
4 920 rows/s
ScyllaDB
3 380 rows/s
Cockroach
1 140 rows/s
Staplarna är logaritmiska — spridningen går till 21×. Tio fyllningar per motor, 500 styckmätningar poolade per stapel. Högre är bättre.

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.

Punktläsning på identifierare, 200 anrop
Lower is better · millisekunder
SQLite
9.7 ms
Postgres
81 ms
MariaDB
88 ms
MaxScale
116 ms
Cassandra
125 ms
MongoDB
129 ms
Cockroach
218 ms
ScyllaDB
240 ms
dpdb
428 ms
CouchDB
1.21 s
Staplarna är logaritmiska — spridningen går till 125×. Tvåhundra sekventiella uppslag per iteration, så den här stapeln prissätter en tur och retur lika mycket som en läsning.

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.

Alla mätvärden för en besiktning
Lower is better · millisekunder
SQLite
3.4 ms
Postgres
8.0 ms
MongoDB
11 ms
MaxScale
14 ms
ScyllaDB
16 ms
Cockroach
17 ms
dpdb
18 ms
MariaDB
19 ms
Cassandra
22 ms
CouchDB
Staplarna är logaritmiska — spridningen går till 6×.

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.

Första sorterade sidan, 50 rader
Lower is better · millisekunder
SQLite
2.9 ms
Postgres
3.9 ms
MongoDB
7.2 ms
MaxScale
21 ms
MariaDB
22 ms
ScyllaDB
28 ms
dpdb
31 ms
Cassandra
40 ms
Cockroach
47 ms
CouchDB
Staplarna är logaritmiska — spridningen går till 16×.

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.

Djup sida, sida 200
Lower is better · millisekunder
Postgres
5.0 ms
MongoDB
12 ms
SQLite
17 ms
dpdb
59 ms
MaxScale
71 ms
MariaDB
72 ms
Cockroach
85 ms
Cassandra
201 ms
ScyllaDB
249 ms
CouchDB
20.4 s
Staplarna är logaritmiska — spridningen går till 4088×.

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.

Filtrerad genomsökning på ett oindexerat booleskt fält
Lower is better · millisekunder
SQLite
75 ms
Postgres
141 ms
MongoDB
221 ms
dpdb
244 ms
MaxScale
296 ms
Cockroach
315 ms
Cassandra
347 ms
MariaDB
405 ms
ScyllaDB
418 ms
CouchDB
4.70 s
Staplarna är logaritmiska — spridningen går till 63×.

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.

Intervallsökning på ett oindexerat tal
Lower is better · millisekunder
SQLite
2.0 ms
Postgres
4.0 ms
MongoDB
5.8 ms
ScyllaDB
12 ms
dpdb
12 ms
MaxScale
13 ms
MariaDB
14 ms
Cassandra
18 ms
Cockroach
24 ms
CouchDB
315 ms
Staplarna är logaritmiska — spridningen går till 155×.

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.

Exakt räkning av hela samlingen
Lower is better · millisekunder
Postgres
99 ms
MongoDB
156 ms
SQLite
437 ms
Cockroach
1.14 s
dpdb
1.42 s
ScyllaDB
1.62 s
MariaDB
1.75 s
MaxScale
1.75 s
Cassandra
2.79 s
CouchDB
32.4 s
Staplarna är logaritmiska — spridningen går till 327×. ScyllaDB och Cassandra kan inte leverera COUNT(*) över 250 000 rader som ett anrop. Deras siffra är en sidbruten genomsökning.

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.

Läs in hela samlingen
Lower is better · millisekunder
SQLite
2.37 s
Postgres
3.02 s
ScyllaDB
3.72 s
Cockroach
4.10 s
Cassandra
4.60 s
MaxScale
4.80 s
MongoDB
6.10 s
MariaDB
6.68 s
dpdb
7.64 s
CouchDB
56.4 s
Staplarna är logaritmiska — spridningen går till 24×.

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.

Reference engine
Compared with
Factor from
Test kind
Scenario groups in focus

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 comparison
SQLiteEmbedded relational
6Won
3Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

PostgreSQLRelational
2Fastest
7Beaten
0Parity

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.

MongoDBDocument store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

dpdbDocument store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

MariaDBRelational
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

MariaDB behind MaxScaleProxy over MariaDB
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

ScyllaDBWide-column store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CassandraWide-column store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

CockroachDBDistributed relational
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CouchDBDocument store
0Won
7Lost
0Parity

Against PostgreSQL, on 7 scenarios, median figures. No overall factor is stated: the spread is the result.

Run H — one 2 GB box per engine, the application on it too — 250 000 generated measurement records in a dedicated database on every engine, seeded from empty, no live data mixed in, ten independent times per engine. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 9 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsSQLitePostgresMongoDBdpdbMariaDBMaxScaleScyllaDBCassandraCockroachCouchDBFactorRelative
Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place.
Writes
250 00010 400 rows/s7 490 rows/s12 500 rows/s10 900 rows/s23 900 rows/s10 500 rows/s6 860 rows/s2 730 rows/s11 700 rows/s9 890 rows/s11 700 rows/s10 300 rows/s3 380 rows/s2 280 rows/s4 920 rows/s2 450 rows/s1 140 rows/s462 rows/s5 930 rows/s5 200 rows/sMongoDB 1.91×
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
2009.7 ms33 ms81 ms109 ms129 ms205 ms428 ms572 ms88 ms135 ms116 ms166 ms240 ms309 ms125 ms246 ms218 ms434 ms1.21 s1.76 sPostgres 1.6×
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
4943.4 ms6.3 ms8.0 ms10 ms11 ms15 ms18 ms25 ms19 ms33 ms14 ms29 ms16 ms20 ms22 ms78 ms17 ms52 msPostgres 1.41×
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
502.9 ms3.9 ms3.9 ms5.0 ms7.2 ms10 ms31 ms75 ms22 ms25 ms21 ms26 ms28 ms36 ms40 ms113 ms47 ms143 msPostgres 1.86×
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
5017 ms21 ms5.0 ms6.2 ms12 ms24 ms59 ms115 ms72 ms78 ms71 ms84 ms249 ms326 ms201 ms378 ms85 ms164 ms20.4 s21.3 sPostgres 2.32×
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 00075 ms87 ms141 ms192 ms221 ms246 ms244 ms325 ms405 ms457 ms296 ms353 ms418 ms495 ms347 ms538 ms315 ms1.08 s4.70 s10.5 sPostgres 1.57×
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
1652.0 ms2.9 ms4.0 ms5.0 ms5.8 ms7.4 ms12 ms21 ms14 ms17 ms13 ms15 ms12 ms15 ms18 ms57 ms24 ms102 ms315 ms367 msPostgres 1.48×
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 000437 ms478 ms99 ms171 ms156 ms171 ms1.42 s1.56 s1.75 s1.86 s1.75 s1.80 s1.62 s1.79 s2.79 s3.17 s1.14 s3.02 s32.4 s33.3 sPostgres 1.58×
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0002.37 s2.49 s3.02 s3.78 s6.10 s6.43 s7.64 s10.9 s6.68 s6.98 s4.80 s4.96 s3.72 s3.94 s4.60 s5.00 s4.10 s8.02 s56.4 s57.5 sPostgres 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 comparison
MongoDBDocument store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

PostgreSQLRelational
7Fastest
2Beaten
0Parity

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.

CockroachDBDistributed relational
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

ScyllaDBWide-column store
2Won
7Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CassandraWide-column store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CouchDBDocument store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

FerretDBDocument layer over PostgreSQL
0Won
8Lost
0Parity

Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.

MariaDBRelational
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

MariaDB behind MaxScaleProxy over MariaDB
0Won
8Lost
1Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Run G — one machine per engine, ten rounds pooled — 250 000 generated measurement records in a dedicated database on every engine, seeded from empty, no live data mixed in, ten independent times per engine. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 9 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsMongoDBPostgresCockroachScyllaDBCassandraCouchDBFerretDBMariaDBMaxScaleFactorRelative
Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place.
Writes
250 00031 800 rows/s28 000 rows/s14 400 rows/s13 500 rows/s2 610 rows/s1 850 rows/s17 900 rows/s15 900 rows/s28 300 rows/s17 100 rows/s10 600 rows/s10 100 rows/s6 170 rows/s5 850 rows/s12 600 rows/s11 400 rows/s12 400 rows/s11 200 rows/sMongoDB 2.21×
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
200100 ms125 ms69 ms76 ms228 ms285 ms62 ms90 ms80 ms105 ms679 ms705 ms60 ms78 ms72 ms89 msPostgres 1.44×
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
4948.3 ms9.1 ms3.4 ms4.7 ms50 ms67 ms7.1 ms7.8 ms12 ms19 ms130 ms161 ms33.8 s35.8 s8.8 ms14 ms9.2 ms13 msPostgres 2.43×
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
503.3 ms3.6 ms2.1 ms2.4 ms60 ms82 ms13 ms17 ms21 ms27 ms243 ms1.57 s40.2 s42.2 s13 ms18 ms13 ms14 msPostgres 1.52×
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
5011 ms11 ms4.2 ms4.3 ms29 ms37 ms269 ms287 ms138 ms148 ms3.46 s3.64 s4.23 s4.35 s74 ms75 ms73 ms74 msPostgres 2.6×
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 000151 ms163 ms51 ms61 ms101 ms123 ms393 ms404 ms262 ms271 ms1.34 s7.47 s3.60 s3.69 s156 ms165 ms146 ms154 msPostgres 2.94×
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
1654.4 ms4.9 ms2.1 ms3.4 ms49 ms65 ms6.3 ms6.7 ms11 ms11 ms115 ms124 ms33.7 s34.6 s9.9 ms10 ms10 ms11 msPostgres 2.1×
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 000157 ms161 ms38 ms39 ms524 ms576 ms2.76 s2.84 s2.26 s2.36 s14.7 s15.2 s30.3 s31.8 s1.81 s1.82 s1.81 s1.82 sPostgres 4.12×
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0003.46 s3.52 s1.11 s1.18 s1.49 s1.55 s5.26 s5.38 s3.32 s3.39 s19.3 s19.9 s55.7 s57.3 s3.81 s3.87 s2.71 s2.81 sPostgres 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 comparison
MongoDBDocument store
3Won
6Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

PostgreSQLRelational
6Fastest
3Beaten
0Parity

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.

CockroachDBDistributed relational
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

ScyllaDBWide-column store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CassandraWide-column store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CouchDBDocument store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

FerretDBDocument layer over PostgreSQL
0Won
8Lost
0Parity

Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.

Run F — one index set, no adapter extras, five rounds pooled — 250 000 generated measurement records in a dedicated database on every engine, seeded from empty, no live data mixed in, five independent times. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 9 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsMongoDBPostgresCockroachScyllaDBCassandraCouchDBFerretDBFactorRelative
Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place.
Writes
250 00046 900 rows/s27 700 rows/s19 300 rows/s12 000 rows/s3 720 rows/s2 430 rows/s12 000 rows/s3 900 rows/s42 200 rows/s13 300 rows/s9 430 rows/s1 820 rows/s9 480 rows/s6 620 rows/sMongoDB 2.43×
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
20071 ms104 ms105 ms150 ms346 ms528 ms118 ms147 ms162 ms292 ms2.97 s3.04 sMongoDB 1.49×
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
4945.0 ms6.2 ms2.1 ms5.8 ms40 ms82 ms5.7 ms11 ms21 ms146 ms261 ms308 ms18.9 s24.8 sPostgres 2.38×
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
501.6 ms2.2 ms1.3 ms2.1 ms37 ms78 ms9.2 ms18 ms30 ms112 ms523 ms647 ms22.7 s28.6 sPostgres 1.28×
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
505.2 ms6.6 ms46 ms76 ms43 ms176 ms233 ms350 ms91 ms402 ms1.09 s1.21 s2.67 s4.12 sMongoDB 8.83×
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 00088 ms127 ms48 ms119 ms102 ms283 ms448 ms837 ms144 ms318 ms2.20 s3.35 s2.39 s3.30 sPostgres 1.85×
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
1652.9 ms4.3 ms1.5 ms8.8 ms35 ms76 ms4.7 ms21 ms9.3 ms45 ms252 ms297 ms18.5 s24.9 sPostgres 1.91×
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 00074 ms100 ms28 ms95 ms331 ms1.40 s3.16 s4.60 s1.14 s2.73 s28.8 s31.8 s12.8 s15.7 sPostgres 2.64×
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0001.77 s2.24 s554 ms1.47 s748 ms1.06 s5.52 s10.0 s1.70 s3.60 s31.0 s36.3 s28.8 s33.8 sPostgres 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 comparison
MongoDBDocument store
3Won
6Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

PostgreSQLRelational
6Fastest
3Beaten
0Parity

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.

CockroachDBDistributed relational
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

ScyllaDBWide-column store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CassandraWide-column store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CouchDBDocument store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

FerretDBDocument layer over PostgreSQL
0Won
8Lost
0Parity

Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.

Run E — seven engines, equal terms, own database — 250 000 generated measurement records in a dedicated database on every engine, seeded from empty, no live data mixed in. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 9 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsMongoDBPostgresCockroachScyllaDBCassandraCouchDBFerretDBFactorRelative
Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place.
Writes
250 00062 500 rows/s43 100 rows/s20 200 rows/s15 200 rows/s285 rows/s226 rows/s25 300 rows/s16 600 rows/s48 500 rows/s21 200 rows/s10 900 rows/s8 980 rows/s11 400 rows/s8 870 rows/sMongoDB 3.09×
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
20050 ms68 ms105 ms109 ms242 ms283 ms137 ms147 ms165 ms179 ms3.00 s3.05 srow counts differ
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
4943.5 ms4.0 ms2.0 ms2.1 ms11 ms12 ms6.9 ms7.6 ms21 ms34 ms249 ms254 ms16.0 s16.4 sPostgres 1.76×
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
501.5 ms2.0 ms1.1 ms1.4 ms10 ms12 ms13 ms15 ms48 ms63 ms250 ms254 ms19.4 s20.1 sPostgres 1.31×
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
505.4 ms6.1 ms42 ms44 ms20 ms26 ms7.58 s10.6 s1.51 s1.63 s29.4 s29.6 s2.49 s2.59 sMongoDB 7.72×
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 00067 ms111 ms36 ms45 ms42 ms140 ms5.09 s5.52 s1.44 s1.51 s26.6 s26.8 s2.25 s2.34 sPostgres 1.87×
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
1652.3 ms2.9 ms1.3 ms1.5 ms9.8 ms20 ms3.8 ms4.7 ms7.7 ms8.0 ms240 ms252 ms15.4 s16.1 sPostgres 1.7×
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 00071 ms74 ms24 ms26 ms195 ms1.28 s5.07 s5.16 s1.44 s1.49 s29.3 s29.4 s11.4 s11.5 sPostgres 2.95×
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0001.89 s2.01 s488 ms508 ms533 ms567 ms5.10 s5.18 s1.45 s1.48 s29.4 s29.7 s24.9 s25.7 sPostgres 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 comparison
MongoDBDocument store
2Won
7Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

PostgreSQLRelational
6Fastest
2Beaten
1Parity

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.

CockroachDBDistributed relational
0Won
8Lost
1Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

ScyllaDBWide-column store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CassandraWide-column store
1Won
8Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

Apache CouchDBDocument store
0Won
9Lost
0Parity

Against PostgreSQL, on 9 scenarios, median figures. No overall factor is stated: the spread is the result.

FerretDBDocument layer over PostgreSQL
0Won
8Lost
0Parity

Against PostgreSQL, on 8 scenarios, median figures. No overall factor is stated: the spread is the result.

Run D — seven engines, strict index parity — 250 000 measurement records from a seeded generator, so every engine holds byte-identical rows. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 9 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsMongoDBPostgresCockroachScyllaDBCassandraCouchDBFerretDBFactorRelative
Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95Medianp95
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place.
Writes
250 00044 238 rows/s11 155 rows/s536 rows/s13 305 rows/s31 328 rows/s10 001 rows/s10 399 rows/sMongoDB 3.97×
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
20062 ms75 ms98 ms106 ms222 ms270 ms103 ms112 ms150 ms171 ms3.06 s3.08 sMongoDB 1.59×
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
4943.8 ms4.2 ms1.9 ms2.5 ms11 ms12 ms4.6 ms5.3 ms8.9 ms24 ms261 ms266 ms16.7 s18.8 sPostgres 2.01×
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
501.6 ms1.7 ms1.1 ms1.3 ms9.5 ms11 ms9.7 ms11 ms16 ms17 ms251 ms263 ms20.5 s20.7 sPostgres 1.45×
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
505.5 ms6.1 ms4.6 ms5.1 ms11 ms15 ms5.15 s5.20 s1.57 s1.66 s30.0 s30.7 s2.50 s2.56 sPostgres 1.18×
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 00072 ms104 ms35 ms41 ms39 ms55 ms5.09 s5.13 s1.49 s1.54 s27.7 s28.5 s2.29 s2.32 sPostgres 2.05×
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
1652.5 ms2.9 ms1.4 ms1.4 ms10 ms13 ms3.9 ms4.4 ms7.5 ms8.2 ms252 ms261 ms16.8 s17.3 sPostgres 1.76×
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 00072 ms73 ms26 ms28 ms169 ms214 ms5.11 s5.15 s1.49 s1.53 s29.8 s34.0 s11.6 s11.9 srow counts differ
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0001.55 s1.64 s522 ms653 ms531 ms688 ms5.12 s5.21 s1.50 s1.54 s29.7 s29.9 s25.8 s26.2 srow 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 comparison
MongoDBDocument store
0Won
0Lost
0Parity

Against PostgreSQL, on 0 scenarios, median figures. No overall factor is stated: the spread is the result.

ScyllaDBWide-column store
0Won
0Lost
0Parity

Against PostgreSQL, on 0 scenarios, median figures. No overall factor is stated: the spread is the result.

Run C — strict index parity, indexes present during the insert — 250 006 measurement records, generated from a seeded generator so both engines hold identical rows. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 9 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsMongoDBScyllaDBFactorRelative
Medianp95Medianp95
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place.
Writes
250 00011 028 rows/s17 147 rows/s
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
20068 ms83 ms115 ms122 ms
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
4944.5 ms6.3 ms6.1 ms6.9 ms
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
501.9 ms2.5 ms12 ms13 ms
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
506.0 ms6.5 ms6.74 s9.63 s
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 00080 ms147 ms6.23 s6.56 s
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
1652.5 ms3.7 ms4.9 ms5.7 ms
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 00076 ms78 ms6.34 s7.93 s
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0001.95 s2.38 s6.43 s7.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
Invalid run, published anyway.The six MongoDB indexes were created after the rows were written, so no write comparison exists for this run and the reads were served by indexes built on settled data rather than maintained through the insert. The reads are close to the valid run; the method still fails.
Run B — parity indexes created after the seed — 250 006 measurement records. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 8 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsMongoDBScyllaDBFactorRelative
Medianp95Medianp95
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
20056 ms66 ms116 ms119 ms
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
4944.5 ms5.2 ms4.9 ms6.8 ms
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
501.6 ms2.2 ms9.7 ms21 ms
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
505.1 ms6.0 ms5.51 s5.62 s
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 00081 ms89 ms5.44 s5.54 s
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
1652.8 ms8.4 ms4.6 ms6.3 ms
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 00066 ms68 ms5.45 s5.47 s
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0001.99 s2.50 s5.48 s5.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
Invalid run, published anyway.MongoDB carried only its default identifier index while ScyllaDB carried six secondary indexes. The three ScyllaDB wins below are artefacts of that gap, not properties of either engine. The timings are real; they answer the wrong question.
Run A — no index parity — 250 006 measurement records. Factors are computed from the median figures printed in this table, stating MongoDB against Postgres. All 9 scenarios are listed; the group filter dims rows, it does not remove them.
ScenarioRowsMongoDBScyllaDBFactorRelative
Medianp95Medianp95
Bulk insert, 250 000 recordsWrite throughput with six secondary indexes already in place.
Writes
250 00027 105 rows/s21 077 rows/s
Point read by identifier, 200 requests200 sequential primary-key lookups, one round trip each.
Key access
20051 ms61 ms114 ms124 ms
All measurements for one inspectionEvery child record of one parent, on an indexed foreign key.
Key access
49488 ms93 ms5.2 ms6.5 ms
First sorted page, 50 rowsPage one of one inspection's measurements, sorted by measurement date, newest first.
Pagination
5086 ms88 ms9.4 ms18 ms
Deep page, page 200Skip 9 950 rows, return the next 50. What a numbered pager asks for.
Pagination
505.9 ms6.6 ms6.12 s20.8 s
Filtered scan on an unindexed booleanEvery record where an approval flag is false. Indexed on none of them.
Scans and filters
10 00085 ms93 ms5.76 s7.36 s
Range scan on an unindexed numberOne indexed equality plus a greater-than on an unindexed measurement value.
Scans and filters
16594 ms103 ms4.8 ms8.1 ms
Exact count of the whole collectionHow many records are there. Not an estimate.
Whole-collection work
250 00075 ms78 ms5.61 s6.01 s
Load the whole collectionEvery record, deserialized into the application.
Whole-collection work
250 0002.30 s2.85 s5.63 s5.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 store
Not yet measured

Built 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