Keys and Relationships
A key is a column, or a set of columns, that a relational database uses to identify and connect rows. A primary key identifies each row of a table uniquely: in customers, the id 42 is one customer, never two. A foreign key is a column in another table that stores that value to point at the row: an order with customer_id 42 belongs to customer 42.
Relationships are built from these two keys, and referential integrity is the rule that holds them together: every foreign key value points at a row that exists.
Why it matters
Keys are how an application finds its data again. Two customers with the same identifier send a support agent to the wrong account; an order that points at a deleted customer breaks the order history; a delete rule nobody asked for removes invoices together with their customer. The screen can look correct while the stored data is already wrong, and the failure surfaces later, in a report, an export or another service.
For a tester, every foreign key is a rule with a positive test (a valid reference is accepted) and negative tests (a missing parent is rejected; deleting a parent does what the requirement says).
Primary keys: one row, one identity
A primary key (PK) is a column, or a combination of columns, whose value is unique in every row and never NULL. A table has at most one, and the database rejects a row whose key is NULL or already used by another row. A primary key made of several columns is a composite key.
A natural key already means something in the real world, such as a product SKU. A surrogate key has no business meaning: an auto-incremented integer, or a UUID, a 128-bit identifier that an application can generate without asking the database for the next number. Teams often use a surrogate primary key and put a UNIQUE constraint, which rejects a value that another row already has, on the natural identifier, because real-world identifiers such as an email change.
- Uniqueness: a second customer with an existing email is rejected with a clear message, not a server error.
- Stability: a customer who changes their email keeps the same
id, so their orders still point at them. - Generation: keys do not collide when records are created in parallel, or after an import that wrote its own ids. Such an import can leave the database’s counter behind, so the next new record gets an id that is already taken.
- Exposure: sequential ids are easy to guess, so changing
/orders/1043in a URL to reach another customer’s order is an access-control test. A UUID is harder to guess but does not replace that check.
Foreign keys and referential integrity
A foreign key (FK) is a column, or a set of columns, in a child table whose values must match the primary key, or another unique key, of a parent table. In orders.customer_id REFERENCES customers (id), orders is the child. Referential integrity holds when every non-null foreign key value matches an existing parent row; a child row that breaks it is an orphan.
With the foreign key declared, the database rejects an order for a customer that does not exist. Deleting a parent that still has children triggers the referential action written in the schema. Choosing it is a business decision, so confirm it with the product owner, then test it:
NO ACTION(the default) orRESTRICT: the delete is rejected; the two differ only in when the check runs.CASCADE: the children are deleted with the parent.SET NULL: the children stay and their foreign key becomes NULL, so the column must allow NULL.- A NULL foreign key passes the check unless the column is NOT NULL: right for an optional relationship, a defect for a mandatory one.
- Declared is not always enforced: by default SQLite leaves foreign keys unchecked unless each connection runs
PRAGMA foreign_keys = ON, and no foreign key reaches into another service. There, only the application protects integrity.
One-to-one, one-to-many and many-to-many
Cardinality says how many rows on one side relate to how many on the other. All three kinds use the same two keys; only the position of the foreign key and the constraint on it change.
- One-to-many (1:N): one customer has many orders. The foreign key sits on the many side,
orders.customer_id, and a parent may have no children at all. - One-to-one (1:1): a customer has at most one loyalty card. The foreign key is also UNIQUE, so a second card for the same customer is rejected.
- Many-to-many (N:M): an order has many products and a product is in many orders. A junction table,
order_items, stores one row per pair with a foreign key to each side; a composite primary key on the pair blocks duplicates. - No foreign key can force a parent to have children: “every order has at least one line” is the application’s job, and an empty order is a classic defect.
One-to-many (1:N)
One customer, many orders
customer_id INTEGER NOT NULL REFERENCES customers (id)
- Foreign key:
- On the many side:
orders.customer_id - Limiting constraint:
- None: a customer has zero or more orders
- Negative test:
- Order for customer 999, which does not exist: rejected
One-to-one (1:1)
One customer, at most one loyalty card
customer_id INTEGER NOT NULL UNIQUE REFERENCES customers (id)
- Foreign key:
loyalty_cards.customer_id, also UNIQUE- Limiting constraint:
UNIQUEon the foreign key: at most one card per customer- Negative test:
- Second card for customer 42: rejected
Many-to-many (N:M)
Many orders, many products
PRIMARY KEY (order_id, product_id)
- Foreign key:
- Two, in the junction table
order_items - Limiting constraint:
- Composite primary key: each order and product pair at most once
- Negative test:
- Same product twice in one order: rejected or merged
Worked example: testing the keys of a small store
This schema is part of a store’s test basis: each REFERENCES clause is a relationship, and each ON DELETE clause, or its absence, is a rule. The product owner confirms that a customer with orders cannot be deleted, deleting an order removes its lines, and a product that was ordered cannot be deleted. Act through the UI or the API, then confirm the stored rows.
- Order for existing customer 42: accepted and stored with
customer_id42. - Order for customer 999, which does not exist, sent through the API: rejected with a validation message, not a server error, and nothing stored.
- Delete customer 42, who has orders: rejected (
RESTRICT). - Second loyalty card for customer 42: rejected by the UNIQUE foreign key.
- Same product twice in one order: rejected by the composite key, or merged into one line if the requirement says so.
- Delete an order: its
order_itemsrows go with it (CASCADE). - Delete a product that was ordered: rejected (
NO ACTION). - Delete a customer who has a loyalty card but no orders: rejected too, because
loyalty_cardshas no delete rule (NO ACTION). Nobody stated this rule, so ask the product owner whether it is intended.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
email VARCHAR(254) NOT NULL UNIQUE,
full_name VARCHAR(100) NOT NULL
);
CREATE TABLE loyalty_cards ( -- 1:1 with customers
card_no CHAR(10) PRIMARY KEY,
customer_id INTEGER NOT NULL UNIQUE REFERENCES customers (id)
);
CREATE TABLE orders ( -- 1:N: one customer, many orders
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers (id) ON DELETE RESTRICT,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE products (
id INTEGER PRIMARY KEY,
sku VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL
);
CREATE TABLE order_items ( -- N:M junction: orders x products
order_id INTEGER NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products (id),
quantity INTEGER NOT NULL,
PRIMARY KEY (order_id, product_id)
);Common mistakes
- Testing relationships only through the screens: a drop-down of existing customers hides the bad references that the API, imports and batch jobs can still send.
- Assuming a foreign key exists because a column is called
customer_id. A naming convention enforces nothing. - Treating
CASCADEas always right or always wrong: invoices usually must survive a deleted customer, while the lines of a deleted order should not. - Skipping the junction table: duplicate pairs, and what happens to the pairs when one side is deleted.
- Running integrity tests where enforcement is off, such as SQLite without
PRAGMA foreign_keys = ON, and reporting a pass.
The QA perspective
Start in refinement: for every new foreign key in a data model or migration (a script that changes the schema), ask whether it may be empty, what happens to the children when the parent is deleted, and who decided that. The answers become test conditions, and a question nobody can answer is a requirement defect found before any code exists.
During testing, act through the product and verify in the database, including the paths the UI hides. After migrations, imports and failed batch jobs, check the relationships the database does not enforce for orphans. Report the tables, the key values, the cause and the broken rule, for example: order 1043 references customer 77, which does not exist; the row came from the nightly import.
Practice exercise
Find the gaps in a course enrolment schema
A training platform’s requirements: a student can enrol in many courses and a course has many students, but nobody enrols in the same course twice; each student has at most one card; deleting a course that has enrolments is rejected, while deleting a student removes their enrolments.
Find where the schema below does not enforce these rules, and write test conditions with expected results.
CREATE TABLE students (
id INTEGER PRIMARY KEY,
email VARCHAR(254) NOT NULL UNIQUE
);
CREATE TABLE courses (
id INTEGER PRIMARY KEY,
title VARCHAR(120) NOT NULL
);
CREATE TABLE student_cards (
card_no CHAR(8) PRIMARY KEY,
student_id INTEGER NOT NULL REFERENCES students (id)
);
CREATE TABLE enrolments (
id INTEGER PRIMARY KEY,
student_id INTEGER NOT NULL REFERENCES students (id) ON DELETE CASCADE,
course_id INTEGER NOT NULL REFERENCES courses (id) ON DELETE CASCADE
);- List each foreign key with its child table, parent table and delete rule.
- Name the relationship each requirement describes and how the schema enforces it.
- Write a test condition with an expected result for every rule and every gap.
Show the model answer
Foreign keys: student_cards.student_id references students, no delete rule (NO ACTION); enrolments.student_id references students, CASCADE; enrolments.course_id references courses, CASCADE.
Relationships: students N:M courses through the junction table enrolments; students 1:1 student cards. The student rule is enforced correctly: ON DELETE CASCADE on enrolments.student_id.
Gap 1: no UNIQUE constraint on (student_id, course_id) in enrolments. Test: enrol the same student in the same course twice, also with two parallel API calls. Expected: one row, and a clear message for the second attempt. Fix: UNIQUE (student_id, course_id), or make the pair the primary key.
Gap 2: student_cards.student_id is not UNIQUE, so the schema allows 1:N. Test: issue a second card to the same student. Expected: rejected. Fix: make student_id UNIQUE.
Gap 3: enrolments.course_id has ON DELETE CASCADE, so deleting a course silently deletes its enrolments. Test: delete a course with an enrolment. Expected: rejected, both rows remain. Fix: ON DELETE RESTRICT, or drop the clause to get the default NO ACTION.
Also test: enrolling in course 999, which does not exist, is rejected; deleting a student who has enrolments but no card deletes the student and the enrolments, and the courses stay. Ask the product owner about students with a card: student_cards has no delete rule, so that delete is rejected.
Frequently asked questions
Can a table have no primary key?
Most databases allow it, but then nothing guarantees that each row can be identified: duplicate rows cannot be told apart, an update or delete may hit several rows, and other tables cannot reference the rows reliably. Treat a table without a primary key as a question for the team, and check it for duplicates.
Is a UNIQUE column the same as a primary key?
Both reject duplicate values, but a table has only one primary key and, in standard SQL, its columns can never be NULL. SQLite is a known exception: for backward compatibility it accepts NULL in most primary key columns not declared NOT NULL. A table can have several UNIQUE constraints, and a UNIQUE column usually accepts NULL; how many NULLs it accepts differs between databases. A foreign key can reference either kind.
If the database enforces foreign keys, do I still test referential integrity?
Yes, but you are not testing the database engine. You check that the right constraints exist with the right delete rules, that they are switched on in every environment, and that the application answers a rejection with a clear message instead of a server error. Where no foreign key can exist, for example between two services, the application’s own checks are the only protection.
How do I find orphan rows?
Join the child table to its parent and keep the child rows without a match, for example SELECT o.id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL. The lesson on SQL JOINs explains this pattern step by step.
References
- Constraints — PostgreSQL DocumentationCheck, not-null, unique, primary-key, foreign-key and exclusion constraints — the rules a database enforces and a tester verifies.
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)The framework part of the international SQL standard, which the dialects of PostgreSQL, MySQL, SQLite and others implement to different degrees.
- SQL As Understood By SQLiteThe SQL grammar accepted by SQLite, statement by statement, with railroad diagrams.
Practise with Mutqan tools
Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Generate INSERT statements from a table definition, sample rows, CSV or JSON.
Generate UUID v4 (random) and v7 (time-ordered) identifiers in bulk, and validate existing ones.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.