R10 — Storing data: choosing stores, replication, partitioning and sharding

Every mechanism so far has assumed a database underneath it that keeps its promises. This section is about that database: which kind to pick, how copies of it stay in step, and how data is split once one machine is no longer enough. R11 picks up what to do when the store you chose becomes the bottleneck.

The story: the ledger and the read model want different things

A customer sends a payment. Two pieces of data change.

  1. The ledger posts a debit and a credit. Two entries that must both exist or neither, against a balance that must never go below what the rules allow.
  2. The customer's activity feed gets a new line. "You paid Asha ₹1,200." A denormalised row in a read model, fed by the event from R4.

Now imagine the network between two data centres drops for thirty seconds.

For the ledger, the right behaviour is to stop. A ledger that keeps accepting debits on both sides of a split can approve two payments against the same balance, and nothing afterwards can make that money exist. Refusing a request is an inconvenience. Posting a wrong entry is an incident.

For the feed, the right behaviour is to keep serving. Showing a feed that is thirty seconds behind costs nothing. Showing an error page costs a support call.

Same payment, same moment, opposite answers. That is the whole of this section in one example: the store is chosen per kind of data, not per company, and the question that decides it is what that data must do when something breaks.

Choosing a store

SQL and NoSQL: what each one is actually good at

The labels are blunt, but the trade-off underneath them is real.

A relational database stores structured rows in tables, enforces a schema, gives you joins and ad-hoc queries, and wraps work in ACID transactions (R0). It scales up easily and out with effort — sharding a relational store is a project, not a setting.

A NoSQL store gives up some of that in exchange for horizontal scale, throughput and availability. Schemas are flexible or absent, access patterns are simple and must be known in advance — each store is built to answer a particular kind of question fast — and the default consistency is eventual rather than transactional — often described as BASE, the deliberate opposite of ACID, which the next subsection unpacks.

Relational (SQL) NoSQL
Data shape Structured, relational Semi-structured or unstructured, sparse, hierarchical
Queries Complex joins, ad-hoc Simple, known access patterns
Scaling Vertical first; sharding is hard work Horizontal by design
Consistency ACID transactions Eventual (BASE), often tunable
Typical home Banking, insurance, OLTP, anything with invariants Feeds, sessions, telemetry, catalogues, huge volume
Interview default Most use cases When distribution and partition tolerance are the point

They are used together far more often than instead of each other. A social product keeps users in a relational store and the social graph and posts in NoSQL. A payments platform keeps the ledger relational and puts sessions, idempotency caches and feeds wherever they are cheapest.

The trap to name: "NoSQL scales, SQL doesn't." Relational databases scale a very long way vertically, and R13 shows how far a single well-tuned primary goes before anything else is needed. What NoSQL buys is not scale in general — it is scale without the transaction boundary. If your data needs that boundary, you are buying the wrong thing.

BASE: what a NoSQL store promises instead of ACID

ACID is a precise promise about one transaction: it happens completely or not at all, it leaves the rules intact, it is not disturbed by other transactions, and once committed it stays committed. BASE is a looser promise about a replicated store: it keeps answering even when its copies disagree, and it lets them agree later. The acronym was chosen to be the chemical opposite of ACID, and the fit is a little forced. Learn the three ideas behind it rather than the letters.

To make them concrete, take one example and keep it for the rest of this subsection. A shopping cart lives in a store that keeps three copies of every key, on three machines called A, B and C. Each copy is a replica. To keep writes fast, the store tells the client "saved" as soon as one replica has the write, and copies it to the other two in the background. (The replication chapter below explains these choices properly. One idea is enough for now: the copies are not updated at the same instant.)

Letter Stands for What it means in plain words What happens to the cart
BA Basically available The store answers almost every request, even when some replicas are down or cut off. The answer may be out of date, but there is an answer. B has crashed. You add a book; A stores it and replies "saved". Nobody waits for B.
S Soft state The copies are allowed to differ for a while, and what you read can change without anyone writing, as the copies catch up in the background. No single copy is the truth at a given instant. A has [book]; C still has []. A read served by C shows an empty cart. Read again a moment later and the book is there, although nobody touched the cart.
E Eventually consistent If writes stop, all the copies end up with the same value. The store promises that they will get there, not when. B comes back and receives the write it missed; C is brought up to date. All three now say [book].

"Soft state" is the letter people get wrong. It does not mean the data is kept only in memory, and it does not mean data is lost. It means the state is not settled yet: background processes are still moving it towards agreement, so two reads a second apart can disagree with no write in between. ACID's state is "hard" in comparison. Once a commit returns, every later read sees it until someone changes it.

Step through the cart below. Watch three things: the write accepted while a replica is down, the read that sees an empty cart, and the background repairs that bring all three copies to the same answer. The last tab shows what happens when two people change the cart at the same moment on opposite sides of a network failure.

ACID and BASE, question by question

Question ACID (a relational ledger) BASE (Cassandra, or DynamoDB with its defaults)
When is a write "done"? When it is committed and durable, with every constraint checked When enough replicas have stored it — often just one
What does a read see straight after a write? The committed value Possibly the old value, from a replica that has not caught up
Several rows changed together? All or none Usually one key at a time; a change across keys can be seen half done
Two clients write the same key at once One waits for the other, through locks or a version check Both are accepted; the store or the application resolves it later
A replica or a network link fails Writes may wait or be refused (the CP choice, below) Writes carry on; the copies converge later (the AP choice)
Who keeps a rule such as "no negative balance"? The database Your application — or nobody
What you pay Coordination on every write: more latency, and refusals during a partition Stale reads, conflicting writes, and repair work running all the time

