Skip to content

    System Design

    System Design: Databases Basics

    Understand relational databases, ACID, indexes and their costs, normalisation, transactions, connection pooling and the four NoSQL families, and how to choose.

    By Published 14 min read

    Share

    This lesson is part of System Design Foundations: From Zero to Your First Design. Enroll for free, then sign in, to read it in full.

    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:

    1. Keep transactions short. A long transaction holds locks or old row versions, blocking others and bloating storage.
    2. Do not call slow external services inside a transaction. Waiting on a network call while holding locks is a classic cause of stalls.
    3. 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.

    Connection pool between app servers and the databaseApp server 1 to Pool or pooler; App server 2 to Pool or pooler; App server 3 to Pool or pooler; Pool or pooler to Database (few long-lived connections)App server 1App server 1App server 2App server 2App server 3App server 3Pool or poolerPool or poolerDatabaseDatabasefew long-lived connections

    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 NOT EXISTS 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

    Rollout order for the exclusion constraintFind overlaps to Resolve; Resolve to Deploy the API; Deploy the API to Rehearse; Rehearse to Add constraint; Add constraint to WatchFind overlaps: self-join queryFind overlapsself-join queryResolve: with the clinicsResolvewith the clinicsDeploy the API: handles conflictDeploy the APIhandles conflictRehearse: prod-size copyRehearseprod-size copyAdd constraint: in a quiet windowAdd constraintin a quiet windowWatch: conflict, latencyWatchconflict, latency

    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.

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments