PhoneNumbers. Her name is in a different table, Person, and the name of her state is in a third one, States. No arrow joins these rows on disk. All they share is a few matching values: 3 in two tables, CA in two others.
Those values are keys. A key is how a row says “this is me”, and how another row says “I belong to that one”. Pick them well and the database keeps your data consistent for you. Pick them badly and you get phones that belong to nobody, people you can’t delete, and URLs that tell strangers how many customers you have.
This post uses one small example the whole way through: three people, their states and their phones. It assumes you know what a table and a row are. For superkeys and candidate keys, see The Relational Model. For choosing between natural and surrogate keys, see Designing a Relational Database.
The example
Here are the three tables. Underlined headings in the figures are primary keys. Dotted cells are foreign keys.Primary key vs foreign key
A primary key answers “which row is this?”. InPerson that is person_id. Two rules come with it: no two rows may share a value, and the value may never be empty. In SQL terms, a primary key is UNIQUE plus NOT NULL. A table has exactly one primary key, although that one key may span several columns.
A foreign key answers “which row does this belong to?”. PhoneNumbers.person_id is a foreign key to Person. Its rule is different: every non-NULL value must match a row that really exists in the parent table. The database checks this on every insert and update. This rule is called referential integrity.
Try five inserts and watch which rule decides each one.
Side by side:
Three points where many tutorials, including the source, get loose:
- A foreign key can point at any unique column, not only the primary key. A
UNIQUEemail column works too. Pointing at the primary key is just the usual choice. - The primary key does not “become” a foreign key. The child gets its own column,
PhoneNumbers.person_id, that holds copies of the parent’s key values. - A parent row can be deleted while children point at it. What happens is up to you. By default the database says no (
RESTRICTorNO ACTION). WithON DELETE CASCADEthe children are deleted too. WithON DELETE SET NULLtheir foreign key becomes NULL. Figure 3 shows all three.
Composite keys
Look atPhoneNumbers again. Can person_id be its key? No: Ana has two phones, so 1 shows up twice. Can phone be its key? No: Ben and Chi share 555-0200. But the pair (person_id, phone) is different on every row. A key made of two or more columns like this is called a composite key.
In SQL you list the columns in one PRIMARY KEY (…) clause, as in the CREATE TABLE above. A few facts that go with it:
- Every column in it is NOT NULL. A primary key can’t be partly empty.
- Only the combination is unique.
(1, '555-0101')can appear once, but1and555-0101can each appear in other rows. - Column order matters for speed. The database builds an index on
(person_id, phone). That index finds “all of Ana’s phones” quickly, becauseperson_idcomes first. It does not help a search onphonealone. - Every child must copy all of it. If another table pointed at a phone row, it would need both
person_idandphone. That is fine for two columns and painful for four.
- Junction tables in a many-to-many link, with one row per pair.
- Child rows that belong to a parent, like phones. This case has a name, which is the next section.
Identifying vs non-identifying relationships
Every foreign key creates a parent–child relationship. There are two kinds, and the test is one question: can the child row exist without its parent? Identifying. A phone number inPhoneNumbers is Chi’s phone. Without Chi, the row means nothing. So the parent’s key, person_id, is part of the child’s key. The child is identified by its parent: “Chi’s mobile”, not just “a mobile”. In diagrams this is drawn as a solid line.
Non-identifying. Person.state points at States, so Person is a child of States. But Ana is not identified by her state. If she moves to Texas she is still person 1. So state stays outside the primary key: it is a plain column that happens to be a foreign key. This is typical of lookup tables such as states, countries or currencies. In diagrams it is drawn as a dashed line.
Optional or mandatory
A non-identifying relationship has one more choice: may the foreign key be NULL?- Optional: the column allows NULL. “Dan has not told us his state yet” is a valid row. If CA is deleted,
ON DELETE SET NULLcan clear the pointer and keep the person. - Mandatory: the column is
NOT NULL. Every person must have a state. Deleting a state that is in use is refused.
The pseudokey shortcut
Many teams give every table its own id column, even child tables. Here that meansPhoneNumbers gets a phone_id, and phone_id alone becomes the primary key:
- Before:
PRIMARY KEY (person_id, phone) - After:
PRIMARY KEY (phone_id)
- It made
person_idrequired. Key columns can never be NULL. Nowperson_idis an ordinary column, so a phone with no owner can be stored. - It stopped duplicates. The pair (
person_id,phone) had to be unique. Now onlyphone_idmust be unique, and every new row gets a newphone_id. So Chi’s mobile can be saved twice, as rows 105 and 106.
ON DELETE CASCADE is not one of the lost rules; you choose it with either key. Keep it so Chi’s phones still go when Chi does. The third tab of Figure 3 shows the duplicate getting in, then being refused once UNIQUE is added.
One side effect: diagram tools pick the line style from the key alone. person_id is no longer in the key, so they draw a dashed (non-identifying) line, even though a phone still can’t exist without its person. That is only the drawing. The database enforces exactly the constraints you write, nothing more.
What the key value is made of
The last choice is the key’s value. Most tables use a surrogate key: a value with no meaning, made just to identify the row. There are three common kinds. Watch five new users sign up under each. Auto-increment hands out 1, 2, 3, … It is small (8 bytes asBIGINT), new rows go at the end of the index, and it is easy to read in logs. The Clever Cloud post lists its costs:
- It leaks.
/users/1047tells anyone roughly how many users you have. Sign up twice a week apart and you know your growth rate too. - It is guessable. If
/invoices/1047is yours,/invoices/1046is someone else’s. That only hurts if the server forgets to check who is asking, but that mistake is common. - It is per table. User 5 and order 5 are both “5”. Mix them up in a script and you delete the wrong thing.
- Only the database can make one. The application must insert a row before it knows its id, which makes creating a parent and its children in one go more awkward.
- Its
serialoverflow worry is real, butserialis a 32-bit integer (about 2.1 billion). Today you would useBIGINT GENERATED … AS IDENTITY, which does not run out in practice. - PostgreSQL has had
gen_random_uuid()built in since version 13, so no extension is needed. Version 18 addeduuidv7(). - UUIDv7 did not exist yet. It was standardized in RFC 9562 in 2024 and fixes the scattered-index problem.
- A UUID in a URL is not security. It is hard to guess, but it can still leak through logs, shared links and browser history. Always check that the user may see the row.
What to pick
There is no single right answer, but a few defaults hold up:- Internal tables nobody sees in a URL: a
BIGINTidentity is the simplest choice. - Ids that appear in URLs or APIs, or that the app creates offline: UUIDv7. If creation time must stay private, use UUIDv4.
- Both at once: a common pattern is a
BIGINTprimary key for joins plus a separateUUIDcolumn, with aUNIQUEconstraint, for the outside world. - A natural key from an outside authority (an ISO state code like
CA, an ISBN) is fine when it is stable. That is whyStatesusesstateitself as the key.
Check yourself
Glossary
For AI agents
Adapted from GeeksforGeeks, “Difference between Primary Key and Foreign Key” and “Composite Key in SQL”, and Clever Cloud, “Why Auto Increment Is A Terrible Idea”. Corrected: foreign keys may reference any unique column, parent rows can be deleted with
ON DELETE actions, and 2015-era UUID details (built-in gen_random_uuid(), UUIDv7, BIGINT identity). The people and phone data are illustrative; figures are simplified teaching models.