Cruxly

Cruxly › System design › Databases: Choosing One

How to choose a database

start from the workload, default to relational, leave for a measured reason

Open the interactive lesson

Picking a database is a short chain of reasoning, not a product preference. Write down the access patterns, the read to write ratio, the consistency need, the size and the latency target. Start from a relational database, and leave only when one of those numbers cannot be met by a primary, replicas, a cache and partitioning. Every other store you add is a derived copy of one system of record.

Start from the workload, not the product

Candidates lose this question in the first ten seconds by naming a product. Ask for, or state, the workload first. Eight things decide almost everything:

QuestionWhat you are really asking
Access patternsKey lookups, range scans, joins, aggregations, text search? Which five queries matter?
Read/write ratio and peak rateReads per second and writes per second at peak, not average
Consistency needsWhich data must never be stale or lost (money, inventory)? Which can lag (feeds, counters)?
Data size and growthBytes per record x records x retention. Does it fit one machine in two years?
Latency targetA p99 number, and for which query
Query flexibilityDo you know every query today, or will product invent new ones?
Team and operational maturityWho is on call for this store at 3 a.m., and have they run one before?
CostInstances, storage, provisioned IOPS, cross-region traffic, and the cost of the people

Give numbers. "About 60,000 reads and 2,000 writes a second, 400 GB, strong consistency for payments" is a workload. "A lot of data" is not. If the interviewer has not given you the numbers, say your assumptions out loud and ask whether they are fair.

Expect the interviewer to push on each row. "What if reads double?" tests the read/write ratio. "What if product wants a new filter next quarter?" tests query flexibility, and it is the question that quietly kills stores where the query is fixed at design time. "Who runs this?" tests maturity. Have a one-line answer for each before you start drawing boxes.

The order matters. Access patterns come first because they decide the data model, and the model decides which stores are even candidates. Cost comes last because it breaks ties between options that already work.

:::key State assumptions, then derive size Estimate the total yourself: records per day x bytes per record x retention. A candidate who computes "2 TB in a year" before choosing a store shows the judgement the question is testing. :::

Default to relational, and leave only for a reason

The default is a relational database, PostgreSQL or MySQL. Say that sentence in the interview, then make the other side earn its place. The reasons are practical: you get multi-row transactions, constraints the database enforces, ad-hoc queries for questions nobody has asked yet, secondary indexes that stay consistent, mature tooling and a hiring pool that knows it. Later lessons cover what transactions and isolation actually guarantee.

One primary plus replicas, a cache in front, and large tables partitioned goes a long way. Reads scale by adding replicas. The part you cannot copy freely is the primary, because every write still lands there. So the one number to watch is write load on the primary. I did not find a trustworthy universal "writes per second per primary" rule of thumb, and you should not invent one in an interview. Say instead: "I'd measure it, and here is the signal that tells me to move."

What leaving looks like. When the primary saturates, you have two families of exits:

:::warn Version-dated complaints Uber's 2016 post about leaving PostgreSQL for MySQL is often quoted. It was based mainly on PostgreSQL 9.2, a release about four years old at the time (Uber's own post called it "somewhat old" and noted 9.5 was current). It cited write amplification from indexes, physical replication cost across data centres, replicas that could not run long reads without lagging, and process-per-connection limits. Some of that has been mitigated since. Quote it as "one company's reasons in 2016", never as a current fact about Postgres. :::

Real reasons to leave, and reasons that are not

A real reason has two parts: a requirement and a measurement showing one primary plus replicas, cache and partitioning cannot meet it.

Genuine reasonWhy relational strugglesWhere you go
Sustained write rate beyond one primary, with a natural partition keyOne writer; sharding adds operational workSharded relational, or an LSM wide-column/key-value store
Active-active writes in several regionsSingle-leader means distant writers pay cross-region latency or lose availabilityDynamo-style stores, or distributed SQL
Time-series or analytics scaleRow stores read whole rows when queries touch a few columnsColumnar warehouse, time-series store
Deep graph traversalsEach hop is another joinGraph store (or recursive SQL when small)
Relevance-ranked text or vector similarityB-trees do not rank by relevance. Postgres full-text search (a GIN inverted index) covers modest needs; fuzzy matching, faceting at scale, custom analyzers or load isolation outgrow itDedicated inverted-index search engine, vector index
Key-value access at extreme scaleYou pay for features you do not usePartitioned key-value store

A real example, with a caveat. Discord stores messages by channel and time bucket. It shows what a wide-column store costs to run at scale, not that relational failed: Discord started on MongoDB and moved to Cassandra at roughly 100 million messages, when data and index no longer fit in RAM. Years later, at trillions of messages, they moved Cassandra to ScyllaDB: 177 nodes became 72, historical-read p99 went from 40 to 125 ms down to 15 ms, and insert p99 from 5 to 70 ms down to 5 ms. The pain was hot partitions, compaction backlogs and garbage-collection pauses. The reasons were a specific access pattern and measured operational limits, not "NoSQL is cool".

Wrong reasons, and the one-line answer to each:

:::warn Operational maturity is a requirement The team that has never run Cassandra will pay for compaction, repair and hot partitions. If the access pattern does not demand it, a managed relational or managed key-value service wins on risk. :::

The decision tree and the family table

Two gates place most workloads. Do the work out loud.

The one-page comparison. Later lessons go deep on each row.

FamilyGood forBad forTypical examples
RelationalTransactions, constraints, joins, ad-hoc queriesWrites past one primary, global active-activePostgreSQL, MySQL
DocumentRecords fetched whole, varying shapeHeavy cross-document joins; multi-record invariants are possible (multi-document transactions) but costlier and less natural than relationalMongoDB
Key-valueLookups by key at huge scale, sessions, cartsAd-hoc or relational queries beyond the key and the access paths you built in advance (sort key, secondary indexes)Redis, DynamoDB
Wide-columnHuge write rates, time-ordered data under one keyAd-hoc queries; the query is fixed at design timeCassandra, ScyllaDB, HBase
GraphMulti-hop relationship traversalBulk analytics, simple lookupsNeo4j
Time-seriesAppend-only metrics, range and downsample queriesGeneral transactionsInfluxDB, TimescaleDB
SearchFull-text relevance, facetingBeing the source of truthElasticsearch, OpenSearch
VectorNearest-neighbour similarity on embeddingsExact-match and transactional workpgvector, dedicated vector stores
Distributed SQLSQL and transactions across nodes or regionsLowest single-node latency; higher commit latencySpanner, CockroachDB

Columnar warehouses (BigQuery and similar) belong beside time-series for analytics scale. Treat the table as a map of where to look, not a verdict. Within a family, products differ a lot.

Talk trade-offs without reciting CAP

Reciting "CP or AP" at an interviewer ends the conversation rather than advancing it. Name the price instead. There are four currencies:

You always trade between them, and the trade is per kind of data, not per system. A bank balance read before a transfer pays latency to hit the primary. A like counter reads a nearby replica and tolerates a second of staleness. The same product does both.

A strong sentence has this shape: "For this data, I tolerate this staleness, and I pay for it with that." For example: "Search results can be seconds behind the catalogue; I'm paying with freshness to keep the primary free of search load." Or: "Inventory at checkout reads the source of truth; I'm paying with latency so we never oversell."

Be ready for the follow-ups:

The consistency lessons later in this topic cover the mechanics. Here the skill is saying the trade in one plain sentence.

One system of record, several derived stores

Real systems rarely use one store, and they rarely use six equals. The pattern that works is polyglot persistence with one system of record:

Never write all the stores from the application. A write to the database followed by a write to the search index is not atomic. A crash between them leaves them disagreeing, and nothing in the design guarantees a repair. Two concurrent writers can also apply updates to each store in different orders.

The safe pattern is the transactional outbox. Write the business row and an event row in the same local transaction. A change-data-capture connector, or a relay that reads the table, publishes the committed event to the derived stores. Debezium's outbox router, for example, uses an aggregate id as the message key so events for one entity keep their order within a partition. Delivery is at-least-once, so consumers must deduplicate on the event id.

:::warn The index is not the source of truth If your search index is the only copy of some field, you cannot rebuild it. Keep a rebuild path: snapshot the record, replay the change stream into a new index, swap an alias. :::

Do not add a store without a reason. "Six stores for a toy problem" is as much a miss as one store for everything. Operations, backups and failure modes of each one are on you.

Worked example: three workloads, reasoning aloud

1. Billing for a software product. "Queries are by account and invoice, with joins. Writes are a few hundred a second. Money must not be lost or counted twice, so I need real transactions and constraints. That fits one relational primary with a replica, and I'd revisit at ten times the load. The failure I design for is the primary going down: a synchronous standby (or quorum of standbys) so no committed write is lost on failover, accepting the extra commit latency and configuring it so one standby outage does not block writes, plus tested point-in-time recovery." No exotic store is justified, so none is added.

2. Sensor metrics, 200,000 points a second. "These are append-only, never updated, read as time ranges and aggregates. A single unpartitioned table with one row per point is the trap: per-row overhead and index bloat, and retention by row-by-row DELETE. Time-partitioned tables, where dropping or detaching a partition is far faster than a bulk DELETE, or TimescaleDB, fix retention; at this rate a time-series or columnar store with compression and downsampling is still the better fit. I'd put a queue in front for burst absorption and write there with retention and downsampling. Raw data expires after a window; rolled-up data stays. Ingest is at-least-once, so writes are idempotent by series and timestamp. The cost I accept is eventual freshness of a few seconds."

3. People within three hops of a user. "In SQL each hop is another join, and the work grows with each hop. If this is the core query over a huge graph, I'd use a graph store that walks edges directly. If it is one feature on a modest graph, a recursive query in Postgres is fine and avoids a second system. I need the graph size and the query rate to decide." The honest answer names both branches and the number that selects between them.

Notice what each answer does: restates the access pattern, names the default, says what would break it, picks the store, and states the cost. Where the interviewer gave no numbers, the candidate asked or assumed aloud. The chosen store changes with the answers while the questions stay the same.

Common wrong statements, and what to say instead

Wrong statementWhy it failsSay instead
"NoSQL scales, SQL doesn't."Large relational systems run sharded; Shopify shards MySQL by shop"Relational scales reads with replicas and writes by sharding; I'd leave when the primary's write load says so."
"Joins are slow."Slow when unindexed, huge and unselective, or across shards, not inherently"Selective indexed joins within a shard are cheap; I'd avoid cross-shard joins by choosing the shard key well."
"Our data is JSON, so MongoDB."JSONB exists; schema moves to code"If records are fetched whole and shapes vary, a document store fits; otherwise JSONB columns."
"Eventual consistency is faster, use it everywhere."Payments and inventory need strong guarantees"Per data: strong for money, eventual for feeds and counters."
"Elasticsearch as our main database."Derived index, no rebuild path"Relational is the record; the index is rebuilt from it."
"Cassandra, because it's web scale."Operational cost is large at small size"Only if the keyed write rate demands it and the team can run it."
"Use a different database per microservice, for flexibility."Stores multiply operations work"One default; add a store only for a measured need."
"Use a cache, so the database does not matter."Cache misses, invalidation"A cache removes read load; the database still owns correctness."

A five-beat answer you can defend:

  1. Restate the access pattern with numbers.
  2. Name the default: relational.
  3. Name the specific limit that breaks it, if any.
  4. Name the store you would use instead, and what it costs you to operate.
  5. Say what would make you revisit the choice.

If you do only one thing from this lesson: never name a database before you have said the queries and the numbers. The next lessons in this topic fill in each box of the decision tree: ACID and isolation, storage engines, each family in depth, modelling for NoSQL, distributed SQL, scaling PostgreSQL, consistency, operations and real case studies.

How to say it in the interview

"Before I name a database: the main queries are these, at roughly this rate, with this consistency need and this data size. That fits one relational primary with replicas, so that is my default. I'd leave it only if writes outgrow one primary, or the query shape is something relational does badly, and I'd tell you what that costs me to operate."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons