<< Home

Chapter 25: Database Design Patterns

Relational design patterns help you express business rules as tables, keys, and constraints. This chapter focuses on the most common relationship shapes and the practical details that keep data consistent (and queries fast). You will see how to model one to many and many to many, how to implement junction tables for flexibility, and how to plan safe schema changes with versioned migrations.

šŸ’” Think in terms of entities, attributes, and relationships. Tables represent entities; columns represent attributes; keys and constraints represent relationships and rules.

One-to-many relationships

In a one to many relationship, a single parent row is associated with zero or more child rows. Typical examples are authors to books, or departments to employees. The usual implementation is a primary key on the parent and a foreign key from the child to that parent.

Canonical layout for 1:N

-- Parent
CREATE TABLE authors (
  author_id   INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name        VARCHAR(200) NOT NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Child (many side)
CREATE TABLE books (
  book_id     INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  author_id   INTEGER NOT NULL,
  title       VARCHAR(300) NOT NULL,
  published_on DATE,
  CONSTRAINT fk_books_author
    FOREIGN KEY (author_id)
    REFERENCES authors(author_id)
    ON UPDATE CASCADE
    ON DELETE RESTRICT
);

This pattern keeps the relationship in the child, which simplifies inserts for new children and avoids nullable columns in the parent.

Choosing ON DELETE behavior

Choose a deletion rule that matches your business rules. The following table summarizes common options.

Rule Effect on children When to use
RESTRICT Deletion blocked if children exist Historical integrity (you must move or delete children first)
CASCADE Children deleted automatically Dependent data that has no meaning without the parent
SET NULL Child foreign key set to NULL Children can survive without a parent (must allow NULL)
SET DEFAULT Child foreign key set to default value Route orphans to a default record (rare but useful)
āš ļø Not all engines support every clause in exactly the same way. For example, some SQLite configurations need PRAGMA foreign_keys = ON to enforce constraints, and SET DEFAULT requires a defined default value on the column.

Indexing FOREIGN KEY columns

Always index the child foreign key column for fast joins and to avoid table scans during parent deletes or updates.

CREATE INDEX ix_books_author_id ON books(author_id);

The parent primary key is already indexed by the primary key. The child index helps both JOIN performance and referential checks.

Enforcing optional vs required relationships

Use NOT NULL on the child foreign key to make the relationship required. Allow NULL for optional relationships.

-- Required relationship
ALTER TABLE books
  ALTER COLUMN author_id SET NOT NULL;  -- Dialect may vary …

Many-to-many relationships

Many to many arises when multiple rows on each side can relate to multiple rows on the other side. Examples include students and courses, or posts and tags. Implement this with a dedicated junction table that holds pairs of foreign keys.

Canonical layout for M:N

CREATE TABLE students (
  student_id  INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  full_name   VARCHAR(200) NOT NULL
);

CREATE TABLE courses (
  course_id   INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  code        VARCHAR(20) NOT NULL UNIQUE,
  title       VARCHAR(200) NOT NULL
);

-- Junction table (no own identity needed unless you add attributes)
CREATE TABLE student_course (
  student_id  INTEGER NOT NULL,
  course_id   INTEGER NOT NULL,
  enrolled_on DATE NOT NULL DEFAULT CURRENT_DATE,
  CONSTRAINT pk_student_course PRIMARY KEY (student_id, course_id),
  CONSTRAINT fk_sc_student FOREIGN KEY (student_id)
    REFERENCES students(student_id)
    ON DELETE CASCADE,
  CONSTRAINT fk_sc_course FOREIGN KEY (course_id)
    REFERENCES courses(course_id)
    ON DELETE CASCADE
);

CREATE INDEX ix_sc_course ON student_course(course_id);

The composite primary key prevents duplicate pairs. Index both sides for symmetric join performance.

Adding attributes to relationships

Relationship attributes (for example role, grade, position) belong in the junction table because they describe the pairing.

ALTER TABLE student_course
  ADD COLUMN grade VARCHAR(2);  -- e.g., 'A', 'B', …

Querying across M:N

-- All courses for a given student
SELECT c.course_id, c.code, c.title
FROM student_course sc
JOIN courses c ON c.course_id = sc.course_id
WHERE sc.student_id = 42;

-- All students in a course
SELECT s.student_id, s.full_name
FROM student_course sc
JOIN students s ON s.student_id = sc.student_id
WHERE sc.course_id = 7;
šŸ’” If you frequently filter by both keys, keep separate single-column indexes on each foreign key. A composite index on (student_id, course_id) is already provided by the primary key; the extra index on (course_id) makes the second query efficient.

Anti-patterns to avoid

Avoid storing multiple ids in a single column (comma separated values) or variable numbers of columns (for example tag1_id, tag2_id, tag3_id). These break normalization and make queries and constraints unreliable.

Junction tables and referential integrity

Junction tables are the workhorse for flexible relationships. Keep them narrow and well indexed, then enforce integrity with keys and constraints.

Composite keys vs surrogate keys

Use a composite primary key of the two foreign keys when the pair must be unique and you rarely reference the relationship row directly. Add a surrogate key (for example id) only when the relationship itself is a first-class entity (for example you attach many child rows to the relationship or you reference it from other tables).

-- Surrogate key variant for richer relationship entities
CREATE TABLE membership (
  membership_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id       INTEGER NOT NULL,
  team_id       INTEGER NOT NULL,
  role          VARCHAR(40) NOT NULL,
  started_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT uq_membership UNIQUE (user_id, team_id),
  CONSTRAINT fk_m_user FOREIGN KEY (user_id) REFERENCES users(user_id),
  CONSTRAINT fk_m_team FOREIGN KEY (team_id) REFERENCES teams(team_id)
);

Ensuring uniqueness and preventing duplicates

Provide a uniqueness guarantee either through a composite primary key or a separate UNIQUE constraint. This avoids accidental duplicate relationships.

-- If you chose a surrogate key, add an explicit UNIQUE pair
CREATE UNIQUE INDEX ux_membership_user_team
  ON membership(user_id, team_id);

Maintaining integrity with CHECK constraints

When additional rules apply (for example role values, valid date ranges, non overlapping intervals), enforce them with CHECK constraints so the database guards your data consistently.

ALTER TABLE membership
  ADD CONSTRAINT ck_membership_role
  CHECK (role IN ('owner','admin','member'));
āš ļø Some engines have limitations on complex CHECK expressions or require enabling constraint enforcement explicitly. Verify your target engine and test migrations with real data.

Cascade rules that fit reality

In junction tables, ON DELETE CASCADE on both foreign keys is often appropriate because the relationship has no meaning if either side disappears. If junction rows carry historical facts that must remain, prefer RESTRICT with application policies that retire records instead of deleting them.

Schema versioning and migration strategies

Databases evolve. Treat schema changes as versioned, repeatable steps that can be applied forward (and sometimes backward) across environments. Your goal is reliability, auditability, and minimal downtime.

Versioning with a migrations ledger

Maintain a dedicated table that records applied migrations. Each migration has an ordered id, a description, a checksum, and timestamps.

CREATE TABLE schema_migrations (
  version      VARCHAR(50) PRIMARY KEY,  -- e.g., 20251103_1400_add_index
  description  VARCHAR(200) NOT NULL,
  checksum     VARCHAR(64) NOT NULL,
  applied_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Store migration scripts in version control with filenames such as V20251103_1400__add_index.sql. The double underscore separates the version and description; the rest is free text.

Forward-only vs reversible migrations

Forward only favors simplicity (roll forward with new scripts to fix issues). Reversible migrations include an UP and DOWN step that can revert changes when safe. Use reversible for non destructive changes; use forward only when data loss or complex transformations make reversal unsafe.

Change Safe to reverse Notes
Add column (nullable) Yes Provide DOWN to drop the column
Add index Yes DOWN drops the index
Rename column Sometimes Use two phase copy with populated values …
Drop column/table No Forward only; export data first

Zero downtime patterns

When your application must remain available, use additive, compatible changes first, followed by code rollout, then cleanup.

-- Phase 1: additive
ALTER TABLE orders ADD COLUMN order_total_cents BIGINT;

-- Backfill in batches (pseudo code shown in SQL for brevity)
UPDATE orders
SET order_total_cents = (SELECT SUM(quantity * unit_price_cents) FROM order_lines ol
                         WHERE ol.order_id = orders.order_id)
WHERE order_total_cents IS NULL
  AND order_id BETWEEN :lo AND :hi;

-- Phase 2: deploy code reading the new column
-- Phase 3: make non-nullable once all rows are backfilled
ALTER TABLE orders ALTER COLUMN order_total_cents SET NOT NULL;  -- Dialect may vary …
šŸ’” For high write volumes, backfill in id ranges or by time windows and commit in small batches. This reduces lock contention and replication lag.

Idempotent and environment aware scripts

Migrations should be idempotent where possible. Use IF NOT EXISTS guards or detect state before applying changes, then record the version in schema_migrations.

-- Example guard (dialect specific)
CREATE TABLE IF NOT EXISTS audit_log (
  audit_id    INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  actor       VARCHAR(100) NOT NULL,
  action      VARCHAR(100) NOT NULL,
  occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Data migrations with validation

Structural changes often require moving data. Pair transformation steps with validation queries so you can prove correctness before cleanup.

-- Transform
UPDATE membership SET role = 'member' WHERE role = 'basic';

-- Validate (counts must match)
SELECT COUNT(*) FROM membership WHERE role = 'basic';  -- Expect 0
āš ļø Always test migrations against a production like copy with realistic row counts. Time the operations, ensure indexes support the updates, and verify long running transactions do not exhaust resources.

Tooling and workflow

Adopt a migration tool that suits your stack (for example pure SQL runners or code based tools). Integrate migrations into continuous integration so every deployment applies the exact same steps. Gate releases on successful migration application and verification queries.

Keep every migration small, isolated, and named for its purpose. Avoid modifying old migration files after they have shipped. If you must change history, create a new migration that corrects the state.