Deep dive 3 · 20 min

Storing Data: Choosing the Right Store

No single database is best at everything. Match each kind of data to the store whose strengths fit its access pattern, and keep one source of truth per fact.

Say it in 30 seconds
  • 6 storesin a typical shop, each owning different facts
  • 1 per factsource of truth; the rest are derived
  • 100M rowsa dashboard scan that starves checkout
Show what to say

“I start from the access pattern, not the product: how data is written, how it's read, and what must never go wrong. Anything transactional goes in Postgres. I add a specialised store, like Elasticsearch for text or ClickHouse for analytics, only when Postgres is the wrong shape, and feed it from the one source of truth through CDC or an outbox.”

Trade-offs

  • Postgres vs specialised store: Postgres gives transactions and joins but scales writes poorly; a specialised store fits one shape but is another system to run.
  • Derived stores vs dual writes: CDC or an outbox keeps search and analytics rebuildable; writing both in one request means one write eventually fails.
  • OLTP vs OLAP on one database: one database is simpler, but big scans starve checkout, so stream changes into a columnar store.

Bring it up before they ask

Never write the database and the search index in one request: one write will eventually fail and the stores drift apart.

New to the words? Read the one-minute primer

0 of 0 sections ticked
30-second answer

Start from the access pattern, not the product: how is data written and read, and what must never go wrong? Use Postgres for anything transactional. Add another store only where a number says Postgres is the wrong shape.

Why one database isn't enough

A kitchen has a knife, a pot and a fridge, because no one tool does every job.

Read more · 144 words

A normal database is great at "move money correctly" and hopeless at "store a 2 GB video". So big systems use several stores, and keeping them in step is the hard part.

For each kind of data, ask:

  • Writes: how many a second, and must they be atomic?
  • Reads: by key, by text, or summed over millions of rows?
  • Correctness: is a lost value an annoyance (a view count) or a bug (a balance)?

Do the sum first. 10 million users at 1 KB each is 10 GB. That fits in memory on one Postgres machine, which serves thousands of simple queries a second. Plain Postgres is enough for most products. It starts to strain when writes reach tens of thousands a second, or when big scans share a machine with live traffic. Add a store only when the data has a different shape.

10M users at 1 KB
10 GB
fits in RAM on one machine
100K orders a day
1.2 / s
average write rate
At a 10x peak
12 / s
a rounding error for Postgres

Pick a store, step by step

Walk the tree. come first, since they are hard to add later, and most paths end at Postgres.

Read more · 141 words

Some real jobs:

JobStoreWhy
10M profiles, 500 reads a secondPostgresSmall, needs joins and transactions
Product search with typos and filtersElasticsearchRanks results, forgives typos
200M click events a day, dashboardsClickHouseScans a few columns fast
2 GB product videosS3Blobs bloat a database and its backups

The default is Postgres first, unless a number says otherwise. These are the numbers that say otherwise. They are rules of thumb, so measure before you move.

SignalRough lineMove
Sustained writes on one primaryAbout 10K a secondShard it, or Cassandra and DynamoDB
Reports beside live trafficScans of 100M rowsA replica, then ClickHouse
Search with typos and facets1M+ documentsElasticsearch
FilesOver about 1 MBS3, with the key in Postgres
Lookups by one key100K+ reads a secondRedis in front
Which store fits this data?yesyesyesyesyesyesyesnononononononoPostgreSQL (relational)S3 + CDNRedis or DynamoDBMongoDB (document)Cassandra (wide-column)ElasticsearchClickHouse (columnar)PostgreSQL (the default)Aggregations overbillions of events?Full-text search orrelevance ranking?Write-heavy and read bykey in time order?Flexible entities thatare read as a whole?Only lookups by key, athuge scale or very lowlatency?Large files: images,video, documents?Needs relations,constraints or multi-rowtransactions?

Needs relations, constraints or multi-row transactions?

Stores compared

Every store is a trade: the second column is why you buy it and the third is what it costs you.

Read more · 1458 words
StoreStrong atWeak atHow it scales
Relational (PostgreSQL, MySQL)Transactions, constraints, joins, ad-hoc queriesHorizontal write scaleReplicas, then sharding (Citus, Vitess)
Document (MongoDB)Flexible entities read togetherCross-document transactions and joinsBuilt-in sharding
Key-value (Redis, DynamoDB)Single-key lookups at huge scaleQueries other than by keyPartition by key
Wide-column (Cassandra, ScyllaDB)Write-heavy, time-ordered partitionsAd-hoc queries, joinsPartition key + clustering
Object storage (S3, GCS)Large blobs, cheap and durableLow-latency small readsEffectively unlimited
Search (Elasticsearch, OpenSearch)Full text, relevance, facetsBeing the source of truthShards + replicas
Time-series / columnar (ClickHouse, TimescaleDB)Aggregations over time and eventsPoint updatesPartition by time
Graph (Neo4j)Multi-hop relationship queriesBulk analyticsHard to shard well

For core entities the choice is about how the data is read: with rules and joins, as a whole, or by one key.

  • Relational (the default). Tables with constraints, and changes wrapped in , so money, stock and users stay correct. Use it when the data has relationships, rules or ad-hoc questions: orders, payments, bookings, users. It is also the answer when you do not yet know the access patterns, because lets you ask a new question tomorrow. One serves thousands of simple queries a second, and add read capacity. Bad at: write scale. Every write goes to one primary, so past roughly 10K sustained writes a second or a few terabytes you or move that data elsewhere. Also a poor home for blobs, with no typo-tolerant search.
  • Document. One self-contained, JSON-like record per entity, so what is read together is stored together. Use it when each entity is read whole and its shape varies: a product catalog where a shirt has a size and a laptop has RAM. Embed children that are bounded and read together (an order and its 5 to 10 line items); reference children that grow without limit (reviews), since a document is capped at 16 MB. A catalog of 2M items at 5 KB each is 10 GB, easy for a document store or Postgres. Bad at: joins across documents. Multi-document transactions exist but cost more, so data with many cross-references wants Postgres. For an occasional flexible field, a Postgres JSONB column is enough.
  • Key-value. Answers one question, the value for this key, fast at any size. Use it when access is always by a known key, at a scale or speed a relational database cannot match. keeps data in memory (about 1 ms): sessions, carts, counters, ; 1M sessions at 1 KB is 1 GB of RAM. DynamoDB is durable at any size: design the table around the question ("latest 20 orders for a user"), and it answers in single-digit milliseconds. An item is capped at 400 KB. Bad at: any question you did not design a key for. No joins, no ad-hoc reports, and the fallback is a full Scan. Redis also costs RAM, so it is not for data you only read once a day.

Play Checkout commits, then Out of stock, to see what a transaction buys you.

For volume, time and links three stores serve shapes that strain Postgres.

  • Wide-column (Cassandra, ScyllaDB). Spreads a huge write stream across many machines by partition key and keeps each partition sorted, so appends are cheap and one entity's history is one sequential read. Use it when sustained writes outgrow one primary (about 10K a second and up) and reads are "latest N for this entity". 5 billion chat messages a day is 5,000,000,000 / 86,400, about 58K writes a second, and about 174K at a 3x peak. Partition by (chat, day), sort by time, and keep each partition well under about 100 MB. Bad at: everything ad hoc. No joins, weak , one table per query. At 600 writes a second Postgres does the same job and gives you transactions as well.
  • Time-series and columnar (ClickHouse, TimescaleDB). Each column is stored together, so a query that touches 2 of 8 columns reads about a quarter of the bytes. Use it for append-only events and questions that aggregate millions of rows: dashboards, metrics, audit analytics. 200M events a day at 100 B is 20 GB a day, about 7.3 TB a year. An hourly dashboard that needs 2 of 8 equal columns reads 5 GB instead of 20 GB, before compression. Bad at: point updates, deletes and transactions. Insert in batches of thousands of rows, never one at a time. If your volume is modest and you already run Postgres, TimescaleDB keeps time-series inside it.
  • Graph (Neo4j). Relationships are first-class links, so walking friends of friends of friends follows pointers instead of joining a table to itself three times. Use it when the query is the traversal and runs 3 or more hops deep: fraud rings, recommendations, permission inheritance. With 200 friends each, 2 hops is 40,000 paths and 4 hops is up to 1.6 billion before . Bad at: whole-graph analytics, and , because traversals cross machines. Most social features are one or two hops, and an edge table in Postgres handles those. Do not buy a graph store for a friends list.

