Engineering Atlas › System Design Fundamentals

How relational databases model data

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Values, not pointers

Before 1970, databases such as IBM's IMS stored records in hierarchies that programs navigated by following pointers, so changing the storage layout broke every program. Edgar Codd proposed presenting data as mathematical relations: unordered sets of tuples (Codd, 1970). An order refers to its customer by holding the customer's key value, and disk layout and indexes become invisible to the query.

This physical data independence is why SQL can be declarative. A query is an expression in relational algebra (select, project, join, union, difference, aggregate); each operator takes and returns relations, so the planner can rewrite the expression into any equivalent form. Databases 101 shows tables and SQL from the user's side; this lesson looks at what the model asks of the engine.

Keys and constraints

A candidate key is a minimal set of columns that identifies a row; the primary key is the one you pick. Natural keys (an email) carry meaning but change, dragging every reference with them, so most schemas use surrogate keys (sequences, UUIDs) plus UNIQUE on the natural key. Random version 4 UUIDs land at random places in the primary-key B-tree, so every insert touches a cold page. RFC 9562 (2024) added time-ordered version 7 UUIDs partly for this reason (RFC 9562), and Postgres 18 ships a built-in uuidv7() (Postgres 18 release notes).

A foreign key says every non-null orders.customer_id must exist in customers.id, and chooses what happens when the parent is deleted:

Engines differ in what they actually enforce, so check rather than assume. MySQL parsed CHECK constraints and silently ignored them before 8.0.16 (MySQL 8.0 docs). Postgres 18 (September 2025) added temporal keys: PRIMARY KEY (room_id, valid_at WITHOUT OVERLAPS) forbids two rows for one room with overlapping time ranges, and a PERIOD foreign key checks that a child's range is covered by its parent's. It also added NOT ENFORCED for CHECK and foreign keys, which records the rule without checking it (Postgres 18 release notes).

Normalization in one example

Take order_lines(order_id, product_id, quantity, product_name, customer_email) keyed by (order_id, product_id). product_name depends on only part of the key (a second normal form violation) and customer_email depends on the order, not the line (third normal form). Rename a product and thousands of lines must change; miss one and the product has two names. Normalizing moves each fact next to its key: products(id, name), orders(id, customer_id), customers(id, email), order_lines(order_id, product_id, quantity). Copying facts back for read speed is the deliberate step covered in Denormalization.

Joins put it back together

Engines have three join algorithms:

NULL and three-valued logic

NULL means unknown, so comparisons with it yield UNKNOWN and WHERE drops the row. WHERE id NOT IN (SELECT manager_id FROM staff) returns nothing if any manager_id is null. Use NOT EXISTS.

The same shape elsewhere

Referential integrity is the multi-step consistency shape: deleting one thing must deal with everything that depends on it. Kubernetes objects carry ownerReferences, and deleting a Deployment cascades in the foreground (children first), in the background (a garbage collector follows), or orphans the children, which is SET NULL by another name.

Down the stack

Layer 1The contract

A result is a multiset of rows with no order unless you write ORDER BY. NOT NULL and CHECK are checked per row; UNIQUE and foreign keys per statement, or at commit if declared DEFERRABLE INITIALLY DEFERRED. A committed state never contains a dangling foreign key, a duplicate key or a failed CHECK; a violation aborts the statement (SQLSTATE 23503 for a foreign key, 23505 for unique), and inside a transaction the transaction must roll back. Cascading actions run inside the same statement, so they are atomic with it.

Layer 2The mechanism

Postgres implements foreign keys as internal constraint triggers. Inserting or updating a child row fires RI_FKey_check_ins or RI_FKey_check_upd, which runs roughly SELECT 1 FROM ONLY parent WHERE id = $1 FOR KEY SHARE. FOR KEY SHARE blocks deleting the parent or changing its key but not updating its other columns. Deleting a parent fires RI_FKey_cascade_del, RI_FKey_setnull_del or a no-action or restrict check, each of which queries the child table by the foreign key column.

The parent key must have a unique index, so the check is one B-tree probe. Postgres does not index the child column automatically, so each parent delete can scan the child table; InnoDB creates that index for you if none exists (MySQL docs). InnoDB's cascaded changes also do not fire triggers on the child table. UNIQUE is enforced by the unique index itself, and an insert that meets an uncommitted duplicate waits to see whether it commits.

A hash join's table is bounded by work_mem (4 MB by default) times hash_mem_multiplier (2.0 by default since Postgres 15) (Postgres docs). If the build side does not fit, Postgres splits both inputs into batches by hash, writes all but the first to temporary files, and joins batch by batch.

Layer 3The machine

A Postgres row lives in an 8 KB heap page, found through a 4-byte line pointer near the start of the page. Each tuple has a 23-byte header (Postgres docs), an optional null bitmap, then column data with each value aligned to its type's boundary. Column order therefore changes row size: (bool, bigint, bool, bigint) takes 32 bytes of data because each bigint must start on an 8-byte boundary, while (bigint, bigint, bool, bool) takes 18. With the header the tuples are 56 and 48 bytes; across 100 million rows that is about 800 MB of heap and cache. Values that push a row past about 2 KB are compressed or moved out of line by TOAST (Postgres docs).

Join memory is where the physical costs show. A hash join whose build side is 2 million rows of about 60 bytes needs roughly 120 MB plus hash overhead; with an 8 MB budget it splits into 16 or more batches and writes and rereads most of both inputs through temp files, turning a sub-second join into several seconds of disk I/O. An indexed nested loop costs a B-tree descent per outer row: a few cached page reads, or about 100 microseconds per page from NVMe.

Why it's built this way

  1. Programs that navigate storage pointers break whenever storage changes, so the model links rows by value and hides the physical layout.
  2. Hidden layout means the engine must decide how to execute queries, so a cost-based planner with statistics sits in the middle of every query.
  3. Many applications and scripts write the same data, so integrity rules live in the database, where no writer can skip them.
  4. A child insert must not race a parent delete, so the check takes a row lock on the parent; that lock is the weak FOR KEY SHARE so that ordinary parent updates are not blocked.
  5. Memory is finite and inputs are not, so joins and sorts degrade gracefully by partitioning and spilling to disk rather than failing.

Diagram

sequenceDiagram
  participant App
  participant Exec as Executor
  participant P as "projects (parent)"
  participant T as "child tables"
  App->>Exec: DELETE FROM projects WHERE id = 7
  Exec->>P: mark row 7 deleted (xmax)
  Exec->>T: RI_FKey_cascade_del: DELETE WHERE project_id = 7
  T-->>Exec: 5,000 rows (index scan, or full scan if unindexed)
  Exec->>T: no-action check on invoices referencing project 7
  T-->>Exec: none left
  Exec-->>App: DELETE 1 (cascade done in same statement)

Worked example

A project-management SaaS has projects (2 million rows), tasks (60 million rows, 7 GB, project_id REFERENCES projects ON DELETE CASCADE) and comments (300 million rows, cascading from tasks, indexed on task_id). Nobody indexed tasks.project_id.

A tenant with 10,000 projects churns. DELETE FROM projects WHERE tenant_id = 99 fires the cascade trigger 10,000 times, each running DELETE FROM tasks WHERE project_id = $1 as a sequential scan of 7 GB. At 2 GB/s that is 3.5 seconds per project, about 10 hours, in one transaction that holds locks and pins its snapshot so VACUUM stalls.

With CREATE INDEX CONCURRENTLY ON tasks(project_id), each cascade is a B-tree descent to about 30 tasks (60 million / 2 million). The whole delete removes about 300,000 tasks and 1.5 million comments: perhaps a minute, still too long for one transaction on a busy primary.

The final design deletes 100 projects per transaction, pausing between batches to keep replication lag under a second. For the largest tenants, a soft delete flag makes the user-facing action one row update, and a purge job runs the cascade off-peak.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Foreign keys in the databaseIntegrity guaranteed for every writerLock and index costs; impossible across shardsOne database owns the related tables
References checked in application codeWorks across services and shardsRaces and bugs leave orphans; needs repair jobsData split across services or stores
ON DELETE CASCADEOne statement, atomic cleanupUnbounded fan-out in one transactionSmall, bounded child sets
Soft delete plus async purgeFast user action, purge paced off-peakEvery query must filter deleted rowsLarge tenants, legal retention needs
Surrogate keysStable, compact, cheap joinsNeed a separate UNIQUE on the natural keyAlmost always
Natural keysReadable, no extra lookupKey changes cascade to every referenceTruly immutable identifiers such as ISO codes

Pitfalls in production

Leadership lens

Check yourself

  1. Why can the planner reorder joins freely in a relational database but not in a pointer-navigating one?

    Show answer

    Relational operators take and return relations and links are values, so equivalent algebra expressions give the same result regardless of order. Pointer navigation fixes the access path in the program.

  2. A DELETE on a parent table with 5,000 rows takes 20 minutes. What is the first thing you check?

    Show answer

    Whether each referencing column in child tables is indexed. Without an index every per-row foreign key action scans the whole child table.

  3. Why does Postgres lock the parent row FOR KEY SHARE instead of FOR SHARE when checking a child insert?

    Show answer

    The check only needs the key to stay put. FOR KEY SHARE blocks deletes and key changes but lets other transactions update non-key columns of the parent.

  4. Under what condition does NO ACTION succeed where RESTRICT fails?

    Show answer

    When the same statement, or with deferral the same transaction, also removes or repoints the referencing rows, because NO ACTION checks at the end rather than immediately.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →