Modeling for Postgres
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.
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.
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.
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.
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.
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.
-- 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.
- 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_idwas missing. - Adding a generated column on 18 without writing
STOREDand 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 betweenbigints on a billion-row table — the alignment padding is invisible in the schema and permanent on disk until a full rewrite.
- Let the database compute derived values with
GENERATED ALWAYS AS, and stateSTOREDexplicitly whenever the value has to be indexed or constrained. - Rewrite every
NOT IN (SELECT …)asNOT EXISTS, which is null-safe and gives the planner an anti-join instead of a subplan. - Declare
NOT NULLwherever 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 FROMin any comparison where either side may be missing, rather than wrapping both sides inCOALESCEwith 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.
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