For finding things and holding files two stores serve users directly but never hold the truth. Both hang off a row in Postgres.

  • Search (Elasticsearch, OpenSearch). An from words to documents, so it ranks results and forgives typos in a way a SQL LIKE never can. Use it when users type free text and expect relevance, typo tolerance and facet counts, usually over a million or more documents; below that, try Postgres full-text search first. 10M products at 2 KB is 20 GB, which fits in one or two shards (a common guide is 10 to 50 GB per shard). New documents appear after the next refresh, about 1 second. Bad at: being the source of truth. No transactions, and a bad mapping means a reindex. Feed it from the database with CDC or an , and treat it as a copy you can rebuild.
  • (S3, GCS). Files by key for little money, almost never lost: S3 is designed for 99.999999999% durability. Use it for blobs: images, video, backups, exports, logs. 1M photos at 3 MB is 3 TB; in Postgres that bloats every backup and the buffer , in S3 it is one key per photo and the row stores the key. Put a in front for reads. Bad at: small, low-latency reads (first byte in tens of milliseconds, not one) and in-place edits, since an object is replaced whole. You cannot query inside it, so keep the searchable fields in Postgres.

Play A typo search, then A price change.

Press Play to watch it run · 5 steps

The app changes stock and the order in one transaction on the primary. Reads that can tolerate a few milliseconds of lag go to a replica.

Relational: one transaction, many readersThe app changes stock and the order in one transaction on the primary. Reads that can tolerate a few milliseconds of lag go to a replica.writeWALreadClientAppTakes every write. One machine, so write scale is vertical.PostgresprimaryA copy fed by the write-ahead log. It adds read capacity, not write capacity.Replicareads

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Database
Open in playground
Text description
  1. A shopper buys 2 units of SKU a1 for 998.
  2. BEGIN. Decrement stock where qty is at least 2, then insert the order.
  3. COMMIT. Stock and order land together, or neither does.
  4. The log streams to the replica, usually within milliseconds.
  5. Order confirmed.
Bytes read for one day of events (assumed 100 B rows, 8 equal columns)
05101520GB20 GBRow store5 GBColumnar, 2 columns
Show data table
Bytes read for one day of events (assumed 100 B rows, 8 equal columns)
GB
Row store20 GB
Columnar, 2 columns5 GB

Press Play to watch it run · 5 steps

Queries go to Elasticsearch for ranking, then the app loads full rows from Postgres. Changes reach the index through an indexer, about a second late.

Search: a derived index with a delayQueries go to Elasticsearch for ranking, then the app loads full rows from Postgres. Changes reach the index through an indexer, about a second late.searchloadCDCupsertClientAppElasticsearchinverted indexPostgressource of truthIndexerCDC consumer

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Search index
  • Database
  • Worker
Open in playground
Text description
  1. A shopper types runnin shoos.
  2. Words are split, matched with typo tolerance and scored by relevance.
  3. Ranked ids come back, with facet counts (brand, size) in the same call.
  4. The app loads the live price and stock for those ids from Postgres, so the page never shows a stale price.
  5. Results rendered.

Quick checkWhere do uploaded photo bytes go?

A real product, many stores

Take a food-delivery app and ask the tree's questions of each fact, one at a time, and the architecture falls out.

Read more · 217 words

Assume 200K orders a day, 100K restaurants and 50K drivers online.

FactDeciding questionStore
Orders and paymentsTransactions? Yes. 200K a day is 2.3 a secondPostgres
MenusRead whole, varying shape?Postgres JSONB, or MongoDB
Driver location nowKey lookup, 50K pings / 4 s = 12.5K writes/sRedis
Driver location historyAggregated later?ClickHouse
Dish search with typosFull text and facets?Elasticsearch
Restaurant photosA file over 1 MB?S3 and a CDN

Notice what stayed in Postgres: orders, and probably menus. The tree sends only the shapes that break Postgres elsewhere. Now check any single fact against the numbers.

A typical shop runs six stores, each owning different facts. Step through a page view, a price change and a checkout.

Postgres, MongoDB, and S3 are written directly. Elasticsearch and ClickHouse are derived from the change stream. Lose either and you rebuild it from the source. At an assumed 20 MB a second of bulk indexing, rebuilding 20 GB of search index takes 20,000 / 20 = 1,000 seconds, about 17 minutes. That is the price of a copy, and the reason it is safe to have one.

Is Postgres still enough?yesyesyesyesnonononoShard, or a write-firststoreReplica, then ClickHouseAdd ElasticsearchAdd S3, key in PostgresStay on PostgresFiles over about 1 MB?Typo-tolerant search orfacets over 1M+documents?Scans over 100M rowsnext to live traffic?More than about 10,000sustained writes asecond?

More than about 10,000 sustained writes a second?

Press Play to watch it run · 9 steps

Postgres owns orders, payments, prices and stock; MongoDB the catalog; Redis sessions and carts; S3 behind a CDN the images. CDC streams changes through Kafka into Elasticsearch for search and ClickHouse for analytics.

