Natural, surrogate, and composite keys, foreign keys, and referential integrity, where most real data bugs begin.
⏱ 31 min readTopics chapter readerLevel · Foundations
01 · Orientation
What You'll Master Here
"this value points at exactly one row." Keep that promise and the whole model stays honest.
⏱ 4 min · Topic 1 of 11
A relationship is only as trustworthy as the way you identify a row. Chapter 2 connected entities; this chapter answers the question every connection depends on: how do you point at exactly one row, now and forever? That is the job of keys.
Start with the three broken reports below: diagnose each one and the chapter routes you to the topic that repairs it. Every claim from here on is backed by something you can run, DDL, sample rows, and the exact result or error to expect.
Core mental model
A key is a promise: "this value points at exactly one row." Keep that promise and the whole model stays honest.
Why it matters
Keys are the backbone of correctness. Get identity right and joins, counts, updates, and deletes all behave; get it wrong and no amount of clever querying can recover trustworthy answers.
key
A column (or set) whose value uniquely identifies a row.
primary key
The one chosen key that officially identifies each row; unique and not null.
foreign key
A column that must match a key in another table, enforcing a relationship.
referential integrity
The guarantee that every foreign key points at a row that actually exists.
Three broken reports · one city parking-permit system
Every bug below is an identity bug. Name the defect, and the chapter tells you where it is fixed.
0/3 diagnosed
What the report shows
The dashboard shows 8,412 active permits. The permit office issued 8,190 — and can name every one of them.
Which identity defect explains it?Each defect leaves a different fingerprint: duplicates push counts up, broken references pull totals down, and a key that changes splits one thing into two. Match the direction of the error to the defect.
Where this chapter fixes itDiagnose this report correctly and the fix — and the topic that teaches it — appears here.The four ideas you will use
Key — unique, never null, so one value points at one row.
Natural vs surrogate — identify by a business value, or by an id that never changes.
Composite key — when it takes two columns together to be unique.
Foreign key — the database refusing to let a reference break.
Common mistake
Treating "id" as a formality and not thinking about identity at all. Duplicates and orphans creep in, and every downstream metric inherits the mess.
Better habit
Decide what identifies a row before writing any column.
Back every assumed key with a real uniqueness constraint.
Use foreign keys so the database enforces relationships for you.
The big idea
Identity is a decision, not an accident. A model without deliberate keys is a model that will eventually contradict itself.
Remember this
Keys are the promise that a value points at exactly one row; identity decisions made well are what keep joins, counts, and changes correct.
02 · Primary keys
Primary Keys & Candidate Keys
Candidate keys = all the columns that could identify a row. Primary key = the one you elect; the rest stay UNIQUE.
⏱ 6 min · Topic 2 of 11
A primary key is the column (or set of columns) you choose to officially identify each row. It must satisfy two rules with no exceptions: every value is unique, and no value is null. Those two rules are what let any other table point at this row unambiguously.
Often a table has more than one column that could serve. Each unique, non-null column is a candidate key. In a customers table, both customer_id and email might be unique, so both are candidates, but you pick exactly one as the primary key and keep the others enforced as unique constraints. Picking well (small, stable, never-changing) matters, which is the next section.
Test that below: tick columns from a real asset register and both rules run against every row, including the third rule people forget, that a candidate key must be minimal.
Core mental model
Candidate keys = all the columns that could identify a row. Primary key = the one you elect; the rest stay UNIQUE.
Why it matters
The primary key is the anchor every join and reference relies on. If it is not truly unique and non-null, every relationship built on it inherits the ambiguity.
candidate key
A minimal column set that is unique and non-null, eligible to be the primary key.
superkey
A unique set that still contains a droppable column; every candidate key is a superkey, but not the reverse.
primary key
The single elected candidate key; enforced as unique and not null.
unique constraint
Enforces uniqueness on a column without making it the primary key.
Declare a primary key (and keep other candidates unique)worked example
SQL
createtablecustomers(customer_idbigintgeneratedalwaysasidentityprimarykey,-- chosen PKemailvarchar(255)notnullunique,-- candidate keyfull_nametextnotnull,phonevarchar(40)-- nullable: not a key);
customer_id is the primary key; email is also unique (a candidate key) so duplicates are still impossible; phone is nullable, so it can never be a key.
The primary key rejects duplicates, by designworked example
SQL
insertintocustomers(customer_id,email,full_name)values(7,'mara@ex.com','Mara O.');-- Attempt a second row with the same primary key value:insertintocustomers(customer_id,email,full_name)values(7,'mara2@ex.com','Mara Two');-- ERROR: duplicate key value violates unique constraint "customers_pkey"
The second insert fails. That error is exactly the protection a primary key exists to give: there can only ever be one row for customer 7.
Common mistake
Choosing a primary key column that can be null. Null cannot identify a row, the database rejects it as a primary key, and relationships have nothing to point at.
Leaving other candidate keys unconstrained because there is already a PK. Duplicate emails (or SKUs) slip in even though the id is unique, recreating the duplication problem.
Better habit
List all candidate keys, then elect one primary key.
Add UNIQUE constraints to the candidate keys you did not pick.
Never allow a nullable column to act as a key.
Watch out
A primary key on its own does not stop business duplicates. If customer_id is the PK but email is unconstrained, you can still create two accounts for the same person. Constrain the natural key too.
Remember this
A primary key is one elected candidate key (unique, not null); enforce the other candidate keys as UNIQUE so business duplicates stay impossible too.
03 · The big choice
Natural vs Surrogate Keys
Surrogate key for stable identity and joins; unique constraint on the natural key for business correctness. Use both, not one.
⏱ 4 min · Topic 3 of 11
The most consequential key decision is natural vs surrogate. A natural key is a real business value that already identifies the thing, an email, an ISBN, a SKU. A surrogate key is a meaningless, system-generated id (an auto-increment integer or UUID) whose only job is to be a stable handle.
Natural keys are tempting because they carry meaning and need no extra column. But business values change, and by then the natural key has been copied into every related table as a foreign key. Throw the two events below at all three designs: only one survives both, and it is the one that uses a surrogate primary key and a unique constraint on the natural key together.
Core mental model
Surrogate key for stable identity and joins; unique constraint on the natural key for business correctness. Use both, not one.
Why it matters
Choosing a natural key as the primary key is one of the most common and most expensive early mistakes, because undoing it later means rewriting every reference. Defaulting to a surrogate plus a unique natural key avoids the whole class of pain.
natural key
A real business value that identifies the entity (email, ISBN, SKU).
surrogate key
A system-generated, meaningless id used purely as a stable handle.
business key
The natural key kept as a UNIQUE constraint alongside a surrogate PK.
The standard pattern: surrogate PK + unique natural keyworked example
SQL
createtableusers(user_idbigintgeneratedalwaysasidentityprimarykey,-- surrogate: stable handleemailvarchar(255)notnullunique,-- natural key: still uniquenametextnotnull);-- Now an email change is a one-line update that touches NOTHING else,-- because every relationship joins on the unchanging user_id.updateuserssetemail='new@ex.com'whereuser_id=7;
Best of both: joins use the stable user_id, while the UNIQUE on email still guarantees one account per person. The email can change freely.
Natural vs surrogate keys
Aspect
Natural key
Surrogate key
Stability
Can change → cascades
Never changes
Size / join speed
Often large / multi-column
Small integer, fast
Meaning
Carries business meaning (and PII)
Meaningless by design
Uniqueness
Naturally unique
Needs UNIQUE on the business key too
Common mistake
Using a mutable business value (email, phone) as the primary key. When it changes, every foreign key pointing at it must change too, an error-prone migration that creates orphans.
Adding a surrogate key but forgetting the unique constraint on the natural key. The id is unique but the business is not: you get two customer rows for the same real person.
Better habit
Default to a surrogate primary key for entities.
Always add a UNIQUE constraint on the natural/business key.
Join on the surrogate; look up and dedupe on the business key.
Production reality
Warehouses lean even harder on surrogate keys: they stay stable while source systems change ids, and they are what makes slowly changing dimensions (Chapter 11) possible. The surrogate is the durable anchor for history.
Interview note
"Surrogate primary key, with a unique constraint on the natural key" is the answer that signals you have felt the pain of a changing natural key in production.
Remember this
Prefer a surrogate primary key for stability and speed, and keep the natural key as a UNIQUE constraint; that combination gives you stable joins and business correctness at once.
04 · Hash surrogates
Hash-Based Surrogate Keys
Hash key = hash(natural key columns). Same source row → same surrogate, every load. No coordinator needed, every worker is independent.
⏱ 5 min · Topic 4 of 11
This section and the next preview how surrogate generation works in real warehouse loads — deeper water than the rest of this chapter. Read them once now, and return when you are building loads at scale.
In transactional databases an IDENTITY or SEQUENCE column generates the surrogate. Warehouse loads mostly use a deterministic hash key instead: hash the natural-key columns and the output is the surrogate. The same input always produces the same output, so the same source row gets the same surrogate on every load, with no central counter and no coordination between workers.
MD5 (128-bit, 32 hex characters) is faster and narrower, which matters for join performance in wide fact tables; SHA-256 (256-bit, 64 characters) is slower with more collision margin. Neither belongs anywhere near password hashing — MD5 is cryptographically broken — but for identifying business rows rather than guarding secrets, MD5 is standard and accepted. Say so explicitly when you discuss it.
In dbt, `dbt_utils.generate_surrogate_key(['col_a', 'col_b'])` is the standard tool: it concatenates the listed columns with a fixed delimiter (the exact character has changed across versions, so check your pinned one), coalesces NULLs to the literal string '_dbt_utils_surrogate_key_null_', then MD5-hashes the result. Build that expression yourself below, and see what each of those two decisions is protecting you from.
Core mental model
Hash key = hash(natural key columns). Same source row → same surrogate, every load. No coordinator needed, every worker is independent.
Why it matters
Hash keys are the standard surrogate strategy in modern warehouse loads because they are deterministic (idempotent reloads, no duplicate surrogates on backfill), embarrassingly parallel (no central sequence), and stable across source system changes.
deterministic hash key
A surrogate generated by hashing the natural key columns so the same input always produces the same surrogate.
hash collision
Two distinct inputs that produce the same hash output; negligible at normal volumes for MD5, vanishing for SHA-256.
generate_surrogate_key
A dbt_utils macro that concatenates, null-coalesces, and MD5-hashes listed columns to produce a stable surrogate.
MD5 vs SHA-256 for surrogate generation
Aspect
MD5 (128-bit)
SHA-256 (256-bit)
Output size
32 hex chars / 16 bytes
64 hex chars / 32 bytes
Speed
Faster (lighter compute)
Slower (~2× compute overhead)
Collision risk
Negligible (2^−128 per pair)
Vanishing (2^−256 per pair)
Crypto security
Broken — never for signatures
Secure — unneeded for surrogates
When to use
Default warehouse surrogate
Policy-mandated extra margin
Common mistake
Hashing a composite natural key without a deterministic delimiter between fields. The concatenation of ('AB', 'C') and ('A', 'BC') both produce the string 'ABC', so they hash to the same surrogate. Two completely different real-world entities collide to one key. Downstream joins silently merge distinct rows.
Assuming MD5 collisions are impossible rather than documenting the actual probability. The risk is not zero — it is 2^−128 per pair, and the birthday bound means you would need ~2^64 distinct keys before a 50% chance of any collision. At a billion rows the expected collisions are essentially zero. The real distinction to document is adversarial vs accidental: if inputs can be crafted by an attacker, MD5 is trivially breakable and a secure keyed hash is required; for accidental collisions at warehouse scale, MD5’s math holds.
Better habit
Always use a delimiter that cannot appear in your data values when concatenating multi-column natural keys before hashing — a pipe character surrounded by a control character, or hash each field separately, is safer than a bare pipe.
Coalesce NULLs to a fixed placeholder string before hashing so NULL in any column does not silently produce NULL as the surrogate.
Document whether you are using MD5 or SHA-256, and explain why — "MD5 for performance; not a security context" is the correct framing in code comments and runbooks.
Production reality
The dbt_utils.generate_surrogate_key macro is the de-facto standard for warehouse surrogate generation. It handles NULL coalescing, delimiting, and MD5 consistently across Snowflake, BigQuery, and Redshift. Reaching for it by default — rather than rolling a custom md5(concat(...)) — avoids the delimiter and NULL-handling footguns that appear in hand-rolled implementations.
Remember this
Deterministic hash keys (MD5 or SHA-256 of the natural key columns) are the standard modern warehouse surrogate strategy: idempotent, parallel, and stable. Use a safe delimiter when hashing composite keys, coalesce NULLs, and be explicit that MD5 here is for deduplication not cryptographic security.
05 · Warehouse scale
Surrogate Keys At Scale
Sequence key = one counter, many workers wait. Hash key = each worker computes independently, zero coordination. Same source data, same output, every run.
⏱ 5 min · Topic 5 of 11
Identity/sequence columns (AUTO_INCREMENT, GENERATED ALWAYS AS IDENTITY, Snowflake AUTOINCREMENT) work perfectly for transactional databases where rows are inserted one at a time or in small batches. At warehouse scale, where you load hundreds of millions of rows in parallel across many workers, they become a bottleneck.
The reason is central coordination: a monotonic sequence means every worker that needs the next id must serialize on the counter. In a distributed load with dozens of parallel tasks writing to one table, that serialization either stalls workers or forces the warehouse to assign non-sequential id blocks — which defeats the "monotonic" property you may have relied on for ordering.
Four strategies are in common use and none of them wins on every axis. Cross them below with the four questions that actually decide a load: parallelism, what a re-run does, how the key behaves in a clustered index, and what it costs per row.
Core mental model
Sequence key = one counter, many workers wait. Hash key = each worker computes independently, zero coordination. Same source data, same output, every run.
Why it matters
At warehouse scale, sequential surrogate generation is a load bottleneck. Hash keys remove the coordination point entirely, making large-scale loads parallel and idempotent — the properties that matter most in data pipelines.
sequence bottleneck
The serialization point that arises when concurrent load workers must all request the next value from a central IDENTITY/SEQUENCE counter.
idempotent load
A load that produces the same output each time it runs on the same source data. Full treatment: Data Pipeline KB, Idempotency, Retries & Exactly-Once.
UUIDv7 / ULID
Monotonic (time-sortable), globally unique identifiers that are clustered-index friendly, unlike the fully-random UUIDv4.
Sequence vs hash surrogate: DDL comparisonworked example
SQL
-- Sequence/IDENTITY surrogate: one central counter.-- Workers must coordinate on every insert; parallelism is limited.createtabledim_customer_seq(customer_skbigintgeneratedalwaysasidentityprimarykey,customer_idbigintnotnull,emailtextnotnull,unique(customer_id)-- natural key constraint);-- Hash surrogate: each row computed independently.-- Any number of workers can run simultaneously with zero coordination.createtabledim_customer_hash(customer_skchar(32)notnullprimarykey,-- MD5 hexcustomer_idbigintnotnull,emailtextnotnull,unique(customer_id));-- Load expression for dim_customer_hash (BigQuery example):-- INSERT INTO dim_customer_hash-- SELECT-- md5(coalesce(cast(customer_id as string), '_null_')) as customer_sk,-- customer_id, email-- FROM source.customers;-- Re-running this on the same source produces the exact same customer_sk.
The hash version has no INSERT-time coordination cost. Re-running the load is safe because the same source row always produces the same customer_sk — existing downstream FK references stay valid.
Common mistake
Using an IDENTITY column as the surrogate key in a large-scale dbt full-refresh model. Every full-refresh run assigns new sequence values to every row, breaking all downstream fact table references that stored the old surrogate. A hash key would have been stable across re-runs.
Choosing UUIDv4 as a clustered primary key in a row-oriented database (InnoDB, SQL Server). Fully random UUIDs cause B-tree page splits on every insert, fragmenting the clustered index and degrading both write throughput and range-scan performance. Use UUIDv7 or ULID instead.
Encoding business meaning into a surrogate key — the "smart key" or "intelligent key" anti-pattern (e.g., 'ELEC-2021-0042' to encode category=Electronics, year=2021). When the business rule changes, the "immutable" key becomes factually wrong — you either live with misleading codes forever or cascade an expensive key change across every referencing table. (See the pitfall callout below for the worked ELEC-2021-0042 case.)
Better habit
In dbt models and warehouse loads, default to hash surrogates (via generate_surrogate_key or plain md5()) rather than sequence columns — the load becomes idempotent and parallelism is free.
If you need a globally unique, human-opaque surrogate on a row-store table with a clustered index, reach for UUIDv7 or ULID rather than UUIDv4.
Keep the natural/business key as a UNIQUE constraint even when the surrogate is a hash — it is the only human-interpretable key and the guard against duplicates in source data.
Keep surrogates completely meaningless — never embed category codes, year prefixes, or any other business signal in the key. Business attributes change; a key that encodes them will eventually lie.
Production reality
Every major warehouse-native transformation tool (dbt, Dataform) defaults to hash surrogates precisely because of idempotency and parallelism. The generate_surrogate_key macro is the standard expression of this pattern. If a data pipeline job can be safely re-run and produce the same dimension table with the same keys, that stability is almost always built on deterministic hashing, not sequences.
Smart keys break when business rules change
A smart key (or intelligent key) embeds business meaning directly in the surrogate — for example, a product key like 'ELEC-2021-0042' that encodes category and year. It feels convenient until the business recategorizes the product or the year rolls. Now the "immutable" key is factually wrong, and changing it requires updating every foreign key reference in every fact table. Keep surrogates meaningless. Business attributes belong in their own columns, where they can change freely without touching the key.
Interview note
"Why do warehouse loads use hash surrogates instead of sequences?" is a standard senior interview question. The complete answer covers three properties: (1) no central counter so the load is embarrassingly parallel, (2) same input produces the same key so backfills and full-refreshes are idempotent, and (3) the surrogate is stable across source system changes because it depends only on the business key columns.
Remember this
Hash surrogates remove the sequence coordination bottleneck, making warehouse loads parallel and idempotent. Prefer them in bulk-load pipelines; use UUIDv7/ULID over UUIDv4 when a globally unique key is needed on a clustered-index table.
06 · Composite keys
Composite Keys: When One Column Is Not Enough
When one column repeats by design, the key is the combination of columns that together are unique, and that combination is the grain.
⏱ 5 min · Topic 6 of 11
Sometimes no single column identifies a row, and it takes a combination. That is a composite key: a primary key made of two or more columns together. The classic case is the bridge table from Chapter 2, and any line-item table, where one column repeats by design.
Take order_items. The same order_id appears on every line of an order, and the same sku appears across many orders, so neither alone is unique. But the pair (order_id, sku) is unique: an order has each product at most once. That pair is the correct composite key, and it also declares the grain (one row per product per order).
Getting it wrong fails in two opposite directions, and the simulator below runs both: a key that is too narrow refuses a legitimate row, while a key with an extra column bolted on accepts a duplicate. Only the correct pair does neither, which is why choosing the composite key is really choosing the grain, the subject of the next chapter.
Core mental model
When one column repeats by design, the key is the combination of columns that together are unique, and that combination is the grain.
Why it matters
Picking the wrong key on a line-item or bridge table either allows true duplicates or forbids legitimate rows. The composite key encodes exactly what "one row" means here.
composite key
A primary key made of two or more columns that are unique only in combination.
line-item grain
One row per item within a parent (one row per product per order).
key check
GROUP BY the candidate columns and look for any count greater than one.
A composite primary key on a line-item tableworked example
SQL
Input data
order_items3 rows
order_id
sku
qty
unit_price
1001
A1
1
40.00
1001
B7
2
20.00
1002
A1
1
40.00
order_id 1001 appears twice (two products); sku A1 appears twice (two orders). Only the pair (order_id, sku) is unique.
createtableorder_items(order_idbigintnotnullreferencesorders(order_id),skutextnotnull,qtyintnotnullcheck(qty>0),unit_pricenumeric(10,2)notnull,primarykey(order_id,sku)-- the PAIR is unique, neither column alone is);
The composite key (order_id, sku) allows an order to have many items and a product to be in many orders, while forbidding the same product twice on one order.
Common mistake
Forcing a single-column key onto a line-item or bridge table. Either you block legitimate rows or you add a meaningless id and lose the uniqueness rule the pair was enforcing.
Adding a surrogate key but dropping the unique constraint on the composite business key. The same product can now be added twice to one order; keep the composite as UNIQUE even if you add a surrogate.
Better habit
When a column repeats by design, look for the unique combination.
Verify a key with a GROUP BY ... HAVING count(*) > 1 check.
If you add a surrogate, keep the composite as a unique constraint.
Key = grain
Choosing (order_id, sku) is the same act as declaring "one row per product per order". The key and the grain are two views of the same decision, which is why grain is the next chapter.
Remember this
When no single column is unique, the key is the combination that is, usually on bridge and line-item tables, and that combination literally defines the grain.
07 · Foreign keys
Foreign Keys & Referential Integrity
"this value exists over there." The database refuses any write that would break the promise.
⏱ 6 min · Topic 7 of 11
A foreign key is how a relationship from Chapter 2 becomes real and enforced. It is a column whose value must match a key in another table. orders.customer_id is a foreign key referencing customers.customer_id: it says "every order must belong to a customer that actually exists."
That guarantee is called referential integrity, and the database enforces it on every write: an order for a customer who does not exist is refused, and so (by default) is deleting a customer who still has orders. Without it you get orphans, child rows pointing at parent ids that are not there, which break joins and counts without ever raising an error. Load the batch below both ways and reconcile the two totals.
Analytical warehouses break this contract silently: BigQuery, Snowflake, and Redshift accept PRIMARY KEY, FOREIGN KEY, and UNIQUE declarations but do NOT enforce them at write time. Each warehouse phrases the trust differently — Redshift treats constraints as always informational only; Snowflake requires an explicit RELY (default NORELY) before the optimizer trusts a constraint; BigQuery requires the NOT ENFORCED qualifier (ENFORCED is not supported). In every case the optimizer uses the declaration as a hint — eliminating joins, assuming uniqueness — based purely on what you declared. It trusts you blindly.
The danger is that if you declare RELY or NOT ENFORCED on a key that is not actually unique — because your load pipeline has a bug, a late-arriving duplicate, or a missed deduplication step — the optimizer produces wrong query results with no error. Aggregates double-count; joins collapse; group-bys return phantom rows. You get a number, not an exception. This is one of the highest-impact silent footguns in analytical modeling and is exactly what separates mid-level from senior warehouse modelers in interviews.
Core mental model
A foreign key is an enforced promise: "this value exists over there." The database refuses any write that would break the promise.
Why it matters
Referential integrity is the difference between a model that stays consistent automatically and one where orphaned rows accumulate until reports quietly go wrong. The database does the policing if you let it.
foreign key
A column required to match a key in another (parent) table.
parent / child
The referenced table is the parent; the table holding the foreign key is the child.
orphan row
A child row whose foreign key points at a parent that does not exist.
Declare the foreign key (the relationship, enforced)worked example
NOT NULL plus REFERENCES means every order must point at a real customer, no orphans, no missing links. This is the Chapter 2 "places" relationship made physical.
Warehouse footgun: RELY on a non-unique key → silent wrong aggregate (Snowflake)worked example
SQL
Input data
dim_products (after buggy load)2 rows
product_id
product_name
category
42
Widget Pro
Electronics
42
Widget Pro
Electronics
product_id 42 is duplicated. RELY told the optimizer to assume uniqueness. No constraint error was raised on insert.
fact_sales2 rows
sale_id
product_id
revenue
s-1001
42
150000.00
s-1002
42
90000.00
True revenue for product 42: 150000 + 90000 = 240000.00.
-- dim_products in Snowflake, declared with a RELY primary key.-- Your load pipeline has a bug: product_id 42 was inserted TWICE.createtabledim_products(product_idintegernotnull,product_nametextnotnull,categorytextnotnull,constraintpk_dim_productsprimarykey(product_id)rely-- ^ RELY tells Snowflake: "trust that product_id is unique"-- ^ Snowflake does NOT verify this. It is your promise.);-- The buggy load inserted:-- (42, 'Widget Pro', 'Electronics')-- (42, 'Widget Pro', 'Electronics') <-- exact duplicate, no error raised-- A query that looks correct:selectp.category,sum(f.revenue)astotal_revenuefromfact_salesfjoindim_productsponp.product_id=f.product_idgroupbyp.category;
Wrong result (executed join fanned out on the duplicate dim row)
category
total_revenue
Electronics
480000.00
Correct answer is 240000.00. Each fact row matched the duplicated dim row twice, so every sale was counted twice: (150000 + 90000) × 2 = 480000. No error, no warning — just a wrong number.
Wrong RELY → silent wrong answer, not an error. The optimizer assumed product_id was unique and may have trusted that assumption in its join plan. The duplicate dimension row fanned out the fact rows. Always verify uniqueness with a duplicate check before declaring RELY or NOT ENFORCED.
Common mistake
Skipping foreign keys "for performance" or convenience. Orphan rows accumulate undetected; joins silently drop them and metrics drift without any error.
Making a foreign key nullable when the relationship is mandatory. You allow orders with no customer, exactly the orphan state the foreign key was meant to prevent.
Declaring RELY or NOT ENFORCED on a primary or unique key without first verifying uniqueness in the actual data. The warehouse query optimizer trusts the declaration and may produce silently wrong aggregates — doubled revenue, undercounted users, phantom groups — with no error message. The duplicate rows are invisible until someone audits the numbers against a ground truth.
Better habit
Implement every mandatory relationship as a NOT NULL foreign key.
Let the database enforce integrity instead of hoping the app does.
Read foreign keys as the documented map of how tables connect.
Before declaring RELY (Snowflake) or NOT ENFORCED (BigQuery) on any key, run the GROUP BY duplicate check from the "Keys In Practice" section and confirm zero violations. Schedule it as a recurring data-quality test.
Production reality
Some high-scale or warehouse systems relax foreign keys for load performance, then must enforce integrity in pipeline checks instead. The guarantee still has to live somewhere; turning off the FK does not remove the need, it moves it.
The RELY / NOT ENFORCED footgun
BigQuery, Snowflake, and Redshift do not enforce PRIMARY KEY or FOREIGN KEY constraints at write time. They are optimizer hints. Snowflake's RELY and BigQuery's NOT ENFORCED tell the planner "assume uniqueness / referential integrity" without checking. A duplicate row in a dimension table declared RELY causes the optimizer to fan out fact rows in a join — you get a wrong aggregate, no exception. Treat RELY/NOT ENFORCED like a contract: only declare it after you have proven the guarantee holds, and re-verify it on every load.
Interview note
"Do warehouses enforce foreign keys?" is a senior-filter question. The complete answer: Redshift constraints are always informational only; Snowflake uses RELY (default NORELY) as an optimizer hint; BigQuery declares constraints NOT ENFORCED and the optimizer may use them. None enforce on write. A wrong RELY declaration produces wrong results silently — that is the footgun to name explicitly.
Remember this
A foreign key turns a relationship into an enforced promise that the referenced row exists; referential integrity is what makes joins and counts trustworthy by construction.
08 · Integrity actions
What Happens When You Delete A Parent
RESTRICT (block), CASCADE (delete children too), or SET NULL (keep children, drop the link). Pick by who owns whom.
⏱ 5 min · Topic 8 of 11
Referential integrity raises a question: if an order must belong to a customer, what happens when you delete a customer who has orders? You decide, in advance, with a referential action on the foreign key. There are three you will use.
You decide in advance, with a referential action on the foreign key: RESTRICT blocks the delete while children exist, CASCADE deletes the children too, and SET NULL keeps them while clearing the link. Delete a parent below under each rule and watch how far the deletion travels.
The right choice depends on meaning. Cascade suits truly owned children (delete an order, delete its order_items). Restrict suits records you must not lose by accident. Set null suits optional links. Choosing on purpose is how you avoid both surprise data loss and unexpected orphans.
Core mental model
On delete: RESTRICT (block), CASCADE (delete children too), or SET NULL (keep children, drop the link). Pick by who owns whom.
Why it matters
The referential action is a decision about data loss. Defaulting to cascade carelessly can erase far more than intended; never setting one can leave you unable to delete anything. It deserves a deliberate choice per relationship.
referential action
The rule (RESTRICT/CASCADE/SET NULL) applied to children when a parent is deleted or updated.
cascade
Automatically delete (or update) child rows along with the parent.
ownership
Whether a child only exists as part of its parent; the basis for choosing cascade vs restrict.
The three actions, side by sideworked example
SQL
-- RESTRICT: cannot delete a customer who still has orders.createtableorders(order_idbigintprimarykey,customer_idbigintnotnullreferencescustomers(customer_id)ondeleterestrict);-- CASCADE: deleting an order deletes its items automatically (items are owned).createtableorder_items(order_idbigintnotnullreferencesorders(order_id)ondeletecascade,skutextnotnull,primarykey(order_id,sku));-- SET NULL: keep the order, but clear the link (FK must be nullable).createtableorders_optional(order_idbigintprimarykey,promo_idbigintreferencespromos(promo_id)ondeletesetnull);
Each action expresses ownership. order_items are owned by an order (cascade); an order must not silently disappear with its customer (restrict); a promo is an optional tag (set null).
Choosing a referential action
Action
Effect on children
Use when
RESTRICT
Delete is blocked
Children must not be lost by accident (default)
CASCADE
Children are deleted too
Children are truly owned (order → order_items)
SET NULL
Children kept, link cleared
The link is optional (FK is nullable)
Common mistake
Using ON DELETE CASCADE without thinking through the blast radius. Deleting one parent silently erases mountains of related data that other things still needed.
Leaving no action and being unable to delete anything cleanly. Routine deletes fail with FK errors and people work around integrity with risky manual fixes.
Better habit
Choose a referential action deliberately for every foreign key.
Use CASCADE only for children that are truly owned by the parent.
Default to RESTRICT when unsure; it fails safe.
RESTRICT vs NO ACTION
They look identical and differ in timing. RESTRICT checks immediately and cannot be deferred; NO ACTION (the SQL default) checks at the end of the statement, and if the constraint is DEFERRABLE, at the end of the transaction. That gap is what lets you delete a parent and re-point its children within one transaction.
Remember this
Decide per foreign key what happens to children when a parent is deleted, RESTRICT to fail safe, CASCADE for owned children, SET NULL for optional links, never by accident.
09 · Applied method
Keys In Practice: Verifying & Choosing
surrogate PK for joins, unique business key for truth.
⏱ 4 min · Topic 9 of 11
Knowing the types of keys is not enough; you have to verify identity in real, messy data. The single most useful skill is the duplicate check: before you trust a column as a key or add a unique constraint, group by it and look for counts above one. Audit three columns below, and note that reading the result takes judgment the query cannot supply.
Then think ahead about durability. Because surrogate keys never change, they are the anchor that lets you keep history: the same donor keeps one durable id even as their donor number, centre, and email change. That is the foundation of slowly changing dimensions in Chapter 11; here, just internalize that a stable key is what makes tracking change possible at all.
Core mental model
Trust no assumed key until a GROUP BY proves it. Then enforce it: surrogate PK for joins, unique business key for truth.
Why it matters
A key is only real if the data actually obeys it. Verifying uniqueness and choosing stable keys is the practical work that turns key theory into a model that stays correct over time.
duplicate check
GROUP BY a candidate key with HAVING count(*) > 1 to find violations before enforcing it.
durable key
A stable identifier (surrogate) that stays constant so history can be tracked against it.
enforce
Turn an assumed key into a real PRIMARY KEY or UNIQUE constraint the database guarantees.
The default entity pattern: surrogate PK + unique business keyworked example
SQL
createtabledonors(donor_idbigintgeneratedalwaysasidentityprimarykey,-- stable join keyemailvarchar(255)notnullunique,-- enforced business keydonor_notextunique,-- second candidate: unique, but nullablecentre_codetextnotnull,created_attimestamptznotnulldefaultnow());-- Joins use donor_id forever; the email and the centre can change without breaking-- anything; and the durable donor_id is what lets us track history later.
This single pattern covers the vast majority of entity tables: stable identity, enforced business uniqueness, and a durable anchor for future history (Chapter 11). donor_no shows the other half of the audit above, a column UNIQUE accepts and PRIMARY KEY could not, because it is allowed to be missing.
Common mistake
Adding a unique constraint without checking for existing duplicates. The constraint creation fails on real data, or worse, you assumed uniqueness in queries that was never true.
Reusing or recycling surrogate ids. History and references silently point at the wrong entity; a durable key must never be reassigned.
Better habit
Run the GROUP BY duplicate check before trusting any key.
Default every entity to surrogate PK + unique business key.
Keep surrogate ids stable and never recycle them.
Interview note
When asked "how do you know this is the key?", answer with the check: "I would group by it and confirm no group has more than one row." That is the practical, senior answer.
Production reality
Duplicate-key incidents are a top cause of inflated metrics. A scheduled "GROUP BY key HAVING count(*) > 1" data-quality test catches them before a dashboard does (Chapter 21).
Practice key decisions in the ERD studio
Add primary keys and foreign keys to your ERD in the studio, then run Start review — it flags any entity missing a primary key or leaning on a mutable natural key without a surrogate.
Verify every key with a duplicate check, then enforce a surrogate PK plus a unique business key; stable, verified keys are what keep a model correct as data and history grow.
10 · Practice
Practice Lab
Say what one row means before you draw the second table. Everything else in a review follows from that sentence.
⏱ 3 min · Topic 10 of 11
Five design scenarios for this chapter, on the ERD canvas. Each one is graded against a real rubric, seeded with real rows, and each has one fault planted in the data rather than in the diagram.
Build them with the chapter closed. If one goes wrong, come back to the section it belongs to rather than re-reading the whole thing.
Core mental model
Say what one row means before you draw the second table. Everything else in a review follows from that sentence.
Why it matters
Reading about a grain mistake and watching one inflate a number you produced are different memories. The second is the one still there under interview pressure.
Common mistake
Revealing the reference model before your own review comes back. You see what correct looks like without finding out what your version got wrong, and your version is the one you will draw again under pressure.
Better habit
Write the three questions the model must answer before drawing a single table.
State the grain of every table out loud. If the sentence needs an "and", you have found a composite key.
Run the review, fix what it finds, and run it again. The second score is the one that means something.
Each scenario compiles your canvas to DDL, seeds correlated rows, and runs acceptance and anomaly checks against them. A finding comes with the query that produced it and the rows it returned.
Remember this
A model you have defended against seeded data is worth more than three you have only drawn.
11 · Next Chapter
Next Chapter
You can now identify a row precisely, and you saw that choosing a composite key is really choosing what one row represents. That idea, the grain, is so central it gets its own chapter.
⏱ 3 min · Topic 11 of 11
Next chapter
Grain: What One Row Means
You can now identify a row precisely, and you saw that choosing a composite key is really choosing what one row represents. That idea, the grain, is so central it gets its own chapter.
Chapter 4 covers declaring grain explicitly, spotting mixed-grain tables, and why getting the grain wrong is the most common silent cause of double-counted, untrustworthy metrics.