Natural, surrogate, and composite keys, foreign keys, and referential integrity, where most real data bugs begin.
⏱ 21 min readTopics chapter readerLevel · Foundations
01 · Overview
Start here
⏱ 7 min · Topic 1 of 15
Every table needs a way to point at exactly one row. That way is a key. A key is a column, or a group of columns, whose value finds one row.
In chapter 2 you drew entities and the relationships between them. This chapter makes those relationships real. You will choose keys that stay correct when the data changes.
The shop again
This chapter uses the shop from the SQL chapters, and the SQL examples use PostgreSQL. One thing is new: customers now has an email column. Here is all the data.
customers (5 rows)
customer_id
email
name
country
1
asha@example.com
Asha
IN
2
ben@example.com
Ben
GB
3
carla@example.com
Carla
US
4
dev@example.com
Dev
NULL
5
omar@example.com
Omar
IN
orders (6 rows)
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
order_items (7 rows)
order_id
product
quantity
101
Pen
2
101
Notebook
1
102
Bag
1
103
Lamp
1
104
Pen
3
106
Chair
1
106
Lamp
1
What you will learn
What a primary key is, and how to check one.
Natural and surrogate keys, and why most tables use both.
What breaks when a business id changes, repeats or is reused.
Why a surrogate key stays private.
Composite keys, foreign keys and delete rules.
How one person with many ids becomes one identity.
02 · Primary keys
Primary and candidate keys
⏱ 11 min · Topic 2 of 15
You met primary keys in the SQL chapters. Here you check one.
A primary key is the column that officially identifies each row. It follows two rules. Every value is unique, and no value is NULL.
NULL means a value is missing.
In orders, order_id is the primary key. Here is the table in SQL:
ERROR: duplicate key value violates unique constraint "orders_pkey"
DETAIL: Key (order_id)=(101) already exists.
Order 101 is already in the table, so the insert fails and no row is added.
This error is the point of a key. There can be only one row for order 101.
Candidate keys
Sometimes more than one column could be the key. Each one is a candidate key. In customers, customer_id and email are both unique, so both are candidates.
You choose one candidate as the primary key. You give the others a UNIQUE rule. A UNIQUE rule refuses duplicates, but it is not the primary key.
A column that repeats today cannot be a key. Find the countries that more than one customer has. HAVING keeps only the groups that pass its test.
Asha and Omar both have IN. Dev has NULL, so country cannot identify him either.
Watch out
A primary key does not stop duplicate people. Two rows can still share one email. Add UNIQUE on email. In PostgreSQL, a UNIQUE column may hold NULL many times. A primary key may not.
03 · The big choice
Natural and surrogate keys
⏱ 8 min · Topic 3 of 15
A natural key is a real value from the business. An email, a passport number and a product code are natural keys. People already know them.
A surrogate key is an id that the database makes. Surrogate means a substitute. It stands in for the real value and has no meaning, like 1, 2, 3.
Compare the two kinds for one customer:
Natural key
Surrogate key
Example
email: asha@example.com
customer_id: 1
Made by
The customer or the business
The database
Meaning
Has meaning
No meaning
Can it change?
Yes. Asha can get a new email.
It should never change.
Most tables use both. The surrogate key is the primary key, and the natural key gets a UNIQUE rule.
Here is customers built that way:
customers: surrogate key plus unique emailworked example
Asha is customer 1. Joins use this number, and the number never needs to change.
Watch out
A surrogate key alone does not know who is who. Without UNIQUE on email, you can insert asha@example.com again. The same person then has two ids, such as 1 and 6.
04 · Why surrogate
When a business value changes
⏱ 8 min · Topic 4 of 15
A natural key looks safe until its value changes. Asha gets a new email. We test two designs.
A foreign key is a column that must match a key in another table.
ERROR: update or delete on table "customers" violates foreign key constraint "orders_customer_email_fkey" on table "orders"
DETAIL: Key (email)=(asha@example.com) is still referenced from table "orders".
The foreign key says every order email must match a customer email. Orders 101 and 102 still use the old one, so the update is refused.
You must now change the email in customers and in orders together. Every other table that stores the email needs the same change. Miss one, and its rows no longer match the customer.
In the second design, orders stores customer_id. The email lives in one place only.
One row changed in one table. Orders 101 and 102 still point at customer 1.
The UNIQUE rule on email still works. Asha can have the new email, but nobody else can take it.
Interview question
“Why use a surrogate key?” Answer: a natural key can change, repeat or be reused. It is also copied into every table that points at it. A surrogate key has no meaning and never changes. Keep a UNIQUE rule on the natural key so duplicates still fail.
05 · Why surrogate
Reused and repeated ids
⏱ 10 min · Topic 5 of 15
A business value can break in two more ways. It can be given to a new person. Or two systems can use the same value for different people.
A reused id
A source system deletes Dev. Later it gives his number, 4, to a new customer called Fay. Your copy of order 106 still says customer_no 4.
Order 106 now shows the wrong customerworked example
The pair stays unique. Every other table joins on customer_key. This works while a source never reuses an id. If it does, close the old row, for example with a valid_to column.
06 · Optional
Keep surrogate keys private
⏱ 8 min · Topic 6 of 15
You can skip this section on a first read.
A surrogate key is an id for inside use. Keep it inside your system. Do not hand it to customers or partners as the id they use.
If you share it, it becomes a business key. You can then never change it and never reuse it. There are three reasons not to share it.
Other people copy it. A customer writes “order 101” in an email. A partner saves it in a file. You cannot change it now.
It changes if you reload the table (see the reload section). The database can give out different numbers.
It is easy to guess. If the page /orders/101 works, someone will try /orders/102. Guessable ids can leak data if the page does not check who is asking.
Give the outside world its own id. It has one job: to be seen outside.
A UUID is a long random id. gen_random_uuid() makes one, in PostgreSQL 13 and later. DEFAULT fills it for every row. Add a public id to orders:
Nobody can guess the next one. If you must change it, order_id stays the same.
Never reuse a surrogate id. PostgreSQL never gives out a used number again, unless you reset it on purpose. Even a failed insert uses a number.
Gaps in the numbers are normal. Do not fill them.
Watch out
Do not put meaning inside a key. A product key like ELEC-2021-0042 says “electronics, year 2021”. When the product moves to another category, the key is wrong. Keep the category in its own column.
07 · Composite keys
Composite keys
⏱ 7 min · Topic 7 of 15
Sometimes no single column is unique. Look at order_items. Order 101 appears twice, and Pen appears twice.
The pair (order_id, product) is unique. An order has each product at most once.
You saw a composite key in the Mia example. This pair is another one.
Order 101 already has a Pen row. To change the quantity, update that row.
The key also says what one row means. Here, one row is one product in one order. This is the grain, and chapter 4 explains it fully.
Watch out
Do not add extra columns to the key. If the key is (order_id, product, quantity), the row (101, Pen, 5) is allowed next to (101, Pen, 2). Order 101 now has Pen twice, and no error warns you.
08 · Foreign keys
Foreign keys
⏱ 8 min · Topic 8 of 15
You met the foreign key in the SQL chapters. Now we give it a rule.
The table with the foreign key is the child. The table it points to is the parent. Here orders is the child and customers is the parent.
Each order points at one customer. The two customer_id values match:
order_id
customer_id in orders (foreign key)
customer_id in customers (primary key)
name
101
1
1
Asha
102
1
1
Asha
103
2
2
Ben
104
3
3
Carla
105
3
3
Carla
106
4
4
Dev
The foreign key says every order belongs to a customer who exists. Here is orders again, with the link added:
ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey"
DETAIL: Key (customer_id)=(9) is not present in table "customers".
Customers 1 to 5 exist. There is no customer 9, so the order is refused.
Without a foreign key you can get an orphan. An orphan is a child row whose parent does not exist. Joins drop orphans without any error, so totals quietly go wrong.
The check works in the other direction too. Try to delete Asha:
No delete while children existworked example
SQL
Input data
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
DELETEFROMcustomersWHEREcustomer_id=1;
Error from PostgreSQL
message
ERROR: update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders"
DETAIL: Key (customer_id)=(1) is still referenced from table "orders".
Orders 101 and 102 belong to customer 1, so Asha cannot be deleted. Omar has no orders and could be.
09 · Delete rules
What a delete does
⏱ 8 min · Topic 9 of 15
The last delete was refused. You can choose a different rule for each foreign key.
The rule is called a referential action. There are three:
Rule
What happens to the child rows
Use it when
RESTRICT
The delete is refused.
You must not lose the children by accident.
CASCADE
The delete flows down to the children.
The children only exist as part of the parent.
SET NULL
The children stay. The link becomes NULL.
The link is optional.
If you set no rule, the default is NO ACTION. Here it acts like RESTRICT.
The coupon_id column is new. It is only for this example. Here is each rule in SQL:
The three rulesworked example
SQL
-- RESTRICT: a customer with orders cannot be deleted.customer_idBIGINTNOTNULLREFERENCEScustomers(customer_id)ONDELETERESTRICT-- CASCADE: deleting an order deletes its items too.order_idBIGINTNOTNULLREFERENCESorders(order_id)ONDELETECASCADE-- SET NULL: deleting a coupon keeps the order. coupon_id must allow NULL.coupon_idBIGINTREFERENCEScoupons(coupon_id)ONDELETESETNULL
These lines go inside CREATE TABLE. Order items belong to an order, so CASCADE fits. An order should not vanish with its customer, so RESTRICT fits.
First see SET NULL. Orders 101 and 102 have coupons. Delete coupon 7:
The two rows of order 101, Pen and Notebook, are gone. Seven rows became five.
Watch out
CASCADE can delete far more than you expect. If orders cascaded from customers, deleting one customer would erase all their orders and items. Use CASCADE only for rows that belong to the parent. When you are unsure, use RESTRICT.
10 · In practice
Check keys in real data
⏱ 6 min · Topic 10 of 15
Real data is messy. Never trust a key until you check it. The check is the same each time: group by the key, and look for any count above one.
Each order matched Asha twice. The true total is 40.00 + 25.00 = 65.00, and the report shows twice that.
Watch out
A warehouse is a database built for reports. Warehouses often do not enforce keys. Snowflake, BigQuery and Amazon Redshift accept PRIMARY KEY and FOREIGN KEY lines but do not check them on write. Some can use the declared key to skip work. If the key is false, the answers can be wrong. Run the check on a schedule.
Remember
A primary key is unique and never NULL. Use a surrogate primary key plus UNIQUE on the natural key. Use a composite key when one column is not enough. Use a foreign key so a child always has a parent.
11 · Optional
Surrogate keys at scale
⏱ 6 min · Topic 11 of 15
You can skip this section on a first read.
A warehouse reloads data often. An IDENTITY column gives numbers in the order rows arrive. A reload can give the same row a different number.
Here customers_dim holds yesterday’s rows. A dimension table is a table of descriptions, such as customers.
staging_customers holds today’s load. The rows arrive in the other order:
staging_customers (today)
email
ben@example.com
asha@example.com
TRUNCATE empties the table, and RESTART IDENTITY sets the numbers back to 1. Then INSERT ... SELECT copies the staging rows in:
The query has no ORDER BY, so row order is not promised. A table that saved customer_key 1 for Asha now points at Ben.
No error appears. The reports just show the wrong person. A key made from the row itself fixes this, and the next section builds one.
12 · Optional
Hash keys from natural keys
⏱ 8 min · Topic 12 of 15
You can skip this section on a first read.
A hash key avoids renumbering. A hash function turns any text into a short code. The same text always gives the same code. md5 gives 32 letters and numbers.
In PostgreSQL, || joins text together. ::text turns a number into text. So order_id::text || '|' || product makes 101|Pen.
Build a key for each row of order_items from its natural key, the pair (order_id, product):
The same key as before, because neither part is NULL. Also keep the UNIQUE rule on the natural key.
In dbt
The dbt_utils package has a macro called generate_surrogate_key. It joins the columns with a separator, replaces NULL and applies md5. MD5 is fine for row keys. Never use it for passwords.
13 · Optional
One person, many ids
⏱ 11 min · Topic 13 of 15
You can skip this section on a first read.
A visitor opens the shop website. They are not logged in, so the site gives them an anonymous id, kept in a cookie. A cookie is a small file the browser stores.
Later, the visitor logs in. Now you also know their customer_id. The same person has two ids, and the events before login look like a different person.
Here are five events. Visitor v-77a is Asha. She logs in at event 3.
events (5 rows)
event_id
anonymous_id
customer_id
event_name
1
v-77a
NULL
view_product
2
v-77a
NULL
add_to_cart
3
v-77a
1
login
4
v-77a
1
purchase
5
v-91c
NULL
view_product
First, count people the simple way. Use customer_id when it exists, and anonymous_id when it does not.
The pair (id_type, id_value) is a composite key. An id can belong to only one person. If a browser is shared, do not store its anonymous_id here. New ids join person 1 without changing person_key.
14 · Practice
Practice lab
⏱ 3 min · Topic 14 of 15
Five design exercises for this chapter. You build each one on the ERD canvas. A review then checks it against sample rows.
Try them with the chapter closed. If one goes wrong, go back to the section named in its prompt.