Topic 12

Modeling for Postgres

Schema Design

Normalization is assumed knowledge here. What this topic covers is everything the engine offers after the tables are drawn: values the database computes so that no code path can get them wrong, a three-valued logic that empties a query's result set without raising anything, a physical row layout in which column order costs real disk, and the row-store shape that makes adding a column nearly free and reading two columns of forty surprisingly expensive.

Cartwheel has three of these live right now. order_items has no line total, so three code paths multiply qty by unit_price and one of them applies a promotional discount the other two have never heard of. The unique index on the new orders.public_id column currently holds four rows whose value is null, which the word "unique" was taken to rule out. And delivery_events is on its way to 1.2 billion rows in a column order that was never chosen on purpose.

Generated Columns, Stored and Virtual

A generated column is a column whose value is an expression over other columns of the same row. Postgres computes it and refuses to let anything write to it, which removes the entire class of bug where two services disagree about what a derived value means. The expression must be immutable, may not contain a subquery, may not reference another table, and may not reference another generated column.

A line total the application cannot compute differently
ALTER TABLE order_items
  ADD COLUMN line_total numeric(10,2)
      GENERATED ALWAYS AS (qty * unit_price) STORED;

-- leaving the keyword off means VIRTUAL since 18,
-- which is not what a 17-era migration meant by it
ALTER TABLE order_items
  ADD COLUMN line_total_v numeric(10,2)
      GENERATED ALWAYS AS (qty * unit_price);

The keyword at the end decides everything, and omitting it in 18 selects the opposite of what it selected before. Virtual is now the default: the value occupies no storage and is computed on every read, like a one-column view welded to the table. Stored computes the value on write and keeps it on disk. In 18 a virtual generated column cannot be indexed, and since a unique constraint is implemented by an index, it cannot be unique either; its expression is also restricted to built-in functions and types. Anything you intend to filter, sort or constrain on has to say STORED, deliberately and in writing.

The two forms also differ at the moment you add them. Adding a virtual generated column never rewrites the table — it is a catalogue entry and nothing more. Adding a stored one, which the last topic of this chapter does on a live table, rewrites the entire table and every index on it.

One keyword decides everything about a generated column
STOREDcomputed on write
Kept on disk, so it can be indexed and therefore constrained. Anything you intend to filter, sort or constrain on has to say so deliberately — and adding one rewrites the entire table and every index on it.
VIRTUALthe default since 18
No storage; computed on every read. In 18 it cannot be indexed, and therefore cannot be unique, and its expression is restricted to built-in functions and types. Adding one never rewrites the table.

NULL Is Not a Value

Null is not zero, not an empty string, and not a value at all. It is the absence of one, and comparing anything to it produces NULL rather than true or false. That third truth value propagates: qty <> 5 is NULL for a row whose qty is null, and a WHERE clause keeps only rows that evaluate to true, so those rows drop out of a predicate that reads as though it should include them.

Customers who have never ordered, according to Cartwheel
SELECT count(*) FROM customers
 WHERE id NOT IN (SELECT customer_id FROM orders);
 0

SELECT count(*) FROM orders WHERE customer_id IS NULL;
 3      -- guest checkouts from the prototype, never cleaned up

Three null rows in a 40-million-row table are enough to zero the whole answer. For any customer, NOT IN has to establish that the id differs from every value the subquery produced, and a comparison against null is unknown, so the predicate is never true for anyone. A count of zero comes back, nothing is raised, and the churn report built on it reads like a business result rather than a bug. Postgres is doing exactly what the SQL standard requires; the standard is what surprises people.

How three null rows empty a 40-million-row answer
id NOT IN (SELECT customer_id …)the predicate as written
Three of those rows are nullout of 40 million
The comparison is unknownso the predicate is never true for anyone
count = 0and the report reads like a business result

IS DISTINCT FROM compares two values treating null as an ordinary comparable, so it returns true or false and never unknown. COALESCE substitutes a real value before the comparison happens. And NOT EXISTS is the safe rewrite of NOT IN — it asks whether a matching row exists rather than whether a value equals every member of a set, so a null row simply fails to match instead of poisoning the predicate. It usually plans better too, because the planner can execute it as an anti-join.

NULL in Unique Indexes

The same rule reaches uniqueness. Two null values are not considered equal in a unique comparison, so a unique constraint permits any number of rows carrying null in the constrained column. That is standard behaviour and it is almost never what the person who typed UNIQUE had in mind.

Two spellings of uniqueness that disagree about null
CREATE UNIQUE INDEX orders_public_id_key
    ON orders (public_id);                -- any number of NULL rows allowed

ALTER TABLE orders
  ADD CONSTRAINT orders_public_id_uq
      UNIQUE NULLS NOT DISTINCT (public_id);   -- at most one NULL row

Since 15, NULLS NOT DISTINCT makes nulls compare equal for that constraint, so at most one row may be missing a value. Cartwheel's four null public_id rows arrived during the backfill that gave existing orders their customer-facing identifier: the column was nullable while the backfill ran, the unique index raised nothing, and the team read the constraint as a guarantee it had never made. The four rows are still there.

Column Order Costs Bytes

Postgres stores each fixed-width value on its natural alignment boundary: 8 bytes for bigint, timestamptz and double precision, 4 for integer and date, 2 for smallint, 1 for boolean. When a wide type follows a narrow one, the gap between them is padding — bytes written to disk, shipped to the replica, and read back into shared_buffers forever, carrying nothing.

The same four columns, two orders, two row sizes
-- 32 bytes of column data: 7 wasted after each boolean
CREATE TABLE a (flag_a boolean, id bigint, flag_b boolean, ts bigint);