How "eventually" actually happens

Convergence is not magic. Three background mechanisms do the work, and a BASE store runs all of them:

R6 covers read repair and anti-entropy in detail, because they are reconciliation inside the database.

Repair handles copies that are merely behind. Concurrent writes are harder: two clients change the same key on different replicas before either change has spread. Something has to decide the result:

Approach How it works What it costs
Last write wins Keep the value with the latest timestamp; throw the other away Simple, but it silently drops a write, and clock skew between machines decides which one
Keep both and let the application merge The store returns both versions ("siblings"); the next writer combines them The application must know how to merge. Amazon's original Dynamo merged carts by union, so an add was never lost, though a removed item could reappear
Merge-friendly data types (CRDTs) Data structures built so that any two copies merge to the same result: a set that grows by union, a counter kept per replica and summed Only some shapes fit, and removals need extra bookkeeping

"Eventually" has no deadline.

On a healthy cluster, the copies usually agree within milliseconds. But the promise has no time limit. Cassandra keeps hints for a replica for three hours by default. A replica that is down longer than that misses those writes until anti-entropy repair runs. A deleted row is kept as a marker (a tombstone) for ten days by default, and a replica that misses the delete and is not repaired within that window can bring the deleted data back. Running repair on a schedule is part of operating a BASE store, not an optional extra.

BASE is a dial, not a label on the box

Most BASE stores let you buy stronger consistency for a single request when you need it. In Cassandra you choose a consistency level per query, and QUORUM reads and writes overlap so that a read sees the latest acknowledged write. In DynamoDB, a read on the table can ask for ConsistentRead, and multi-item transactions are available at a higher cost. The quorum arithmetic below shows exactly when that overlap holds.

It works the other way too. A relational database read through an asynchronous replica behaves like a BASE store for that read: the replica can be behind, and R11's stale-read problem is exactly that. So BASE describes how a particular read or write behaves, not which product you bought.

What BASE hands to your application

BASE gives up the promise of a correct answer now in exchange for an answer now. Choose it where a slightly old answer harms nobody.

In payments, that line falls where you would expect: a feed, a notification history or a product catalogue can be BASE; a balance, a hold or an idempotency record cannot. The CAP section below draws the line precisely.

The four NoSQL families

"NoSQL" is not one thing. Name the family, because each is built for a different kind of access pattern — and, as the next subsection shows, most of them expect you to know those patterns before you design anything.

Family Model Examples Good for
Key-value Opaque value behind a key; get and put Redis, DynamoDB Sessions, caches, idempotency keys, carts, anything read by exact key
Document JSON-like documents with nested fields, queryable MongoDB, CouchDB Records whose shape varies or nests — a product catalogue, a customer profile
Wide-column Rows grouped into partitions by key, sorted inside each partition; each row has its own set of columns Cassandra, ScyllaDB, HBase, Bigtable Very high write volume, time series, per-key history read in order
Graph Nodes and edges as first-class citizens Neo4j, Neptune Relationship-heavy queries — social graphs, recommendations, fraud rings

Columnar stores — Redshift, BigQuery, ClickHouse — are a different thing despite the similar name: they store each column separately for analytics scans, and the wide-column deep dive below sets the two side by side.

Two details worth having ready. Graph databases can be ACID-capable, so "NoSQL" does not automatically mean "no transactions." And a document store's format is JSON or something like it, which is what makes it attractive when the schema genuinely is not fixed — and dangerous when it is fixed and nobody wrote it down.

Access patterns come first: designing a NoSQL store backwards

An access pattern is one specific question the application asks of its data, with its inputs and the order it wants the answer in. "Fetch payment P123." "List customer C42's payments, newest first, 20 at a time." "Total a merchant's payments for one day." Writing these down is the first step in choosing and designing any store. For most NoSQL stores, it is the step the whole design depends on.

A relational database lets you model the data first and ask questions later. You describe the entities — customers, payments, merchants — normalise them into tables, and write whatever query you need, today or next year. The query planner combines joins and indexes to answer questions nobody planned for. It may be slow until you add an index, but it can answer.

A key-value or wide-column store works the other way round. You list every question first, then shape the data so that each question is answered by one lookup on a key, or one range read inside a partition. The reason is how these stores find data. Rows are spread across machines by their key. There are no joins. A query that does not name the key has no machine to go to. The store must then read every partition, refuse, or make you opt in explicitly — Cassandra's ALLOW FILTERING exists to make you feel that cost.

Take the three payment questions above in Cassandra. Each one gets its own table, laid out for exactly that question:

Question Table Partition key (which machine) Clustering order (sorted within it)
Fetch one payment by id payments_by_id payment_id —
A customer's payments, newest first payments_by_customer customer_id created_at descending
A merchant's payments for one day payments_by_merchant_day (merchant_id, day) created_at

The same payment is written three times. That duplication — denormalisation — is the price of making every read a single fetch. In DynamoDB the same idea appears as a partition key and sort key chosen for the main question, plus secondary indexes for the others.

Now add a question nobody listed: "every failed payment over ₹1 lakh last week, across all customers." No table is keyed that way. The fix is a new table or index, plus a backfill of the old data, or sending the events to a search engine or warehouse that handles ad-hoc questions. In Postgres it would have been a WHERE clause and perhaps an index.

In a relational store you model the data and ask anything; in a key-value or wide-column store you model the questions, and the data follows.

Each family suits a different kind of question

How strictly the questions must be known in advance depends on how much the store relies on the key to find data:

Family Must the questions be known up front? Answers well Answers badly
Key-value (Redis, DynamoDB) Yes, strictly. A lookup needs the exact key; DynamoDB adds sort-key ranges and the secondary indexes you define Get and put by key; a range of sort keys within one partition Anything by an attribute that is not part of a key
Wide-column (Cassandra, HBase) Yes. Usually one table per question, built from its partition key and sort order Very high write rates; reads of one partition, in its stored order Filters on other columns, reads across partitions, joins, aggregations
Document (MongoDB) Partly. Shape each document around how it is read, but secondary indexes and the aggregation pipeline can answer new questions Fetching a whole record — a profile, an order with its lines — in one read; queries on indexed fields Joins across collections, and transactions across many documents, which work but cost more
Graph (Neo4j, Neptune) Less so for traversals. Cypher and Gremlin queries can walk the graph in ways nobody planned Multi-hop questions: friends of friends, accounts linked to a known fraud ring Bulk scans and aggregations over every node

So the rule is a gradient, not a switch. Key-value and wide-column stores want the questions fixed before the first table exists. Document stores are more forgiving. Graph stores are built for questions you discover later, as long as they are questions about relationships.

The trap: choosing a NoSQL store before listing the access patterns.

"Schemaless" does not mean "no design". It moves the design from the data to the queries, and it makes changing your mind expensive. A team that picks a wide-column store for flexibility, then discovers a reporting requirement, ends up running a second store to answer it.

How to handle access patterns in a design round

Wide-column stores in depth, with Cassandra as the reference

Wide-column is not columnar

The names are close and the ideas are opposite, so settle this first.

Wide-column Columnar
Examples Cassandra, ScyllaDB, HBase, Bigtable Redshift, BigQuery, Snowflake, ClickHouse; Parquet files
How data sits on disk A row's values together, rows grouped into partitions by key and sorted inside each partition Each column on its own, compressed, across all rows
Built for Very high write rates; reading one partition, or a slice of it, by key Scanning a few columns over billions of rows: sums, counts, group-bys
Typical job The live store behind an application: messages, events, time series Analytics and reporting, fed from the live stores

"Wide" refers to the partition, which can hold a very large number of rows, each with its own set of columns. Cassandra is the wide-column store this subsection uses.

How Cassandra stores and finds data

Every table has a primary key with two parts, and each part has one job.

Together they give the one query shape Cassandra is built for: go to one partition, then read a contiguous slice of it in sorted order.

Writes are cheap by design. A write is appended to a commit log for durability and added to an in-memory table (the memtable). Full memtables are flushed to disk as immutable files called SSTables, and a background process called compaction merges them. There is no read before a write: an INSERT of an existing key simply overwrites it (an upsert), and a delete writes a marker called a tombstone. This structure is a log-structured merge tree. It is why Cassandra absorbs very high write rates, and why every SSTable carries a Bloom filter (end of this section) to keep reads fast.

Reads go to the partition's replicas, merge the memtable and the relevant SSTables, and keep the newest value of each cell by timestamp. You choose per query how many replicas must answer, using the consistency levels from the quorum arithmetic below.

What CQL lets you ask follows directly from that layout:

You can You cannot, without paying for it
Filter on the full partition key, with equality Filter on a column that is not part of the key, except with ALLOW FILTERING (a scan) or a secondary index (slow, used carefully)
Then filter or take a range on clustering columns, in their declared order Skip a clustering column and filter on the next one
Sort by the clustering order, or its exact reverse Sort by anything else
Use LIMIT and page through the result Join tables, or aggregate across partitions cheaply

A worked example: message history in a chat app

Chat history is the classic Cassandra workload. Discord, for example, has described storing its messages in Cassandra with a design very close to the one below. Start from the access patterns, as the previous subsection insists:

# Question How often
Q1 Show the latest 50 messages in a channel Every time anyone opens a channel — the hottest read
Q2 Scroll back: the 50 messages before message X Constantly, as people scroll
Q3 Post a message to a channel Very high, and spiky
Q4 Edit or delete one message Occasional

Why Cassandra fits: the writes are relentless appends, every read is "one channel, in time order", the data grows forever, and a message appearing a moment late on one device harms nobody. This is AP data (the CAP section below).

Things the app will also want, but not from this table: searching message text, and counting a channel's messages. The first goes to a search engine fed by events; the second to a counter kept separately.

First attempt: one partition per channel.

