InterviewKata
System design · Technology guide T03 · Builds on Modules 11 to 17

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.

A 3-minute conversation: Sam asks, Priya explains
0:00 / 2:54

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.

FamilyExamplesStrengthWeakness
Relational, one primaryPostgreSQL, MySQL, Aurora, AlloyDB, Cloud SQLJoins, transactions, constraints, any query you think of laterAll writes go through one machine
Distributed SQLSpanner, CockroachDB, Aurora DSQL, YugabyteDBSQL and transactions, with writes spread over many machines and regionsEach write pays for consensus; more cost and latency than one machine
Key-value and wide-columnDynamoDB, Cassandra, BigtablePredictable speed at almost any scale, for access by keyYou design the tables around the queries; ad hoc queries are scans
DocumentMongoDB, Firestore, Cosmos DBFlexible JSON documents that match objects in codeJoins 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

Relational · open source

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.

Relational · open source (GPL) and commercial

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.

Managed relational · AWS and Google Cloud

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.

Distributed SQL · Google Cloud

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.

Distributed SQL · source available

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.

Distributed SQL · AWS

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.

Key-value · AWS

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.

Wide-column · open source (Apache 2.0)

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.

Wide-column · Google Cloud

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.

Document · SSPL and managed (Atlas)

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.

Document · Google Cloud

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.

Multi-model · Azure

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

OptionModelScales writes byConsistencyMulti-region writesLicence
PostgreSQLRelationalA bigger machine; sharding by hand or with CitusStrong on the primaryNo (one primary)PostgreSQL (permissive)
MySQLRelationalVitess or app shardingStrong on the primaryNo in practiceGPL or commercial
Aurora / AlloyDBRelationalA bigger writerStrong on the writerNo (one writer)Managed service
SpannerRelationalAutomatic range splitsExternal consistencyYes, strongly consistentManaged service
CockroachDBRelationalAutomatic range splitsSerializableYes, strongly consistentEnterprise (source available)
Aurora DSQLRelationalServerless, automaticStrong, optimistic concurrencyYes, strongly consistentManaged service
DynamoDBKey-valueHash partitionsEventual or strongly consistent readsGlobal tables: eventual or strongManaged service
CassandraWide-columnAdding nodes (consistent hashing)Tunable quorumsYes, last writer winsApache 2.0
BigtableWide-columnAdding nodes (tablets)Strong in one clusterReplication between clusters, eventualManaged service
MongoDBDocumentSharding by shard keyTunable (read and write concerns)No (one primary per shard)SSPL
FirestoreDocumentAutomaticStrongMulti-region locations, one logical databaseManaged service
Cosmos DBMulti-modelPartition keyFive levelsYes, but not with strongManaged service

5. When to choose which, and why

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Two-sentence answers
  • "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

Choosing NoSQL "for scale" at small scaleYou give up joins and ad hoc queries for a scale you may never reach. Do the arithmetic first.
A low-cardinality or time-based partition keyStatus or today's date sends most traffic to one partition, however many partitions you pay for.
Designing key-value tables before listing the queriesIn DynamoDB, Cassandra and Bigtable the queries come first; a query you didn't plan for becomes a scan.
Assuming "multi-region" means "consistent"Many multi-region modes resolve concurrent writes by last writer wins. Read which mode you're buying.
Ignoring licences when self-hostingMongoDB (SSPL) and CockroachDB (Enterprise) aren't OSI open source; check before you build a product on them.

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?

Only the partition key routes to one partition; other fields need a scan or a secondary index.

Why is the order date a poor partition key for an orders table?

Recent data is the busiest, so a time-based key creates a hot partition.

Payments must be written in three regions and every region must agree on balances. Which fits?

Distributed SQL uses consensus so regions agree, at the cost of cross-region latency per write.

Which Cosmos DB setup is not possible?

Microsoft's documentation says accounts with multiple write regions can't use strong consistency.

10. Sources and further reading

Product facts checked on 3 November 2026. Versions, licences and limits change; check before relying on them.

SourceUsed for
PostgreSQL 19 RC 1 releasedRelease candidate and planned general availability date
Quotas in Amazon DynamoDBThroughput defaults, secondary indexes, global tables modes
Amazon Aurora DSQL is now generally availableGeneral availability, multi-region clusters and the witness region
Spanner: TrueTime and external consistencyHow Spanner orders commits
Cosmos DB consistency levelsThe five levels; no strong consistency with multiple write regions
CockroachDB enterprise licence updateCore retired; free Enterprise tier
Module 13: Choosing a databaseThe decision process this guide builds on