-- 18 bytes of column data, rounded up to 24 by the row's own alignment
CREATE TABLE b (id bigint, ts bigint, flag_a boolean, flag_b boolean);

Declaring the wide columns first and letting the narrow ones share the tail turns 32 bytes of column data into 18, which the tuple's own alignment rounds to 24. Ordering fixed-width columns widest-first is free at design time, and fixing it afterwards is a full table rewrite. Eight bytes per row is invisible on a lookup table and it is 9.6 GB on delivery_events at 1.2 billion rows — the same 9.6 GB in every base backup, on every replica, and in the portion of the table that has to compete for cache.

Wide Tables, Narrow Reads

Postgres is a row store: the whole row lives together in one 8 KB page. A query that reads two columns of a forty-column table still pulls every one of those pages through the buffer cache, because the two columns it wants are interleaved with the thirty-eight it does not. The width of the table sets the cost of reading any part of it.

Splitting a genuinely cold set of columns into a side table keyed by the same id is a legitimate physical optimization rather than a normalization failure, and it is worth doing when the cold part is large and read rarely. Postgres already does a version of it for you — the TOAST mechanism Chapter 5 opens up — so delivery_events.payload costs less on a narrow scan than its size suggests.

Designing for the Access Pattern

Cartwheel's three interesting tables want three different physical designs from identical modeling advice. inventory is a small hot set under constant read-modify-write, so it cares about update behaviour and contention and almost nothing else. orders is insert-once, read-by-customer, with a status that changes twice early in a row's life and never again. delivery_events is append-only and scanned by time, which means it will want partitioning long before it wants a clever index.

The model is the same in all three cases: keys, foreign keys, no duplicated facts. The physical decisions are not, and a house style applied uniformly to all three guarantees that two of them are wrong. Chapter 8 chooses each table's indexes on that basis, and Chapter 11 partitions delivery_events, the one that has outgrown being a single relation.

Common Mistakes
  • Writing NOT IN (SELECT …) against a nullable column — a single null in the subquery makes the predicate unknown for every row, and the query returns an empty result with no error and no log line.
  • Reading a unique constraint as a ban on duplicate nulls — nulls are distinct from each other by default, which is how Cartwheel ended up with four "unique" orders whose public_id was missing.
  • Adding a generated column on 18 without writing STORED and then trying to index it — virtual is the default now, and the index is rejected on a column that a 17-era migration would have stored.
  • Maintaining a derived value in an ordinary column from application code — the second code path computes it slightly differently, and the two definitions of a line total diverge the first time a discount ships.
  • Declaring a 60-column table because it is "one entity" and then reading two of those columns in the hot path — every lookup drags whole pages of cold data through shared_buffers.
  • Interleaving booleans and smallints between bigints on a billion-row table — the alignment padding is invisible in the schema and permanent on disk until a full rewrite.
Best Practices
  • Let the database compute derived values with GENERATED ALWAYS AS, and state STORED explicitly whenever the value has to be indexed or constrained.
  • Rewrite every NOT IN (SELECT …) as NOT EXISTS, which is null-safe and gives the planner an anti-join instead of a subplan.
  • Declare NOT NULL wherever the business rule allows it — it removes a class of three-valued-logic surprises and tightens the planner's estimates at the same time.
  • Reach for IS DISTINCT FROM in any comparison where either side may be missing, rather than wrapping both sides in COALESCE with an invented sentinel value.
  • Order fixed-width columns widest-first on any table heading past a hundred million rows, and treat the saving as free rather than premature.
  • Design each table's physical layout for that table's dominant access pattern, and write the access pattern down next to the schema so the next person can check it still holds.
Comparable toolsMySQL generated columns, and virtual ones are indexableOracle virtual columns, computed on readSQL Server computed columns with PERSISTEDSQL standard the null semantics, identical everywhere

Knowledge Check

A migration written for Postgres 17 adds a generated column and an index on it. Replayed unchanged on 18, what happens?

  • The column is created virtual and the index on it is rejected
  • The column is created stored as before and the index is built normally
  • The column is created virtual and then promoted to stored by the index
  • The column statement fails because the storage keyword is now required

Why does NOT IN against a subquery that yields one NULL return zero rows rather than raising an error?

  • The planner rewrites the whole predicate to a constant false when it sees a null
  • A comparison against null is unknown, so no row can ever satisfy the predicate
  • The subquery is skipped entirely once a null appears anywhere in its output
  • Nulls are removed from the subquery result and the remaining set matches nothing

A unique index on orders.public_id exists, yet four rows have a null public_id. What is going on?

  • The index is invalid because the backfill was interrupted before it finished
  • Nulls are distinct from each other, so the constraint permits any number of them
  • Rows with a null key are left out of the index and therefore never checked at all
  • The constraint is validated lazily and has not yet examined the backfilled rows

On a 1.2-billion-row table, what does declaring bigint columns before boolean columns actually buy?

  • Faster comparisons, because the wide columns are reached earlier in the row
  • Eight fewer bytes of alignment padding per row, roughly 9.6 GB of storage
  • More columns eligible for TOAST, moving the boolean flags out of line
  • Smaller indexes, since index entries follow the table's column order

Cartwheel reads two columns from a forty-column table on every request. Why is that more expensive than it looks?

  • Whole rows share a page, so the pages holding all forty columns are read
  • Every column of the row is decompressed on read, including the unreferenced ones
  • The planner materializes all forty columns before discarding thirty-eight of them
  • Wide rows are split across two pages, so each read costs two page fetches

You got correct