Databases
"Which database?" is asked in almost every design interview, and "PostgreSQL" is a good default answer more often than people expect. The interesting part is knowing when it stops being enough: when writes outgrow one machine, when users in several regions must all write, or when the data is accessed only by key at huge scale. This guide sorts the main products into families, explains how each works and when to choose it, with a chooser and code you can run.
- Sam "Which database?" What's the safe answer?
- Priya PostgreSQL, more often than people expect. The interesting part is knowing when it stops being enough: when writes outgrow one machine, when several regions must all write, or when data is read only by key at huge scale.
- Sam What are the families?
- Priya Relational with one primary, like PostgreSQL, MySQL, Aurora and AlloyDB. Distributed SQL, like Spanner, CockroachDB and Aurora DSQL, which keep SQL but spread writes. Key-value and wide-column, like DynamoDB, Cassandra and Bigtable. And document stores, like MongoDB, Firestore and Cosmos DB.
- Sam How does a partitioned database answer a query?
- Priya It hashes the partition key to find one partition. A query by the key asks one partition; a query by any other field asks every partition. A secondary index is a second copy keyed by that field, which makes it one lookup again. And a bad key, like today's date, sends all of today's writes to one hot partition.
- Sam What stands out about the products?
- Priya PostgreSQL eighteen is current, with nineteen due on the nineteenth of November. Spanner uses TrueTime to order commits across regions. CockroachDB has one Enterprise licence since November twenty twenty-four, free for small companies. Aurora DSQL has been generally available since May twenty twenty-five, with strong consistency across regions. DynamoDB places items by a hash of the partition key. Cassandra is leaderless with tunable quorums. MongoDB is under the SSPL. And Cosmos DB offers five consistency levels, but not strong with multiple write regions.
- Sam How do I choose?
- Priya Start with PostgreSQL or its managed version. Do the arithmetic before deciding writes outgrow one machine. If several regions must write and agree, use distributed SQL. If last writer wins is fine, DynamoDB global tables, Cassandra or Cosmos DB. If access is only by key at large scale, with queries known up front, a key-value store.
- Priya Your turn. An orders table is keyed by order date. What goes wrong?
- Priya Today's writes all land on one partition. You pay for many partitions and use one.
- Sam And the code?
- Priya Twelve orders in four partitions: a query by customer asks one partition, late orders ask all four, an index makes it one, and keying by day creates a hot partition.
- Sam Common mistakes?
- Priya Choosing NoSQL for scale you don't have. Low-cardinality or time-based partition keys. Designing key-value tables before listing the queries. Assuming multi-region means consistent. And ignoring licences when self-hosting.
1. The families
Most databases you'll name in an interview fall into four families. Module 13 teaches how to choose between them; this guide is about the products in each.
| Family | Examples | Strength | Weakness |
|---|---|---|---|
| Relational, one primary | PostgreSQL, MySQL, Aurora, AlloyDB, Cloud SQL | Joins, transactions, constraints, any query you think of later | All writes go through one machine |
| Distributed SQL | Spanner, CockroachDB, Aurora DSQL, YugabyteDB | SQL and transactions, with writes spread over many machines and regions | Each write pays for consensus; more cost and latency than one machine |
| Key-value and wide-column | DynamoDB, Cassandra, Bigtable | Predictable speed at almost any scale, for access by key | You design the tables around the queries; ad hoc queries are scans |
| Document | MongoDB, Firestore, Cosmos DB | Flexible JSON documents that match objects in code | Joins across documents are weak or missing; schema discipline moves into the app |
2. How a partitioned database answers a query
Every family above except "one primary" spreads data over partitions by a key. That one decision controls which queries are fast.
3. The products
PostgreSQL
One primary takes writes and streams its write-ahead log to replicas (Module 14). Rich SQL, strong extensions (PostGIS, pgvector), JSONB for document-style data. Version 18 is current; 19 is in release candidates with general availability planned for 19 November 2026.
Best for: the default for new products, until writes outgrow one machine.
MySQL
One primary with replicas, the InnoDB storage engine, and Group Replication for automatic failover. Widely run at very large scale with sharding layers such as Vitess.
Best for: teams that know it, and very large sharded fleets with Vitess.
Aurora, AlloyDB, Cloud SQL, RDS
Managed PostgreSQL and MySQL. Aurora and AlloyDB separate compute from a replicated storage layer, so replicas share storage and failover is fast; AlloyDB adds a columnar engine for analytics. Cloud SQL and RDS run the standard engines for you.
Best for: PostgreSQL or MySQL on a cloud without running servers.
Spanner
Data is split into ranges, each replicated with Paxos. TrueTime, a clock API with a known error bound, gives commit timestamps that match the real order of commits (external consistency). Offers GoogleSQL and PostgreSQL dialects.
Best for: SQL with strong consistency across regions at large scale.
CockroachDB
PostgreSQL wire-compatible; ranges replicated with Raft; serializable isolation by default. Since 18 November 2024 there's a single Enterprise licence, free for individuals and companies under 10 million dollars in annual revenue.
Best for: multi-region SQL you run yourself or on its cloud, if the licence fits.
Aurora DSQL
Serverless, PostgreSQL-compatible, generally available since 27 May 2025. Multi-region clusters give strong consistency across regions, with a third region acting as a log-only witness. Uses optimistic concurrency, so conflicting transactions fail at commit and retry.
Best for: active-active SQL across AWS regions without managing servers.
DynamoDB
Items are placed by a hash of the partition key and sorted by an optional sort key. Single-digit-millisecond reads at any table size, on-demand or provisioned capacity, global secondary indexes for other access patterns, and global tables for multi-region (eventual by default, or strong consistency).
Best for: known access patterns at any scale on AWS.
Apache Cassandra
Leaderless: every node can take writes, with tunable quorums (Module 14). An LSM-tree storage engine makes writes cheap. You model one table per query, keyed by partition and clustering columns.
Best for: very high write volumes across data centres, self-managed.
Bigtable
Rows sorted by a single row key and split into tablets across nodes; HBase-compatible API. The row key is the only index, so its design decides everything.
Best for: huge time-series, analytics and personalisation data read by row key ranges.
MongoDB
JSON-like documents in collections, replica sets for availability, sharding by a shard key, and multi-document transactions. The server is licensed under the SSPL, which isn't an OSI open-source licence.
Best for: document-shaped data with evolving fields, often on Atlas.
Firestore
A serverless document database with real-time listeners and offline sync for mobile and web clients, and strong consistency for reads and queries.
Best for: app backends that push changes to clients in real time.
Cosmos DB
Partitioned by a partition key, billed in request units, with APIs for NoSQL, MongoDB, Cassandra and more. Offers five consistency levels: strong, bounded staleness, session, consistent prefix and eventual. Multi-region writes are possible, but not with strong consistency.
Best for: globally distributed apps on Azure that want a consistency level per need.
4. Differences at a glance
| Option | Model | Scales writes by | Consistency | Multi-region writes | Licence |
|---|---|---|---|---|---|
| PostgreSQL | Relational | A bigger machine; sharding by hand or with Citus | Strong on the primary | No (one primary) | PostgreSQL (permissive) |
| MySQL | Relational | Vitess or app sharding | Strong on the primary | No in practice | GPL or commercial |
| Aurora / AlloyDB | Relational | A bigger writer | Strong on the writer | No (one writer) | Managed service |
| Spanner | Relational | Automatic range splits | External consistency | Yes, strongly consistent | Managed service |
| CockroachDB | Relational | Automatic range splits | Serializable | Yes, strongly consistent | Enterprise (source available) |
| Aurora DSQL | Relational | Serverless, automatic | Strong, optimistic concurrency | Yes, strongly consistent | Managed service |
| DynamoDB | Key-value | Hash partitions | Eventual or strongly consistent reads | Global tables: eventual or strong | Managed service |
| Cassandra | Wide-column | Adding nodes (consistent hashing) | Tunable quorums | Yes, last writer wins | Apache 2.0 |
| Bigtable | Wide-column | Adding nodes (tablets) | Strong in one cluster | Replication between clusters, eventual | Managed service |
| MongoDB | Document | Sharding by shard key | Tunable (read and write concerns) | No (one primary per shard) | SSPL |
| Firestore | Document | Automatic | Strong | Multi-region locations, one logical database | Managed service |
| Cosmos DB | Multi-model | Partition key | Five levels | Yes, but not with strong | Managed service |
5. When to choose which, and why
- Start with PostgreSQL (or the managed version on your cloud) unless something below rules it out. It handles more than most products ever need, and keeps every query option open.
- Do writes outgrow one machine? Do the arithmetic first (Module 03): tens of thousands of writes a second still fit one well-sized primary. Beyond that, shard by tenant, or move to distributed SQL or a key-value store.
- Must several regions accept writes? If they must also agree (money, inventory), use distributed SQL: Spanner, CockroachDB or Aurora DSQL. If last writer wins is acceptable, DynamoDB global tables, Cassandra or Cosmos DB.
- Is access only by key, at large scale, with patterns known up front? DynamoDB, Cassandra or Bigtable give predictable latency; you design the tables around the queries.
- Is the data naturally documents read whole? MongoDB, Firestore or Cosmos DB; but PostgreSQL's JSONB is often enough.
Try it: choose for your requirements
6. Partition keys, in code
Twelve orders in a store with four partitions. Keyed by customer, "orders of one customer" asks one partition, but "all late orders" must ask all four. A global secondary index (a second copy keyed by status) turns that into a one-partition query. Keyed by day, half the orders are from today, so today's writes all land on one partition: a hot partition. The program was run in all three languages; each prints the output below it.
PARTITIONS = 4
def partition_of(key):
"""A stand-in for the hash a partitioned store (DynamoDB, Cassandra, Spanner splits) uses to place a key."""
return sum(ord(ch) for ch in key) % PARTITIONS
class PartitionedTable:
def __init__(self, partition_key):
self.partition_key = partition_key
self.parts = [[] for _ in range(PARTITIONS)]
def put(self, row):
self.parts[partition_of(row[self.partition_key])].append(row)
def query_by_key(self, value):
"""Partition key given: the store goes straight to one partition."""
rows = [r for r in self.parts[partition_of(value)] if r[self.partition_key] == value]
return rows, 1
def scan_where(self, field, value):
"""Any other field: every partition must be asked (scatter-gather)."""
rows = [r for part in self.parts for r in part if r[field] == value]
return rows, PARTITIONS
def sizes(self):
return [len(p) for p in self.parts]
ORDERS = []
for i in range(12):
day = "2026-11-03" if i >= 6 else f"2026-11-0{1 + i % 2}" # half the orders are from today
ORDERS.append({"id": f"o{i}", "customer": f"cust{i % 5}", "day": day, "status": "late" if i % 4 == 0 else "ok"})
by_customer = PartitionedTable("customer")
for order in ORDERS:
by_customer.put(order)
rows, asked = by_customer.query_by_key("cust2")
print(f"orders of cust2: {len(rows)} rows, asked {asked} of {PARTITIONS} partitions")
rows, asked = by_customer.scan_where("status", "late")
print(f"late orders: {len(rows)} rows, asked {asked} of {PARTITIONS} partitions")
# A global secondary index is a second table keyed by the other field, kept in step by the store.
by_status = PartitionedTable("status")
for order in ORDERS:
by_status.put(order)
rows, asked = by_status.query_by_key("late")
print(f"late orders via an index: {len(rows)} rows, asked {asked} of {PARTITIONS} partitions")
print("rows per partition, keyed by customer:", by_customer.sizes())
by_day = PartitionedTable("day")
for order in ORDERS:
by_day.put(order)
print("rows per partition, keyed by day:", by_day.sizes())
today = sum(1 for o in ORDERS if o["day"] == "2026-11-03")
print(f"today's {today} writes all land on partition {partition_of('2026-11-03')}: a hot partition")
const PARTITIONS = 4;
type Row = { id: string; customer: string; day: string; status: string };
type Field = keyof Row;
// A stand-in for the hash a partitioned store (DynamoDB, Cassandra, Spanner splits) uses to place a key.
function partitionOf(key: string): number {
let sum = 0;
for (const ch of key) sum += ch.charCodeAt(0);
return sum % PARTITIONS;
}
class PartitionedTable {
parts: Row[][] = Array.from({ length: PARTITIONS }, () => []);
constructor(readonly partitionKey: Field) {}
put(row: Row): void {
this.parts[partitionOf(row[this.partitionKey])].push(row);
}
// Partition key given: the store goes straight to one partition.
queryByKey(value: string): [Row[], number] {
return [this.parts[partitionOf(value)].filter((r) => r[this.partitionKey] === value), 1];
}
// Any other field: every partition must be asked (scatter-gather).
scanWhere(field: Field, value: string): [Row[], number] {
return [this.parts.flat().filter((r) => r[field] === value), PARTITIONS];
}
sizes(): string {
return `[${this.parts.map((p) => p.length).join(", ")}]`;
}
}
const ORDERS: Row[] = [];
for (let i = 0; i < 12; i++) {
const day = i >= 6 ? "2026-11-03" : `2026-11-0${1 + (i % 2)}`; // half the orders are from today
ORDERS.push({ id: `o${i}`, customer: `cust${i % 5}`, day, status: i % 4 === 0 ? "late" : "ok" });
}
const byCustomer = new PartitionedTable("customer");
ORDERS.forEach((o) => byCustomer.put(o));
let [rows, asked] = byCustomer.queryByKey("cust2");
console.log(`orders of cust2: ${rows.length} rows, asked ${asked} of ${PARTITIONS} partitions`);
[rows, asked] = byCustomer.scanWhere("status", "late");
console.log(`late orders: ${rows.length} rows, asked ${asked} of ${PARTITIONS} partitions`);
// A global secondary index is a second table keyed by the other field, kept in step by the store.
const byStatus = new PartitionedTable("status");
ORDERS.forEach((o) => byStatus.put(o));
[rows, asked] = byStatus.queryByKey("late");
console.log(`late orders via an index: ${rows.length} rows, asked ${asked} of ${PARTITIONS} partitions`);
console.log("rows per partition, keyed by customer:", byCustomer.sizes());
const byDay = new PartitionedTable("day");
ORDERS.forEach((o) => byDay.put(o));
console.log("rows per partition, keyed by day:", byDay.sizes());
const today = ORDERS.filter((o) => o.day === "2026-11-03").length;
console.log(`today's ${today} writes all land on partition ${partitionOf("2026-11-03")}: a hot partition`);import java.util.*;
public class Main {
static final int PARTITIONS = 4;
record Row(String id, String customer, String day, String status) {
String get(String field) {
return switch (field) {
case "customer" -> customer;
case "day" -> day;
case "status" -> status;
default -> id;
};
}
}
// A stand-in for the hash a partitioned store (DynamoDB, Cassandra, Spanner splits) uses to place a key.
static int partitionOf(String key) {
int sum = 0;
for (char ch : key.toCharArray()) sum += ch;
return sum % PARTITIONS;
}
static class PartitionedTable {
final String partitionKey;
final List<List<Row>> parts = new ArrayList<>();
PartitionedTable(String partitionKey) {
this.partitionKey = partitionKey;
for (int i = 0; i < PARTITIONS; i++) parts.add(new ArrayList<>());
}
void put(Row row) {
parts.get(partitionOf(row.get(partitionKey))).add(row);
}
// Partition key given: the store goes straight to one partition.
int queryByKey(String value) {
return (int) parts.get(partitionOf(value)).stream().filter(r -> r.get(partitionKey).equals(value)).count();
}
// Any other field: every partition must be asked (scatter-gather).
int scanWhere(String field, String value) {
return (int) parts.stream().flatMap(List::stream).filter(r -> r.get(field).equals(value)).count();
}
String sizes() {
return parts.stream().map(p -> String.valueOf(p.size())).toList().toString();
}
}
public static void main(String[] args) {
List<Row> orders = new ArrayList<>();
for (int i = 0; i < 12; i++) {
String day = i >= 6 ? "2026-11-03" : "2026-11-0" + (1 + i % 2); // half the orders are from today
orders.add(new Row("o" + i, "cust" + (i % 5), day, i % 4 == 0 ? "late" : "ok"));
}
PartitionedTable byCustomer = new PartitionedTable("customer");
orders.forEach(byCustomer::put);
System.out.printf("orders of cust2: %d rows, asked 1 of %d partitions%n", byCustomer.queryByKey("cust2"), PARTITIONS);
System.out.printf("late orders: %d rows, asked %d of %d partitions%n", byCustomer.scanWhere("status", "late"), PARTITIONS, PARTITIONS);
// A global secondary index is a second table keyed by the other field, kept in step by the store.
PartitionedTable byStatus = new PartitionedTable("status");
orders.forEach(byStatus::put);
System.out.printf("late orders via an index: %d rows, asked 1 of %d partitions%n", byStatus.queryByKey("late"), PARTITIONS);
System.out.println("rows per partition, keyed by customer: " + byCustomer.sizes());
PartitionedTable byDay = new PartitionedTable("day");
orders.forEach(byDay::put);
System.out.println("rows per partition, keyed by day: " + byDay.sizes());
long today = orders.stream().filter(o -> o.day().equals("2026-11-03")).count();
System.out.printf("today's %d writes all land on partition %d: a hot partition%n", today, partitionOf("2026-11-03"));
}
}orders of cust2: 2 rows, asked 1 of 4 partitions
late orders: 3 rows, asked 4 of 4 partitions
late orders via an index: 3 rows, asked 1 of 4 partitions
rows per partition, keyed by customer: [3, 2, 2, 5]
rows per partition, keyed by day: [3, 6, 0, 3]
today's 6 writes all land on partition 1: a hot partition
7. Say it in the interview
- "SQL or NoSQL?" PostgreSQL by default, because joins, transactions and ad hoc queries stay available. I'd move to a key-value store like DynamoDB when access is by key, the patterns are known, and the scale or latency needs justify designing tables around queries.
- "What's your partition key?" The field most queries filter by, with enough distinct values to spread load evenly; never a timestamp alone, which makes today a hot partition. Other access patterns get a secondary index.
- "Users in three regions all write." If they must agree, distributed SQL such as Spanner or Aurora DSQL, paying cross-region latency per write. Otherwise a home region per user, or last writer wins where losing a concurrent update is acceptable.
8. Common mistakes
9. Check yourself
A DynamoDB table of orders is keyed by customer ID. You now need all late orders. What happens without a new index?
Why is the order date a poor partition key for an orders table?
Payments must be written in three regions and every region must agree on balances. Which fits?
Which Cosmos DB setup is not possible?
10. Sources and further reading
Product facts checked on 3 November 2026. Versions, licences and limits change; check before relying on them.
| Source | Used for |
|---|---|
| PostgreSQL 19 RC 1 released | Release candidate and planned general availability date |
| Quotas in Amazon DynamoDB | Throughput defaults, secondary indexes, global tables modes |
| Amazon Aurora DSQL is now generally available | General availability, multi-region clusters and the witness region |
| Spanner: TrueTime and external consistency | How Spanner orders commits |
| Cosmos DB consistency levels | The five levels; no strong consistency with multiple write regions |
| CockroachDB enterprise licence update | Core retired; free Enterprise tier |
| Module 13: Choosing a database | The decision process this guide builds on |