Polyglot e-commerce: where each fact livesPostgres owns orders, payments, prices and stock; MongoDB the catalog; Redis sessions and carts; S3 behind a CDN the images. CDC streams changes through Kafka into Elasticsearch for search and ClickHouse for analytics.imageson missWALindexersinkClientAppAPIRedissessions, cartsSource of truth for money: orders, payments, prices and stock.Postgresorders, pricesProduct descriptions and attributes, which vary by category.MongoDBcatalogCDNimagesS3originalsTails the Postgres WAL and MongoDB change streams and publishes every committed change.CDC (Debezium)reads the logsKafkachange eventsElasticsearchproduct searchClickHouseanalytics

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Cache
  • Database
  • CDN
  • Object storage
  • Worker
  • Queue
  • Search index
Open in playground
Text description
  1. A shopper opens the page for SKU a1.
  2. The session and cart come from Redis in about a millisecond.
  3. Session found.
  4. The product document (title, description, attributes) comes from MongoDB in one read.
  5. Price and stock come from Postgres, the source of truth for anything that touches money.
  6. The page comes back with image URLs pointing at the CDN.
  7. The browser loads the images from the nearest CDN edge.
  8. On a CDN miss, the edge fetches the original from S3 once and caches it for everyone after.
  9. Image delivered. Your servers never handled a single byte of it.

Quick check200K orders a day is about 2.3 writes a second. Store?

What goes wrong, and the principles

Storage mistakes are quiet: they pass the demo and fail in month six, usually at the worst moment.

Read more · 385 words
MistakeWhat happensFix
One store for everythingAt 9:00 on a Monday an analyst scans 100M rows in the same Postgres that takes payments, and checkout times outStream changes to a store built for scans (a replica for a small report, ClickHouse for the real thing), so reports and checkout stop sharing disk and CPU
Dual writes that driftThe order commits, the search index call times out, and the order never appears in searchWrite one source of truth and derive the rest through CDC or an outbox. A relay retries until the index catches up; a full reindex is the repair of last resort
Picking by hypeA team picks DynamoDB "to scale", then the first report is revenue by categoryName the access patterns first, then the store. If you cannot name them yet, that is the argument for Postgres, which answers questions you have not thought of
The hot partitionA partition key that is the same for most writes sends them all to one partition: with the date as the key, 5,000 writes a second hit one partition that takes about 1,000Pick a key that varies on every request (user id, chat id plus day). For a single hot counter, split it into N keys and add them on read. The code section shows both

Play Dual write, then , and Date as the key, then User id as the key.

  1. Model by access pattern first

    List the queries before the tables. "Latest orders for a user" and "revenue per day" want different stores.

  2. One source of truth per fact

    Write each fact to one store. Search, analytics and are copies you can rebuild.

  3. OLTP and OLAP don't share a database

    Stream changes into a separate store for analytics, so a report never starves checkout.

  4. Boring by default

    Every store is one more thing to back up and wake up for. Add a store only when an access pattern demands it.

Press Play to watch it run · 4 steps

Writing the database and the search index in one request lets them drift apart. An outbox row in the same transaction lets a relay retry until the index catches up.

Dual write vs outboxWriting the database and the search index in one request lets them drift apart. An outbox row in the same transaction lets a relay retry until the index catches up.commitalso indexoutboxupsertAppPostgresorders + outboxRelayretriesElasticsearch

Tap any box to see what it is and what it talks to.

  • Service
  • Database
  • Worker
  • Search index
Open in playground
Text description
  1. The app inserts order 9001 and commits.
  2. Committed. The order exists.
  3. Then it calls Elasticsearch to index the order. The call times out.
  4. Nothing retries and nobody is told. The order exists but search will never show it.

Press Play to watch it run · 3 steps

A partition key that is the same for everyone sends every write to one partition. A key that varies spreads the load.

Hot partition: the wrong keyA partition key that is the same for everyone sends every write to one partition. A key that varies spreads the load.Clients5,000 writes/sAppPartition 1Partition 2Partition 3

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Database
Open in playground
Text description
  1. The table's partition key is today's date, so every order written today shares one key.
  2. All 5,000 writes a second land on one partition. A partition accepts about 1,000 writes a second, so most are throttled.
  3. Errors and retries, while partitions 2 and 3 sit idle.

Quick checkSearch index call fails after the order commits. What prevents drift?

Code

Relational (PostgreSQL)

