Understand relational databases, ACID, indexes and their costs, normalisation, transactions, connection pooling and the four NoSQL families, and how to choose.
What you'll learn: By the end of this lesson you will be able to explain the relational model and ACID, decide when an index helps and when it hurts, choose between normalised and denormalised designs, describe what connection pooling solves, and pick a database family for a given workload.
Think of a filing cabinet in a busy office. Each drawer holds one kind of record: customers, orders, invoices. Every folder has the same layout, so any clerk can find what they need. Now add rules: you cannot file an invoice for a customer who does not exist, and two clerks cannot both hand out the last copy of a form. Those rules are what turn a pile of paper into something you can trust.
A database is a system that stores data durably, lets many programs read and change it at once, and enforces rules so the data stays correct. Choosing and using one well is one of the highest-impact decisions in system design, because data is the hardest part of a system to change later.
Our running example is a clinic appointment-booking system: patients, clinicians, and a calendar where two people must never hold the same slot.
The core idea
Every database makes trade-offs between four things: how data is organised, how fast different operations are, how strictly correctness is enforced, and how well it spreads across machines. There is no best database, only a best fit for an access pattern.
The practical default: start with a relational database such as PostgreSQL or MySQL. It is mature, flexible and strict, and it handles far more load than people expect. Reach for other families when you have a specific reason you can name.
The relational model and ACID
A relational database stores data in tables. A table has columns (typed fields) and rows (records). Tables link to each other through keys.
A primary key uniquely identifies a row, for example patients.id.
A foreign key is a column that refers to a primary key in another table, for example appointments.patient_id refers to patients.id. The database can refuse an appointment for a patient who does not exist.
You query with SQL, a declarative language: you describe what you want, and the database's query planner decides how to get it. The ability to join tables and ask questions you did not plan for is the great strength of this model.
erDiagram
CLINICS ||--o{ CLINICIANS : employs
CLINICIANS ||--o{ APPOINTMENTS : has
PATIENTS ||--o{ APPOINTMENTS : books
CLINICIANS {
uuid id PK
uuid clinic_id FK
string name
}
PATIENTS {
uuid id PK
string full_name
string phone
}
APPOINTMENTS {
uuid id PK
uuid clinician_id FK
uuid patient_id FK
timestamptz starts_at
timestamptz ends_at
string status
}
Read the lines as sentences: one clinic employs many clinicians; one clinician has many appointments; one patient books many appointments. Each appointment holds only references to its patient and clinician, never copies of their details.
ACID
Relational databases are known for ACID transactions. A transaction groups several operations into one all-or-nothing unit. ACID describes its guarantees:
Letter
Meaning
Everyday analogy
Clinic example
Atomicity
All steps happen, or none do
A bank transfer: money leaves one account and arrives in the other, never one without the other
Booking an appointment and its reminder row together
Consistency
A transaction moves the database from one valid state to another, respecting constraints
A rule that says no negative stock
A foreign key violation rejects the whole booking
Isolation
Concurrent transactions do not see each other's half-finished work (to a degree set by the isolation level)
Two clerks working on separate copies of a form
A half-finished rescheduling is invisible until it commits
Durability
Once committed, the data survives crashes
The receipt is stamped and filed
A confirmed booking is still there after a power failure
NOTE
"Consistency" in ACID means that your declared rules (constraints) hold. It is a different idea from the "consistency" in the CAP theorem and in replication, which is about whether different copies agree. We untangle that in CAP and consistency.
Isolation comes in levels, such as read committed, repeatable read and serializable. Stronger levels prevent more anomalies (odd results such as reading another transaction's half-finished work) but can reduce concurrency. Defaults differ by database, so check yours. "Isolation" is a dial, not a switch.
Indexes and their cost
Imagine finding a name in a phone book with no alphabetical order. You would read every page. That is a full table scan. An index is like the alphabetical order: an extra data structure that lets the database jump straight to matching rows.
Most relational indexes use a B-tree (or a close relative). Picture a sorted, balanced tree: the top levels tell you which branch to follow, and the leaves point to the rows. Finding a value takes a handful of steps even in a table of millions of rows, because the tree is shallow and wide. Because keys are sorted, B-tree indexes also support range queries ("appointments between these dates") and ordered results.
flowchart TD
Root["Root node: A to Z"] --> N1["Node: A to H"]
Root --> N2["Node: I to P"]
Root --> N3["Node: Q to Z"]
N1 --> L1["Leaf: rows for A to D"]
N1 --> L2["Leaf: rows for E to H"]
N2 --> L3["Leaf: rows for I to L"]
N2 --> L4["Leaf: rows for M to P"]
A lookup starts at the root and picks one branch per level, so the number of steps grows very slowly as the table grows.
What an index costs
Indexes are not free. They make writes slower (every insert, update and delete also updates each affected index), they take storage, and they compete with table data for cache memory.
So index to serve your actual queries, not every column. Good candidates are columns used in WHERE filters, JOIN conditions and ORDER BY clauses on frequent queries. Poor candidates are columns with very few distinct values, or tables with heavy writes and rare reads.
Composite indexes cover several columns, and column order matters. An index on (clinician_id, starts_at) serves "this clinician's appointments for this day, in order" very well, but it usually does not help a query that filters only on starts_at, because the index is sorted by clinician_id first.
CREATE INDEX idx_appt_clinician_start
ON appointments (clinician_id, starts_at);
Use your database's explain tool (EXPLAIN in PostgreSQL and MySQL) to see whether a query uses an index rather than guessing.
TIP
When a query is slow, run EXPLAIN before adding hardware. A missing index is among the most common causes of a database bottleneck, and the fix is often a few lines (weighed against the write and storage cost above).
Normalisation versus denormalisation
Normalisation means organising tables so each fact is stored once. An appointment row stores patient_id, not the patient's name and phone. If a patient changes their phone number, you update one row and every appointment reflects it. Benefits: no duplicated data, fewer update bugs. Cost: reading often requires joins.
Denormalisation deliberately duplicates data to make reads cheaper, for example a booked_count on each clinician-day row instead of counting appointments on every page load. Benefits: faster reads. Cost: you must keep copies in sync, and a missed update means drift.
Approach
When to use
Cost
Alternative
Normalised
Data changes often, correctness matters, ad hoc queries
Joins slow down very heavy reads
Add indexes, then cache
Denormalised
Read-heavy hot paths, known access patterns, stores without joins
Duplicate data to maintain, risk of drift
Cache computed results, materialised views
A sensible path: normalise first, measure, then denormalise only the specific hot paths that need it.
Transactions in practice
A transaction protects multi-step changes. Cancelling an appointment may need to mark the slot free and queue a notification; either both happen or neither does.
BEGIN;
UPDATE appointments SET status ='cancelled'WHERE id = :appt_id;
INSERT INTO notifications (appointment_id, kind) VALUES (:appt_id, 'cancelled');
COMMIT;
If any statement fails, you issue ROLLBACK and the database undoes the earlier steps. Three practical rules:
Keep transactions short. A long transaction holds locks or old row versions, blocking others and bloating storage.
Do not call slow external services inside a transaction. Waiting on a network call while holding locks is a classic cause of stalls.
Know your concurrency problems. Two requests that both read a value and then write back can overwrite each other (a "lost update"), and two requests that both check "is the slot free?" can both say yes. Use atomic updates, row locks, constraints or version columns.
Transactions that span multiple separate databases or services are much harder; see distributed transactions later.
Connection pooling
Opening a database connection is expensive: network setup, authentication and, in some databases, a whole server process per connection. Databases also limit concurrent connections. A connection pool keeps a set of open connections that requests borrow and return, like a taxi rank instead of building a new car for each trip.
Points to know: a bigger pool is not better, because a database can only do so much in parallel and excess connections just queue inside it. The total across servers matters (20 servers with a pool of 50 means up to 1,000 connections). An external pooler such as PgBouncer for PostgreSQL multiplexes many client connections onto fewer server connections. And release connections promptly: a leak eventually freezes the app.
The NoSQL families
NoSQL is an umbrella term for databases that are not relational tables. They typically relax something (joins, strict schema, or full transactions) in return for flexibility, scale or speed on a specific pattern.
Family
Data model
Typical uses
Examples
Cost or limit
Key-value
Look up a value by a key
Caches, sessions, feature flags, counters
Redis, DynamoDB used simply
Query only by key
Document
Self-contained JSON-like documents
Catalogues, profiles, content with varied shape
MongoDB, Couchbase
Joins and cross-document transactions are limited or costly
Wide-column
Rows with flexible columns, grouped by partition key
Huge write volumes, time series, event logs
Cassandra, HBase
Tables designed around queries; ad hoc queries are weak
Graph
Nodes and relationships
Social graphs, recommendations, fraud rings
Neo4j
Poor fit for bulk analytics; harder to scale out
Many modern systems are polyglot: each part uses the store that suits it, for example patient records in PostgreSQL, sessions in Redis and scanned documents in object storage. The price is operating several systems, so add each one only when it earns its place.
WARNING
"NoSQL scales, SQL does not" is a myth. Relational databases scale very far with indexes, replicas and caching, and they provide strong guarantees that are hard to rebuild. Many NoSQL systems scale writes horizontally more easily, but you give up query flexibility and sometimes consistency. Choose based on access patterns, not fashion.
flowchart TD
S["Start with the access patterns"] --> Q1{"Strict correctness or many relationships?"}
Q1 -->|yes| REL["Relational such as PostgreSQL"]
Q1 -->|no| Q2{"Lookups only by key?"}
Q2 -->|yes| KV["Key-value store"]
Q2 -->|no| Q3{"Self-contained documents?"}
Q3 -->|yes| DOC["Document store"]
Q3 -->|no| Q4{"Huge writes with known queries?"}
Q4 -->|yes| WC["Wide-column store"]
Q4 -->|no| GR["Consider a graph store or stay relational"]
Answer from the top. Also weigh how much data and traffic you really have (see estimation) and what your team already runs well: an unfamiliar database in production is a risk of its own.
In practice: double-booking prevention for a clinic booking system
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A group of 40 clinics with about 300 clinicians takes bookings through a patient web app, a mobile app and a reception desktop tool. Around 15,000 appointments are created per day, and the appointments table holds roughly 20 million rows. A small team of five engineers runs a TypeScript API on a managed PostgreSQL instance, with Redis for sessions, Prometheus and Grafana for metrics, and Terraform for infrastructure. Health data rules apply: access must be auditable and backups encrypted.
Two things hurt. First, a few times a week two patients end up with the same clinician at the same time, found only when both arrive. Second, the clinician's day view takes 1.5 to 3 seconds because the query scans most of the table.
Starting architecture
sequenceDiagram
participant A as Patient A app
participant B as Reception tool
participant API as API servers
participant DB as PostgreSQL
A->>API: book 10:00 with Dr Rao
B->>API: book 10:00 with Dr Rao
API->>DB: SELECT overlapping rows
DB-->>API: none
API->>DB: SELECT overlapping rows
DB-->>API: none
API->>DB: INSERT booking
API->>DB: INSERT booking
Note over DB: both inserts succeed
The check-then-insert logic lives in application code. Two requests interleave, both see an empty slot, and both insert. No amount of careful coding in the API fixes a race that only the database can see.
Options considered
Option
What it fixes
What it costs
Risk
Do nothing smaller: keep the app check, add retries and a nightly duplicate report
Detects conflicts sooner
Staff apologise to patients, manual cleanup
Race remains; patients are still affected
Unique constraint on (clinician_id, starts_at)
Exact duplicates of the same start time
Cheap, simple
Misses overlaps such as 10:00 to 10:30 versus 10:15 to 10:45
Exclusion constraint on a time range per clinician, plus an index for the day view
All overlaps, enforced by the database
PostgreSQL-specific, needs a one-off data cleanup
Medium; adding it takes a strong table lock while the index builds
The decision
Field
Decision record
Context
Appointments have variable lengths, and overlaps are only detectable atomically inside the database.
Decision
Add an exclusion constraint on (clinician_id, time range) for non-cancelled rows, plus the composite index (clinician_id, starts_at). The API turns the violation into a friendly "slot just taken" response.
Consequences, good
Double booking becomes impossible, whichever client or code path creates it; the day view uses an index.
Consequences, bad
Ties the design to PostgreSQL features; writes cost slightly more; rescheduling logic must respect the constraint.
Revisit when
Booking write latency p95 exceeds 200 ms, or the business wants overlapping bookings (group sessions), which needs a different model.
CREATE EXTENSION IF NOTEXISTS btree_gist;
ALTER TABLE appointments
ADD CONSTRAINT no_double_booking
EXCLUDE USING gist (
clinician_id WITH=,
tstzrange(starts_at, ends_at) WITH&&
) WHERE (status <>'cancelled');
By default tstzrange includes the start and excludes the end, so an appointment ending at 10:30 does not conflict with one starting at 10:30.
Rollout and verification
Order matters. First run a self-join query to list existing overlaps and resolve them with the clinics, since the constraint cannot be added while violations exist. Deploy the API change that maps the exclusion violation (SQLSTATE 23P01) to an HTTP 409 first, so it is ready before the constraint is. Adding the constraint builds an index while holding a strong table lock (writes are blocked, and in PostgreSQL reads are too), so rehearse on a production-sized copy to learn how long it takes, then run it in a quiet window.
Verify with the duplicates query returning zero rows, EXPLAIN showing an index scan for the day view, day-view p95 under a target such as 200 ms, and a Grafana panel of 409 responses per hour (some are expected: they are the races now being caught).
The 2 a.m. failure
At 07:00 on a Monday, clinics open and the page reads "reschedule errors above 20 percent". The reschedule feature was implemented as insert the new slot, then cancel the old one in a second step. Moving an appointment by 15 minutes overlaps the original, which is still active, so the constraint rejects the new row.
flowchart TD
R["Reschedule 10:00 to 10:15"] --> I["INSERT new row 10:15 to 10:45"]
I --> V{"Overlaps old row 10:00 to 10:30?"}
V -->|yes| E["Exclusion violation 23P01"]
E --> X["409 returned, old row unchanged"]
Detection: the 409 rate for the reschedule endpoint spikes, and the PostgreSQL log shows conflicting key value violates exclusion constraint "no_double_booking". The first three checks: grep the database log for that message and note the endpoint, run SELECT id, starts_at, ends_at, status FROM appointments WHERE clinician_id = :id AND starts_at::date = :day; for a failing case, and check git log for the reschedule change. Mitigation: disable the reschedule button behind its flag and have reception cancel and rebook. Follow-up: rewrite rescheduling as a single UPDATE of the existing row's times inside one transaction, which the constraint checks against other rows but not itself, and add an integration test that moves an appointment into its own old time window.
Architect's notes
Put the invariant where it cannot be bypassed. If a rule must always hold ("no two appointments overlap"), enforce it in the database. Every other client, script and future service benefits automatically.
Look at the query log before indexing. Collect the top queries by total time (for PostgreSQL, the pg_stat_statements extension) and index for those, not for the ones developers remember.
Ask who can write to production data. Direct access by support or analytics tools bypasses application checks, which is another reason for constraints. Also decide who reviews migrations.
Rehearse migrations at production size. Lock duration scales with table size. A migration that is instant on a laptop can block writes for minutes in production.
Plan for the unglamorous requirements. Audit logs, backups you have restored at least once, and retention rules matter more than the choice of index in a health setting.
Defer the polyglot stack. Resist adding a document store or search engine until PostgreSQL measurably cannot serve a specific, named access pattern.
Common mistakes
Choosing a database for fashion. A trendy store may not fit your queries. Start from access patterns and the team's skills.
Check-then-insert in application code. Two concurrent requests both pass the check. Use a constraint, a lock or an atomic statement.
Missing indexes. Queries scan whole tables as data grows. Use EXPLAIN and index real filters and joins.
Too many indexes. Each one slows writes and consumes storage. Remove unused indexes.
N+1 queries. Fetching a list, then one extra query per item, multiplies round trips. Use joins or batch lookups.
Long or chatty transactions. They hold locks and slow everyone. Keep them short and free of external calls.
Unbounded connections. Autoscaled apps can overwhelm the database. Pool, cap and monitor connections.
Denormalising too early. Duplicated data drifts. Normalise first and denormalise only measured hot paths.
Key takeaways
Start relational. A relational database with good indexes serves most systems well and gives strong guarantees.
ACID protects correctness. Transactions give all-or-nothing changes, constraints, isolation and durability.
Let the database enforce invariants. Constraints close races that application code cannot.
Indexes speed reads and slow writes. Build them for real queries, check with EXPLAIN, and prune the rest.
Normalise, then denormalise on purpose. Duplicate data only for measured hot read paths and keep it in sync.
Keep transactions short. Long transactions and external calls inside them cause stalls.
Pool connections. Pools protect the database from connection storms; size them modestly.
NoSQL is a set of trade-offs. Key-value, document, wide-column and graph each fit particular access patterns.
Practice exercises
Exercise 1 — Starter (30 min): Write SQL CREATE TABLE statements (on paper or in SQLite or PostgreSQL) for patients, clinicians and appointments, with primary keys, foreign keys and a unique constraint on clinician and start time. Insert a few rows and try to break each rule.
Exercise 2 — Intermediate (2 hours): Generate a table with one million rows of fake appointments in a local database. Time a query for "clinician X's appointments on one day" without an index, then with a composite index on (clinician_id, starts_at). Compare EXPLAIN output and timings, then measure insert speed with zero, two and five extra indexes. Write down the trade-off you observe.
Exercise 3 — Advanced (half day): In PostgreSQL, reproduce the double-booking race with two concurrent sessions or a small script that checks and then inserts. Then add the exclusion constraint shown above and show that the second insert fails. Finally design the reschedule operation so it succeeds when the new time overlaps the old one, and explain how you would roll the constraint out on a live table.
Lesson 9 of 12 · System Design Foundations: From Zero to Your First Design
Understand relational databases, ACID, indexes and their costs, normalisation, transactions, connection pooling and the four NoSQL families, and how to choose.
In the rest of this lesson
Normalisation versus denormalisation
Transactions in practice
Connection pooling
The NoSQL families
In practice: double-booking prevention for a clinic booking system
Architect's notes
Enrolling is free. It unlocks every lesson in System Design Foundations: From Zero to Your First Design, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.