CREATE TABLE messages (
  channel_id  bigint,
  message_id  bigint,    -- Snowflake-style id: unique and ordered by time (R0)
  author_id   bigint,
  content     text,
  PRIMARY KEY ((channel_id), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

The double parentheses mark the partition key; message_id is the clustering column, stored newest first. Q1 is now WHERE channel_id = ? LIMIT 50, and Q2 adds AND message_id < ?. Both are a single slice of a single partition.

The flaw: a busy channel's partition grows without limit.

Every message ever posted to #general lands in one partition, on the same few replicas. After a few years it holds millions of rows and many gigabytes. Those replicas become hot spots, and compaction, repair and node replacement all slow down around it. A common rule of thumb is to keep a partition under about 100 MB.

Second attempt: bucket each channel's history by time.

Add a time bucket to the partition key, so each channel is split into a series of bounded partitions:

CREATE TABLE messages (
  channel_id  bigint,
  bucket      int,       -- which 10-day window, computed from the time inside message_id
  message_id  bigint,
  author_id   bigint,
  content     text,
  PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

Size the bucket so that the busiest channel's messages for one window stay under the partition limit. Ten days is the figure Discord described. Every query now names both parts of the partition key:

-- Q1: latest 50 — start in the current bucket
SELECT * FROM messages WHERE channel_id = ? AND bucket = ? LIMIT 50;

-- Q2: the 50 before message X — X's own bucket comes from X's id
SELECT * FROM messages WHERE channel_id = ? AND bucket = ? AND message_id < ? LIMIT 50;

-- Q3: post — an upsert, with no read first
INSERT INTO messages (channel_id, bucket, message_id, author_id, content) VALUES (?, ?, ?, ?, ?);

-- Q4: edit — the bucket is again derived from the message id
UPDATE messages SET content = ? WHERE channel_id = ? AND bucket = ? AND message_id = ?;

If a read returns fewer than 50 messages, the application reads the previous bucket and continues until it has 50. This is why the time-ordered id matters: the bucket of any message can be computed from its id, so no lookup is needed. A quiet channel may have empty buckets to step over. The usual fix is to remember each channel's most recent non-empty bucket, or to stop after a few empty ones.

Question Partition read Slice taken inside it
Q1 latest 50 (channel, current bucket), then earlier buckets if needed First 50 rows: already newest first
Q2 scroll back (channel, bucket of X) Rows after X in stored order, i.e. older
Q3 post (channel, current bucket) Appended in place by the sort order
Q4 edit (channel, bucket of the message) One row

A new question means a new table. Suppose moderators need "a user's recent messages across all channels". Nothing above is keyed by author, so add messages_by_author, with PRIMARY KEY ((author_id, bucket), message_id), and write every message to both tables. A logged batch makes Cassandra eventually apply both writes or neither. It is not a transaction: a reader can briefly see one table updated and not the other.

Where this design bites

Cassandra rewards one discipline: pick a partition key that spreads load and stays bounded, and a clustering order that matches how you read.

The questions that decide it

Pick the store by answering these, in this order. Each one forks the design; a question that forks nothing is not worth asking.

Question If the answer is… Lean towards
Must several writes commit together, with invariants enforced? Yes — a balance, a double-entry posting, stock levels Relational, ACID
What must it do during a network partition? Refuse rather than be wrong A CP store (below)
Keep serving, converge later An AP store
How is it read? By exact key Key-value
By rich, changing queries and joins Relational or document
By walking relationships Graph
By scanning a few columns over billions of rows Columnar or a warehouse
Is the schema fixed? Yes, and it matters Relational
Genuinely variable Document
Read-heavy or write-heavy? Write-heavy, append-mostly LSM-based stores (Cassandra and similar)
Read-heavy B-tree relational with indexes and caches (R11, R12)
Do you need range scans on the key? Yes Range partitioning, ordered stores
No Hash partitioning

In payments, the first question settles the ledger before you reach the second. Double-entry postings, balances and holds need a transaction boundary, so the ledger lives on a relational ACID store. Everything around it — feeds, notification history, search, analytics — is fed from the ledger's events and can live wherever its access pattern points.

Choose the store by what the data must survive, not by what the team already runs.

CAP, applied to store choice

R0 defines CAP: when the network partitions, a replicated system chooses between consistency (every read sees the latest write) and availability (every request gets an answer). Here the useful move is to apply it per kind of data, the way the opening story did.

Choose Guarantees during a partition Best effort Example stores Fits
CP Consistency — it refuses or blocks rather than return a stale or conflicting answer Availability MongoDB, HBase, CockroachDB Banking, ledgers, inventory
AP Availability — it keeps answering and reconciles afterwards Consistency Cassandra, DynamoDB, Couchbase Feeds, recommendations, carts
CA Both, because there is no partition to survive — A single-node PostgreSQL or MySQL Only while it is one machine

The CA row is there to be named and dismissed. A single node has no partition because it has no network between copies; the moment you add a replica, you are choosing between C and A again.

Two precise points interviewers listen for. First, CAP's C is not ACID's C — CAP means one up-to-date copy, ACID means the rules and constraints still hold. A store can be both, either or neither. Second, many modern stores are not one letter: DynamoDB and Cassandra let you pick consistency per request, which moves them along the dial rather than across it. PACELC (R0) is the more honest frame, because on a healthy day the trade you actually feel is latency against consistency.

Why money is CP, and what can be AP

Money data is CP because the two failure modes are not symmetric. An unavailable ledger means a customer retries in a minute. An inconsistent ledger means two debits approved against one balance, and the second one cannot be taken back by any amount of convergence. R0's rule — prefer the failure you can clean up — points straight at CP.

But most of a payments platform is not the ledger:

Data Partition behaviour Why
Ledger entries, balances, holds CP Invariants per account; a wrong answer is unrecoverable
Idempotency records for money movement CP A lost key is a duplicate payment (R3)
Payment state machine CP Two writers disagreeing on state is a double submission to the rail
Activity feeds, statements views AP Read models, rebuilt from events; stale is fine
Notification history AP Duplicates are tolerable and deduplicated downstream
Search, analytics, recommendations AP Derived data; correctness lives elsewhere

The pattern is: the write model is CP, the read models are AP, and the events from R4 carry changes from one to the other. That is CQRS in one sentence, and R11 covers how to run it.

The design that makes CP affordable is a single writer per account. If every write for an account goes through one home — one shard, one region — there is never a second copy to disagree with, and the partition question becomes "is the home reachable?" rather than "which copy is right?". R13 covers how writes are homed across regions.

Replication: keeping copies in step

A store is replicated for three reasons: availability (a replica takes over when the primary dies), read scale (replicas serve reads), and disaster recovery (a copy in another place). Each copy costs you one of two things — latency or freshness — and which one is decided by when the write is acknowledged.

Synchronous and asynchronous

Synchronous replication acknowledges the write only once the replicas have it. Asynchronous replication acknowledges as soon as the primary has it and ships the change afterwards.

Synchronous Asynchronous
When the client hears "committed" After the replicas confirm After the primary alone commits
Write latency Adds a round trip to the slowest required replica Primary-only
Replica freshness Up to date Behind by the replication lag
Primary dies right after ack No acknowledged write is lost Recent acknowledged writes can be lost
A replica is slow or down Writes slow or stall Writes carry on; lag grows
Fits Data you cannot lose — ledgers, payment state Read scale, analytics copies, cross-region DR where latency rules out sync

The practical middle ground is semi-synchronous: wait for one replica (or a quorum), not all of them. You keep durability against a single failure without letting the slowest replica set your latency.

The read that bites: reading your own write from an async replica.

A customer pays, the write commits on the primary, the confirmation page reads from a replica that has not caught up, and the payment is not there. Nothing is broken; the replica is simply behind. R11 covers the fixes — read-your-writes routing, reading from the primary after a write, and version checks.

The three replication patterns

Pattern Who accepts writes Conflicts Examples Fits
Primary–secondary (leader–follower) One primary; replicas serve reads None from concurrent writes — one writer MySQL, PostgreSQL, Aurora The default; anything with invariants
Primary–primary (multi-leader) Any primary Yes — two primaries can accept conflicting writes CouchDB, multi-region active-active setups Writes from many regions where conflicts are rare or mergeable
Quorum (leaderless) Any replica; a write needs W acks, a read asks R replicas Possible; resolved by versions, LWW or merge Cassandra, DynamoDB, Riak Huge scale, high availability, tunable consistency

Primary–secondary is the pattern to reach for first, because it has exactly one place where a write is decided. That is the single-writer property again, and it is why the ledger sits on it.

Primary–primary trades that away for local writes everywhere. The cost is that two primaries can accept contradictory writes, and something has to decide which wins. For a profile picture, last-write-wins is fine. For a balance, there is no merge rule that makes two approved debits into one, which is why money is homed rather than multi-mastered (R13).

Quorum arithmetic: N, R and W

Leaderless stores make consistency a dial with three settings.

If R + W > N, every read set overlaps every write set in at least one replica, so a read sees the latest acknowledged write. Picture three seats: a write sits in two of them, a read looks at two of them, and two groups of two out of three must share a seat.

Setting (N = 3) Behaviour Fits
W = 1, R = 1 Fastest; eventual consistency Metrics, caches, session data
W = 2, R = 2 (QUORUM) Overlap guaranteed; tolerates one replica down for reads and writes The sensible default
W = 3, R = 1 Fast reads; a write fails if any replica is down Read-heavy, rarely written config
W = 1, R = 3 Fast writes; slow reads Write-heavy logs that are rarely read
LOCAL_QUORUM Quorum inside the local data centre only Multi-region with local latency

Raising W makes writes slower and less available; raising R does the same for reads. Every row is a different way of satisfying the rule, or deliberately breaking it.

The trap to name: quorum overlap is necessary but not sufficient for linearizability. Concurrent writes resolved by last-write-wins can lose to clock skew. Sloppy quorums let writes land outside the home replicas, so a read of the home set misses them. A write that reached one replica and "failed" can reappear later through repair. And a timed-out write is not a failed write — R0's "unknown is not failed" applies here too. For true compare-and-set you need a consensus round per key, which is R8's territory. R17 walks through a full leaderless key-value store built on these settings.

When copies disagree

Replication with any asynchrony produces copies that diverge. Two jobs follow: detect the disagreement — version vectors or checksums — and resolve it — last-write-wins (which trusts clocks that NTP never perfectly syncs), merge-friendly data types (CRDTs), or a repair process. R6 covers read repair and Merkle-tree anti-entropy; the point here is only that choosing an AP store means choosing to run one of them.

Before moving on, see both replication modes side by side. Write to the primary in synchronous mode and watch the acknowledgement wait for the replica; switch to asynchronous, write again, and read from the replica before the change arrives. That stale read is the one R11 spends a section fixing.

Live · Sync vs async replication

Now cut the network. The demo below has two replicas and a partition switch. In CP mode, watch the minority side refuse the write; in AP mode, watch both sides answer and one of them answer wrongly. Ask yourself which of the two you would want behind a balance check.

Live · Two replicas, one partition

Replication gives every node the whole dataset. When the dataset, or the write load on it, outgrows one machine, the next step is to split it.

Partitioning and sharding: not the same thing

These get used interchangeably and are different decisions with different costs. Getting the distinction right in the first sentence of an answer buys you a lot.

Partitioning splits one table inside one database. The rows live in separate physical chunks, but it is still one server, one connection, one transaction manager. Your application does not know it happened. Postgres calls this declarative partitioning; the planner prunes the chunks a query cannot match.

Sharding splits data across independent databases. Each shard is a separate server with its own storage, connections and transaction boundary. Nothing spans them for free — no joins, no foreign keys, no transactions — and something has to route each query to the right one.

Partitioning Sharding
Scope One database instance Many independent databases
Transactions across the split Yes, normal ACID No — you are back to R2 and R5
Joins and foreign keys Work, with caveats Do not work
Routing The query planner does it You build or buy it
What it fixes Table size, retention, vacuum and index bloat, query pruning Write throughput, connections, storage, CPU beyond one machine
Reversibility Cheap to change A one-way architectural door

The rule: partition early, shard late. Partitioning is a schema change with real benefits and modest risk. Sharding changes what your application is allowed to assume, permanently — and the day you shard is the day cross-account transfers stop being a transaction and start being a saga.

Choosing a partitioning scheme

Start with the problem partitioning solves. Picture ledger_entry after three years: two billion rows in one table. A query for "this month's entries for account 42" has to walk an index built over all three years. Deleting entries older than the retention period means a DELETE that runs for hours and bloats the table. Routine maintenance — vacuum, index rebuilds, backups — takes longer every month.

Partitioning fixes this by splitting the one big table into many smaller tables, called partitions, while the application keeps reading and writing a single table name. Two decisions define it:

Postgres, like most relational databases, offers three schemes:

  1. Range — each partition owns a continuous range of values, almost always a span of time.
  2. List — each partition owns an explicit list of values, such as a rail or a country.
  3. Hash — each partition owns a share of the hash of a key, so rows spread evenly.

Choose the scheme from how you query the data and how you delete it. Everything below follows from that one sentence.

1. Range partitioning: split by time

How it works. Each partition holds the rows whose key falls in its range. The October 2026 partition of ledger_entry holds every row with posted_at from 1 October up to, but not including, 1 November. A row is routed to the partition whose range contains it.

Where it fits. Data that only grows and is read and retired by date: ledger entries by month, outbox rows by day, reconciliation staging by value date.

What you gain.

What it costs.

2. List partitioning: split by category

How it works. Each partition is given an explicit set of values: 'UPI' goes to payment_upi, 'NEFT', 'RTGS' and 'IMPS' go to payment_bank. A default partition can catch any value nobody listed.

Where it fits. A small, known set of categories that need different treatment — rail, region, tenant tier.

What you gain.

What it costs.

3. Hash partitioning: split evenly

How it works. The database hashes the key and takes the remainder by the number of partitions: with four partitions, hash(account_id) % 4 picks one. Rows spread roughly evenly, whatever the key values look like.

Where it fits. Data with no natural range, looked up by an exact key, where the goal is even load — account_balance by account_id.

What you gain.

What it costs.

The three side by side

Range List Hash
A partition holds A span of values, usually time Named values A share of the key's hash
Typical payments use ledger_entry by month Payments by rail, customers by region Balances by account
Queries that are pruned Ranges and exact values of the key Exact values of the key Exact values of the key only
Retention Drop old partitions — instant Per category No help
Main risk The newest partition is hot One category dominates Range queries hit every partition

The schemes also combine. A very large ledger can be range by month, then hash by account inside each month: monthly retention and pruning, with no single month becoming one enormous table.

Before you commit to a partition key

Four questions, in order. A "no" to any of the first three means the key is probably wrong.

  1. Does your most common query filter on it? Pruning only works when the query names the partition key. If the usual query cannot, every query visits every partition, and partitioning has made things slower.
  2. Does your retention policy line up with it? Data deleted by age wants partitions by time, so that deleting is dropping.
  3. Where will the writes land? Range by time concentrates writes on the newest partition; hash spreads them. Decide whether that concentration is acceptable.
  4. Can you live with the uniqueness rule? Postgres checks uniqueness inside each partition, not across them.

In Postgres, every unique constraint on a partitioned table must include the partition key. A payment_id that must be unique across a table partitioned by rail therefore needs either the rail in the constraint or a separate registry table keyed by payment_id. This is the detail that catches teams halfway through a migration.

Partitioning well

The walkthrough below turns each scheme into SQL. Pick a scenario at the top, read the explanation on the left, and follow the statements on the right — highlighted lines are the ones that step is about. It covers range by time, list by rail and region, hash by account, sub-partitioning, the uniqueness rule and its fix, how partitioning changes query plans, and what changes when the same idea crosses servers as sharding.

How partitioning changes your queries

Partitioning is transparent to the SQL: the application still reads and writes the parent table by name, and Postgres routes each row to its partition. It is not transparent to performance, and it adds a few rules.

One question decides how a query behaves: does it filter on the bare partition key?

Queries that get faster

Queries that get slower or behave differently

Query pattern What happens What to do
No partition-key filter — WHERE payment_id = 991 on a table partitioned by month Every partition is visited and each index is probed: 36 monthly partitions, 36 lookups. Often slower than one big table Include the key, keep a registry or lookup table, or accept it for rare queries
A function or cast on the key — date_trunc('month', posted_at) = …, posted_at::date = … The planner cannot match the filter to partition bounds, so nothing is pruned Compare the bare column with a range
ORDER BY … LIMIT across partitions Each partition contributes and the results are merged; cheap when ordering by the range key, costlier on other columns Order by the partition key, or narrow the range first
Joins and aggregates between tables partitioned the same way Could run partition by partition, but enable_partitionwise_join and enable_partitionwise_aggregate are off by default Enable them for those workloads and measure
Many partitions Planning time and the number of locks grow, because partitions not pruned at plan time are all opened and locked Keep the count in the hundreds

Rules that change what you can write

Two maintenance surprises. CREATE INDEX CONCURRENTLY is not allowed on the partitioned parent — create the index ON ONLY the parent, build it concurrently on each partition and attach each one. And autovacuum analyzes partitions but never the parent, so the statistics used for queries spanning many partitions go stale unless you schedule ANALYZE on the parent.

Writing queries for a partitioned table

The Query impact tab of the walkthrough above shows each of these with EXPLAIN output and the SQL to fix it.

Choosing a shard key

This is the consequential one, because it is effectively permanent. A good shard key has six properties:

  1. High cardinality — millions of distinct values, so shards can be balanced.
  2. Even distribution — no value carrying a disproportionate share.
  3. Present in almost every query — otherwise you scatter-gather across all shards and lose the benefit.
  4. Stable — it must never change for a row, because changing it means moving the row between databases.
  5. Aligned with your transaction boundary — the things that must commit together should live on one shard.
  6. Aligned with your ordering requirement — whatever must stay in sequence should share a shard, for the same reason a Kafka key groups a partition (R9).

In payments the answer is almost always the account or customer, and the reasoning is properties 5 and 6. An account's ledger entries, its materialised balance, its holds and its payment history all belong together: the balance invariant is per account, the ordering requirement is per account, and the hot row is per account.

Payment ID is the tempting wrong answer. It has perfect cardinality and distribution, and it scatters one account's history across every shard in the estate — so "show me this customer's statement" becomes a fan-out, and "debit this account" can no longer be a single transaction with its own balance row.

Four anti-patterns to name:

Build the key, do not just pick a column

The move that makes sharding survivable is decoupling the logical key from the physical shard with virtual buckets:

bucket   = hash(account_id) % 4096      -- fixed forever
shard    = lookup(bucket)               -- a small table, changeable

The application only ever computes a bucket. A directory maps buckets to shards, and rebalancing means moving one bucket at a time rather than rehashing the world. 4,096 buckets is small enough to cache in memory in every service and fine-grained enough to balance.

Two alternatives and why the directory usually wins:

Buckets are the middle ground, and they buy you the thing you actually need at 3am: the ability to move a slice of traffic without a migration. R28's global design uses exactly this, with a fence token on the bucket so a stale writer cannot keep writing after a move (R8).

Compose the key when one column is not enough. Multi-tenant platforms shard on hash(tenant_id, account_id) so one tenant cannot monopolise a shard. Time-series systems shard on the entity and partition within the shard by time — sharding for throughput, partitioning for retention, both at once.

What breaks once you shard, and what to do about it

Problem The answer
A transfer between accounts on different shards A saga with compensation (R5), or a ledger service that owns both legs and shards below itself
Global uniqueness of IDs Generate them without coordination: UUIDv7, ULID or a Snowflake-style ID
Cross-shard reporting Do not query shards for reports. Feed a read model or a lake via CDC (R4)
Fan-out reads for a customer view Design so the customer's data is on one shard; if it truly is not, aggregate at the edge
A whale — one account with 50× the traffic Its own shard, or sub-key its events with a bounded fan-out that keeps the business ordering
Referential integrity across shards Enforce it in the domain, verify it by reconciliation (R6). The database will not do it for you

Deciding you need it, and getting there

The escalation order matters, and saying it shows judgement rather than enthusiasm:

  1. Vertical — a bigger instance. Fast, boring, often enough.
  2. Read replicas for read load (with the replication-lag caveat).
  3. Partitioning for table size, maintenance and retention.
  4. Caching and CQRS read models to take load off the write path.
  5. Sharding — only when writes, connections or storage genuinely exceed one machine.

The signals that you have arrived at 5: write throughput at the instance ceiling, connection counts you cannot proxy away, storage or index size beyond what fits in memory, maintenance windows that no longer fit the night, and p99 degrading with load rather than with any one query.

The migration is R16's expand-contract applied to data: dual-write to old and new topology, backfill history, run a continuous comparison, switch reads, then retire the old path. With virtual buckets you do it one bucket at a time, which means the blast radius of a mistake is a slice of customers rather than all of them — and the rollback is a directory update.

One thing to do before any of it: run the distribution against real production data. Count rows and write volume per candidate key, and look at the top 100 values. Skew that looks theoretical in a design review is usually obvious in the data, and it is much cheaper to discover it there.

How data is placed: modulo, consistent hashing and rendezvous

Once data is split across nodes — shards, cache servers, replicas in a leaderless ring — every request has to answer one question: which node owns this key? The answer has to be cheap to compute, the same on every client, and stable when the set of nodes changes. That last property is the one the obvious answer fails.

Modulo hashing, and why it breaks

The first thing everyone writes:

node = hash(key) % N

It is O(1), stateless and perfectly even. It works until N changes.

Take key 12. With 10 nodes it lives on node 12 % 10 = 2. Add one node and it moves to 12 % 11 = 1. Lose one and it moves to 12 % 9 = 3. That is not one unlucky key: changing N remaps almost every key, roughly N/(N+1) of them when you add one node.

For a cache, that is a cold start across the whole fleet at the moment you were adding capacity because of load. For a database, it is moving nearly every row between machines. At scale, a full rehash is not a migration — it is an outage.

Consistent hashing

The fix is to stop tying the answer to N.

  1. Hash each node — its ID or address — onto a ring of positions from 0 to the hash maximum.
  2. Hash each key onto the same ring.
  3. Walk clockwise from the key's position. The first node you meet owns the key.

Now look at what a topology change costs:

That is roughly 1/N of the data per change instead of nearly all of it. The ring is held as a sorted array or balanced tree of node positions, so a lookup is a binary search: O(log N). The hash function matters less than people think — Dynamo used MD5, current systems typically use MurmurHash — as long as it spreads evenly.

The basic ring is uneven.

With one position per node, the arcs between nodes are random lengths, so one node can own far more of the ring than another. And a new node relieves only its one clockwise neighbour, not the cluster.

Virtual nodes

The fix is to give every physical node many positions on the ring — N1_1, N1_2, N1_3 and so on.

DynamoDB, Cassandra and Riak all use them. Cassandra's default moved from 256 tokens per node to 16 in version 4.0, alongside a smarter token allocator, because more tokens also means more peers involved in every repair. The trade-off is a larger token map that every node must hold.

Virtual nodes also give you replication for free: store each key on the first r distinct physical nodes clockwise from it. A newly joined node starts empty, and the next replicas along the ring cover for it while it fills.

Hot spots: the problem hashing does not solve

Consistent hashing balances keys. It does nothing about one key that receives a disproportionate share of traffic — the celebrity account, the merchant running a flash sale. One key lives on one node, and that node melts however even the ring is.

The fixes live above the placement layer: an application-level cache in front of the hot key (R12), extra replicas serving its reads, a CDN for public content, or splitting the key itself — the whale treatment in the sharding table above. In payments, a hot account is also a hot row, and R11 covers sharded counters for that.

Rendezvous hashing (highest random weight)

A different answer to the same question, with no ring at all.

For each key, compute a weight for every server — weight = hash(key, server) — and pick the server with the highest weight. When a server leaves, only the keys it was winning move, each to its own second-highest choice. When a server joins, it wins only the keys whose new highest weight is its own. Unequal capacity is handled by scaling the weights.

It is simple to implement, needs nothing in memory beyond the server list, and moves the minimum on change. The price is O(N) per lookup, because every server is scored. That makes it a good fit for small and medium clusters — load balancers, Memcached clients, distributed hash tables — and a poor one for thousands of nodes.

The comparison

Modulo Consistent hashing Rendezvous (HRW)
Complexity Low Medium Medium
Lookup cost O(1) O(log N) O(N)
Keys moved when a node changes Almost all About 1/N About 1/N
Even spread Yes Only with virtual nodes Yes, with good hashing
Hot keys Not addressed Not addressed Not addressed
Best for A cluster that never changes size Large, dynamic clusters Small to medium clusters

How this connects to the bucket directory above. Virtual buckets are the same idea taken one step further: hash to a fixed number of buckets, then map buckets to shards with a table you control. Consistent hashing decides placement by arithmetic; a bucket directory decides it by a lookup you can edit at 3am. For a payments estate, where moving one account deliberately matters more than moving none automatically, the directory usually wins — as the sharding section argues.

Walk through it step by step. The walkthrough below starts with the modulo problem and builds the ring one piece at a time — hashing nodes, hashing keys, the clockwise walk, a node joining and leaving, then virtual nodes and the binary-search lookup. At the join and leave steps, count how many keys actually move.

Then route some traffic. The shard router below sends keys through range and hash sharding. Feed it sequential IDs under range sharding and watch every write pile onto the last shard — the monotonic-key anti-pattern from the shard key list, happening live. Switch to hash and watch the load spread, then try a range query and see it touch every shard.

Live · Shard router, IDs 1–1000 over 4 shards

Bloom filters: asking "is it here?" without looking

A different storage problem, and one that shows up inside the stores you choose. You need to know whether a key is in a very large set without reading the set. A read in an LSM store is the classic case: the key could be in any of dozens of immutable files on disk, and opening each one to find out is most of the cost of the read.

A Bloom filter answers the question in a few bits per key, with one deliberate weakness.

How it works

Answer Meaning Can it be wrong?
"Not present" At least one of its bits was never set Never — no false negatives
"Maybe present" All its bits are set, possibly by other keys Yes — a false positive

Each operation is O(k), effectively constant. The filter stores bits, not keys, so it is tiny. And it cannot delete: clearing a bit might erase evidence of another key. Where deletes matter, use a counting Bloom filter or a cuckoo filter.

Sizing it

Two formulas, for n expected keys and a target false-positive rate p:

m = -(n × ln p) / (ln 2)²      bits in the array
k = (m / n) × ln 2             hash functions

Worked example: one million keys at a 2% false-positive rate.

m = -(1,000,000 × ln 0.02) / (ln 2)²
  = (1,000,000 × 3.912) / 0.4805
  ≈ 8.1 million bits  ≈ 1 MB

k = 8.1 × 0.693 ≈ 5.6  →  6 hash functions

One megabyte to rule out a million keys. The rule of thumb behind it: about 10 bits per key gives roughly a 1% false-positive rate, and every halving of p costs more memory. The filter only helps if it sits in RAM, so it is sized per node — R17's key-value store works through that budget for a petabyte cluster.

Where it earns its place

In LSM stores — Cassandra, Bigtable, HBase — every SSTable carries a Bloom filter. A read checks each file's filter first and opens only the files that say "maybe." A key that is nowhere costs a few memory lookups instead of a disk read per file, and a key that exists costs one or two reads instead of dozens. This is what keeps the read amplification of an LSM tree bounded, and it is why write-heavy stores can still serve point reads quickly.

Elsewhere, the same shape: a cache that checks a filter before going to the database for a key that does not exist (the cache-penetration fix in R12), spam and IP blocklists, intrusion detection.

The part people miss: the false positive is safe only because the "maybe" is followed by a real check. A Bloom filter is a fast way to skip work, never the authority. Used as the only duplicate check for payments, a false positive would silently reject a legitimate payment as a duplicate — the R3 idempotency record stays the source of truth, and a filter can only sit in front of it.

Try it. Add a handful of keys and check a few that you did not add. Keep adding until a key you never inserted comes back "maybe present" — that is the false positive, and you can watch the array fill up to cause it.

Live · m = 16 bits, k = 3 hashes
Already added: alice, bob. Try checking "dave", or add a few items until a check gives a false positive.

What to say in the interview

"I list the access patterns before I name a store, then choose the store per kind of data, by the questions it must answer and what it must do when the network breaks. The ledger, balances and idempotency records go on a relational ACID store, single writer per account, because a refused request is recoverable and a double debit is not; feeds, notifications and read models are AP and rebuilt from the ledger's events. I partition early by time for retention and pruning, and I shard late, on the account, through a fixed set of hashed buckets mapped to shards by a directory — so rebalancing moves one bucket at a time instead of rehashing the world."