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/1043 in 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.

Try it: UUID Generator

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) or RESTRICT: 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.
Where the foreign key sits and which constraint limits it decide the relationship: on the many side for 1:N, made UNIQUE for 1:1, and twice in a junction table with a composite key for N:M.

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:
UNIQUE on 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_id 42.
  • 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_items rows 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_cards has 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)
);

Try it: SQL Formatter Try it: SQL INSERT Generator

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 CASCADE as 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

Your task

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.

Material
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
);
Steps
  1. List each foreign key with its child table, parent table and delete rule.
  2. Name the relationship each requirement describes and how the schema enforces it.
  3. Write a test condition with an expected result for every rule and every gap.
Show the model answer
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

Practise with Mutqan tools

Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).

Database

Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.

Database

Generate INSERT statements from a table definition, sample rows, CSV or JSON.

Database

Generate UUID v4 (random) and v7 (time-ordered) identifiers in bulk, and validate existing ones.

Utilities

Go deeper

Your progress on this topic

Part of the QA Roadmap