place-order.js
// Relational (PostgreSQL): stock and the order change together or not at all.
// Decrement stock first; if no row changed, the item is out of stock, so roll back instead of creating an order
export class OutOfStockError extends Error {
  constructor(sku) { super(`out of stock: ${sku}`); this.name = 'OutOfStockError'; this.sku = sku; }
}

export async function placeOrder(pool, { userId, sku, qty, total }) {
  const c = await pool.connect();
  try {
    await c.query('BEGIN');
    const { rowCount } = await c.query('UPDATE stock SET qty = qty - $2 WHERE sku = $1 AND qty >= $2', [sku, qty]);
    if (rowCount === 0) throw new OutOfStockError(sku);
    await c.query('INSERT INTO orders (user_id, sku, qty, total) VALUES ($1, $2, $3, $4)', [userId, sku, qty, total]);
    await c.query('COMMIT');
  } catch (e) {
    await c.query('ROLLBACK');
    throw e;
  } finally {
    c.release();
  }
}

Document (MongoDB)

document.js
// Embed bounded children that are read together: an order and its line items are one document
await db.collection('orders').insertOne({
  userId, status: 'paid',
  items: [{ sku: 'a1', qty: 2, price: 499 }],   // embedded line items
  total: 998,
});

// Reference unbounded children: a product can collect millions of reviews, far past the 16 MB document limit
await db.collection('reviews').insertOne({ productId, userId, stars: 5, text, createdAt: new Date() });
await db.collection('reviews').createIndex({ productId: 1, createdAt: -1 });   // "latest reviews for a product"

Key-value (Redis, DynamoDB)

// Key-value: one key, one value, about a millisecond. No joins, so the key IS the query.
await redis.set('session:' + id, JSON.stringify(sess), 'EX', 1800);   // expires after 30 idle minutes
await redis.incr('views:' + postId);                                   // atomic: no read-modify-write race

Wide-column (Cassandra)

-- Wide-column (Cassandra CQL): partition by (sensor, day) so no partition grows without bound;
-- rows inside a partition are sorted by time
CREATE TABLE readings (
  sensor_id text, day date, ts timestamp, value double,
  PRIMARY KEY ((sensor_id, day), ts)
) WITH CLUSTERING ORDER BY (ts DESC);

Partition keys that stay even

partition-keys.js
// Keys that keep a partition small and a write load even. Pure functions: easy to test, safe to copy.

// Wide-column: one partition per chat per day, so no partition grows without bound.
export function dayBucket(chatId, date) {
  return `${chatId}#${date.toISOString().slice(0, 10)}`;
}

// Key-value: one very hot key becomes n keys. Writes pick one at random...
export function spreadKey(base, n, rnd = Math.random) {
  if (!Number.isInteger(n) || n < 1) throw new RangeError('n must be a positive integer');
  return `${base}#${Math.floor(rnd() * n)}`;
}

// ...and a read asks all n and adds the answers up. That is the price of spreading.
export function readKeys(base, n) {
  if (!Number.isInteger(n) || n < 1) throw new RangeError('n must be a positive integer');
  return Array.from({ length: n }, (_, i) => `${base}#${i}`);
}

Object storage (S3)

// Object storage (S3): the server only signs a short-lived URL; the file itself never passes through it
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({});
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp']);

app.post('/api/uploads', async (req, res) => {
  if (!ALLOWED.has(req.body.type)) return res.status(415).end();
  const key = `u/${req.user.id}/${crypto.randomUUID()}`;            // the server picks the key, never the client
  const url = await getSignedUrl(s3,
    new PutObjectCommand({ Bucket: process.env.BUCKET, Key: key, ContentType: req.body.type }),
    { expiresIn: 300 });                                               // valid for 5 minutes
  res.json({ url, key });                                              // the client PUTs the file to url
});

Search and time-series

search-events.js
// Producer: publish the change after the database commit (better still, the outbox or CDC, so it can't be lost)
export async function onProductSaved({ id, name, price, version }) {
  await queue.publish('product.updated', { id, name, price, version });
}

// Indexer: consumes the topic and upserts the search document. Replays and out-of-order events are safe:
// with external versioning, Elasticsearch rejects any version not newer than the one it already has.
export async function indexProduct({ id, name, price, version }) {
  try {
    await es.index({ index: 'products', id, document: { name, price }, version, version_type: 'external' });
  } catch (err) {
    if (err.meta?.statusCode !== 409) throw err;    // 409 = already have this or a newer version: skip
  }
}
-- ClickHouse: append-only events, partitioned by month, sorted for the common filter
CREATE TABLE events (
  ts DateTime, user_id UInt64, type LowCardinality(String), sku String, price Decimal(10, 2)
) ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (type, ts);

-- Orders per hour over the last day: scans two columns, not whole rows
SELECT toStartOfHour(ts) AS hour, count() AS orders
FROM events
WHERE type = 'order_placed' AND ts > now() - INTERVAL 1 DAY
GROUP BY hour ORDER BY hour;

The chooser, as code

Read more · 56 words
pick-store.js
// Walk the chooser with numbers. These thresholds are rules of thumb, not laws: measure your own workload.
export const LIMITS = {
  writesPerSec: 10_000,        // sustained writes one Postgres primary handles comfortably
  scanRows: 100_000_000,       // scans this big should not share a machine with checkout
  searchDocs: 1_000_000,       // below this, Postgres full-text search is often enough
  fileBytes: 1_000_000,        // files above about 1 MB belong in object storage
};

export function pickStore(w) {
  if (w.fileBytes >= LIMITS.fileBytes) return { store: 'object storage', why: 'blobs bloat a database and its backups' };
  if (w.transactions) {
    return w.writesPerSec > LIMITS.writesPerSec
      ? { store: 'sharded postgres', why: 'transactions needed, but one primary is too small' }
      : { store: 'postgres', why: 'transactions, constraints and joins' };
  }
  if (w.textSearch) {
    return w.docs >= LIMITS.searchDocs || w.typos
      ? { store: 'search engine', why: 'relevance, typo tolerance and facets' }
      : { store: 'postgres', why: 'full-text search is enough at this size' };
  }
  if (w.scanRows >= LIMITS.scanRows) return { store: 'columnar', why: 'big scans stay off the transactional database' };
  if (w.writesPerSec > LIMITS.writesPerSec) {
    return w.timeOrdered
      ? { store: 'wide-column', why: 'huge append rate, read by entity in time order' }
      : { store: 'key-value', why: 'huge rate, access always by key' };
  }
  return { store: 'postgres', why: 'nothing here crosses a line' };
}

Choosing a data store in the interview

Bring storage up when you reach the data model, and let each store earn its place with a number.

Read more · 363 words

Walk five decisions in this order.

  1. Write down the access patterns

    For each entity, say writes per second, how it is read (by key, by text, summed) and what must never be wrong.

  2. Start with Postgres and say why

    "10 GB fits one machine and 12 orders a second is nothing." This shows you do not add stores for decoration.

  3. Name the number that breaks it

    Tens of thousands of writes a second, 100M-row scans, typo-tolerant search or large files. Each one names the second store.

  4. Keep one source of truth

    Everything else is derived through CDC or an and can be rebuilt. Never write two stores in one request.

  5. Own the cost of each store

    Say what each extra store adds: another backup, a sync lag of about a second, and a rebuild plan.

If the interviewer saysYou raise
"Billions of chat messages a day"Cassandra, partitioned by chat and day, sorted by time
"Users upload photos and video"S3 with a presigned URL and a CDN; the key in Postgres
"Search by keyword, with typos"Elasticsearch fed by CDC
"A dashboard over events"ClickHouse, never the transactional database
"Carts and sessions"Redis, with a TTL
"Friends of friends of friends"Postgres to 2 hops, a graph store beyond

Quick checkWhat do you say before adding a second store?

Interview questions

Where do chat messages live?

Wide-column (Cassandra), partitioned by conversation and sorted by sequence.

Why not store images in Postgres?

Blobs bloat the database and its backups. Use , with the key in Postgres.

How do you keep Elasticsearch in sync?

Change events feed an indexer, and a full reindex rebuilds it from the source of truth.

SQL or NoSQL?

Let the access patterns decide. or ad-hoc queries mean . Known-key access at huge scale means .

When would you pick DynamoDB over Postgres?

When access is few, known and key-based at huge scale (carts, sessions). Not for joins or ad-hoc reports.

600 writes a second of messages: Cassandra or Postgres?

Postgres. That rate is far below its limit, and you keep transactions and ad-hoc queries. Cassandra pays off from tens of thousands of writes a second.

A DynamoDB table is throttled but mostly idle. Why?

A : the partition key sends most writes to one key. Pick a key that varies, or split one into N.

Check yourself

Question 1 of 5

Score 2 out of 3 or better to mark this page complete.

1. Which store for a like counter that's read constantly?

Next: Next: how data reaches the client in real time.