Skip to main content
Back to module
Database Administration · Database Design

Modelling for workloads

Reading39mLesson 2 of 3 in this module

What you'll learn

  • Choose keys, types and constraints
  • Match a schema design to an OLTP, analytics or append-only workload
  • Pick exact types for money and time-zone-aware types for timestamps
  • Use NOT NULL, UNIQUE and CHECK so the database rejects bad rows

Choose keys, data types and constraints so the database itself rejects bad data for the workload it serves.

Why it matters

Application code changes every week. The database usually outlives several versions of it, plus scripts, imports and the occasional engineer typing into a console. Constraints and good types are the only rules that every writer has to obey. A DBA who models carefully gets fewer "how did this row get here?" incidents and faster queries.

Start from the workload

Before picking types, ask how the data will be used:

  • OLTP (online transaction processing): many small reads and writes, such as placing an order or updating a profile. Favour normalised tables, narrow rows, strict constraints and indexes on the lookup paths.
  • OLAP (analytics): fewer, large read queries that scan and aggregate millions of rows. Favour wide, denormalised fact and dimension tables, often in a separate warehouse, loaded in batches.
  • Write-heavy logs and events: append-only tables, often partitioned by time so old data can be dropped cheaply.

The same "orders" data can be modelled three ways depending on which of these you serve. Write down the top five queries and the expected row counts before you design.

Keys

  • Primary key: every table needs one. It must be unique, not null and stable.
  • Surrogate keys (id INTEGER / BIGINT identity, or UUID) never change meaning. Natural keys (an ISO country code, a SKU) are fine when they are truly unique and stable. Emails are a poor primary key because people change them; keep them as a UNIQUE column instead.
  • Use BIGINT for high-volume tables in PostgreSQL or MySQL: a 32-bit integer runs out at about 2.1 billion, and changing the type of a primary key on a large live table is painful.
  • Foreign keys make relationships real. Decide what happens on delete: ON DELETE RESTRICT (the safe default), CASCADE (child rows go too) or SET NULL.

Types

Pick the narrowest type that is always correct:

DataGood choice (PostgreSQL)Avoid
MoneyNUMERIC(12,2) or integer centsREAL/FLOAT (rounding errors)
Moments in timeTIMESTAMPTZtext dates, local time without zone
Calendar datesDATETIMESTAMP with fake midnight
Yes/noBOOLEAN'Y'/'N' text
Free textTEXT (with a CHECK on length if needed)arbitrary VARCHAR(255) habits
Status valuesTEXT + CHECK (status IN (...)), or an enum/lookup tableunchecked text

SQLite is different. It uses type affinity: a column declared INTEGER will happily store 'abc' unless the table is declared STRICT (SQLite 3.37+). Foreign keys are only enforced after PRAGMA foreign_keys = ON;. That is fine for a teaching sandbox, but it is exactly why server databases are stricter by default.

Constraints

Constraints are rules the database checks on every write:

SQL
CREATE TABLE members (
  member_id          INTEGER PRIMARY KEY,
  email              TEXT NOT NULL UNIQUE,
  full_name          TEXT NOT NULL,
  plan               TEXT NOT NULL CHECK (plan IN ('basic', 'pro')),
  monthly_fee_cents  INTEGER NOT NULL CHECK (monthly_fee_cents >= 0),
  joined_on          TEXT NOT NULL
);
  • NOT NULL says the value is required. Make it the default habit and allow nulls only when "unknown" is a real state.
  • UNIQUE stops duplicates (and creates an index).
  • CHECK encodes simple business rules: allowed values, ranges, end_date >= start_date.
  • DEFAULT gives a sensible value when none is supplied, such as DEFAULT CURRENT_TIMESTAMP.

Worked example: catching bad rows

With the table above, these inserts behave very differently:

SQL
INSERT INTO members (email, full_name, plan, monthly_fee_cents, joined_on)
VALUES ('ana@example.com', 'Ana Silva', 'pro', 1500, '2026-01-10');   -- ok
INSERT INTO members (email, full_name, plan, monthly_fee_cents, joined_on)
VALUES ('ana@example.com', 'Ana S.', 'basic', 500, '2026-02-01');     -- fails: UNIQUE
INSERT INTO members (email, full_name, plan, monthly_fee_cents, joined_on)
VALUES ('eve@example.com', 'Eve Ruiz', 'gold', 900, '2026-02-03');    -- fails: CHECK

In SQLite, INSERT OR IGNORE skips rows that break NOT NULL, UNIQUE, CHECK or primary key constraints instead of stopping, which makes it easy to see which rows your design rejects. (PostgreSQL has INSERT ... ON CONFLICT DO NOTHING for unique conflicts only.)

Common mistakes

  • Floats for money. 0.1 + 0.2 is not exactly 0.3 in binary floating point.
  • Everything nullable. Nulls leak into reports as blank totals and surprise NOT IN results.
  • Rules only in the app. The next import script will not know them.
  • Wide "god" tables with dozens of rarely used columns; split optional groups into their own table.
  • No plan for growth. Estimate rows per year; it changes key types and partitioning choices.

Quick recap

  • Design for the workload: OLTP, analytics or append-only logs.
  • Stable primary keys, BIGINT for big tables, foreign keys with a deliberate delete rule.
  • Narrow, correct types: exact numerics for money, time zones for timestamps.
  • NOT NULL, UNIQUE and CHECK make the database enforce the rules.

Try it

In the Do tab, write a CREATE TABLE whose constraints reject four bad rows from a given batch of inserts.

Key terms (5)

OLTP
Online transaction processing: many small reads and writes, best served by normalised tables and strict constraints.
Natural keys
Keys made from real data, such as a SKU or ISO country code; fine only when truly unique and stable.
Foreign keys
Constraints that make a column reference a row in another table, with a chosen rule for what happens on delete.
type affinity
SQLite's loose typing, where a column's declared type is a preference rather than a rule unless the table is STRICT.
CHECK
A constraint that encodes a simple business rule, such as allowed values or a minimum, checked on every write.

Recap

  • Design for the workload: OLTP, analytics, or append-only logs.
  • Use stable primary keys, BIGINT for big tables, and foreign keys with a deliberate delete rule.
  • Choose narrow, correct types: exact numerics or integer cents for money, time zones for timestamps.
  • NOT NULL, UNIQUE and CHECK make every writer obey the rules, not just the app.
  • SQLite is loose by default: STRICT tables and PRAGMA foreign_keys change that.
Also in this lesson: