Cruxly

Cruxly › System design › Foundations of Data Systems

Choosing a Datastore

access pattern first, product second

Open the interactive lesson

The question is never "SQL or NoSQL". It is: what are my queries, what consistency does each class of data need, and what shape is the write path. Answer those and the product is nearly determined. This is the short version: the full treatment, with worked case studies, is the lesson "How to choose a database" in the topic Databases: Choosing One.

Start from the queries

Write down every query the system performs and how often. Usually there are three or four that matter:

Now the decisions make themselves. The first two are point lookups and appends on a single partition key — any partitioned key-value or wide-column store does that at this rate, and a relational primary would need sharding to keep up. The third is a small sorted list per user, ideally maintained on write rather than computed. The fourth is a different problem entirely — it needs an inverted index, so it is a separate system fed asynchronously, and a hundred per second means it can be seconds stale.

The conclusion writes itself: one system does not serve all four, and that is fine. Saying so is the mark of someone who has actually shipped something.

What relational actually gives you

Candidates dismiss relational databases too early. Say what they buy before you move on:

The limits are real: a single primary caps write throughput, joins across shards do not work, and schema changes on very large tables need care. But a modern single primary handles roughly 10-20k durable writes per second and tens of terabytes, which covers the great majority of systems. "This is 3,000 writes/s — one Postgres primary with read replicas, and I'd revisit at 10×" is frequently the correct and highest-scoring answer.

The non-relational families

FamilyQuery shapeReach for it whenThe trap
Key-valueGet/put by key onlySessions, caches, feature flags, precomputed blobsNo query beyond the key — that's the whole deal
Wide-columnPartition key + sorted clustered columnsTime-ordered data under an entity: messages in a conversation, events per deviceQuery pattern is fixed at schema-design time
DocumentQuery by field, flexible schemaRecords vary in shape and are fetched wholeFlexible schema means the schema now lives in your app code
GraphTraversals of arbitrary depthYour query is genuinely a recursive joinA specialised store you now have to operate
Search indexInverted index, relevance, facetingText searchNever the source of truth
Time seriesAppend writes, range reads, aggregationMetrics with downsampling and retentionDon't build this on a relational table
Object storageBlob by referenceAnything above a few hundred KBStore the reference in the row, never the blob

:::warn The document-store trap, stated precisely "Flexible schema" doesn't remove the schema — it relocates it into your application code, where nothing enforces it. Old documents keep their old shape forever, so every reader must handle every historical shape you have ever written. That cost arrives later, which is exactly why it gets underweighted at design time. :::

Wide-column is the usual right answer for chat and telemetry — time-ordered data under an entity key is precisely the shape it's built for.

Naming a real product per family is worth doing unprompted — it signals you've actually operated one of these, not just read the category name:

:::key The historical tradeoff — and why it's no longer a clean split The classic framing: relational databases give rich queries and ACID transactions on one node; NoSQL stores scale out by giving some of that up.

Classic relationalClassic NoSQLDistributed SQL (NewSQL)
SchemaFixed, enforced by the databaseFlexible, enforced by application codeFixed, enforced
QueryingRich, ad-hoc, joinsMostly the access pattern the store was built forRich SQL, joins
Scale-outVertical; horizontal means manual shardingHorizontal by designHorizontal by design
TransactionsACIDVaries — often per-key or per-row onlyACID across nodes

The lines have blurred, so state the specifics instead of the stereotype:

:::

Polyglot persistence, and its cost

Using several stores is normal and correct. The thing to get right is which one is authoritative.

Pick one source of truth per piece of data. Everything else is a derived view — fed asynchronously, and rebuildable. For a chat system, messages live in the wide-column store; the search index and the analytics warehouse are both derived from the same change stream. If the search index is corrupted you rebuild it from the log. You never reconcile it by hand.

The cost is the sync, and this is where interviewers push:

DUAL WRITE — a bug app 1. write database ✓ ✕ crash 2. never happens search index ✗ permanently inconsistent OUTBOX — one transaction app ONE TRANSACTION row outbox relay publishes search index ✓ A crash can't split a transaction — the outbox row is either there with the data, or neither is. The relay just retries until it publishes. Nothing is ever permanently out of sync.
Dual writes fail because two writes can't be made atomic across two systems. The outbox makes them one write, then moves the risky part to something that can safely retry.

:::warn Dual writes are a bug, not a shortcut Writing to the database and then the search index in application code means a crash between them leaves the two permanently inconsistent — and no amount of retry logic fixes the general case, because the crash can land after the retry logic itself is gone. :::

:::key Naming the outbox pattern unprompted is a strong senior signal Write once to the source of truth and derive everything else from its change stream — change data capture, or the outbox pattern: write the row and an outbox record in one transaction, and a separate relay publishes from the outbox.

Then state the lag: "the search index is 1–2 seconds behind, so a message is searchable almost immediately but not atomically — which is fine, because nobody searches for a message they just sent." :::

Schema and access shape

Normalise for writes, denormalise for reads. Normalised means one place to update and joins on read; denormalised means fast reads and every update touching many rows. Choose by ratio — at 1000:1 reads, denormalise without hesitation; at 1:1, normalise.

Precompute on write when the read is hot. A user's conversation list ordered by recency should be a maintained row updated on each message, not an aggregate computed per read. You are moving work from the frequent path to the rare one.

:::key The primary key is the performance decision

PRIMARY KEY (message_id)
→ "last 50 messages in a conversation" becomes a full scan
PRIMARY KEY (conversation_id, timestamp DESC)
→ the same query is one sequential partition read

Same data. Same store. Two orders of magnitude, decided entirely by which query you designed the key around. :::

@@ 1 | Never run a blocking ALTER on a large hot table Adding a column to a billion-row table is a four-step online process, not a statement:

add nullable → dual-write → backfill in batches → make it required.

Saying you'd sequence it this way is a small detail that reads as real experience, because it's the kind of thing you only learn by having taken a table down once.

How to say it in the interview

"Let me list the three queries this system actually runs, and their consistency needs — I'd rather pick the store from the access pattern than start from a product and justify it backwards."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons