Constraints and Data Integrity

A constraint is a rule declared in the database schema. The database checks it on every insert, update and delete and rejects any statement that would break it, whichever application sent that statement. The SQL standard and the major relational engines share a core set: NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY and CHECK, with DEFAULT usually taught alongside.

Example: with CHECK (quantity BETWEEN 1 AND 50) on order lines, a line with quantity 0 is refused by the database itself, whether it comes from the web form, a CSV import or a developer’s script. Data integrity is the goal — data that is complete, valid and consistent — and constraints are the part of it the database can guarantee.

Why it matters

Application validation lives in one code path; data arrives through many: imports, batch jobs, admin screens, other services and two requests racing each other. Any of them can skip the checks the web form performs. Without a constraint the bad row is stored silently and surfaces weeks later as a duplicate customer or an order line pointing to an order that does not exist.

Bad data spreads into reports, invoices and other systems before anyone notices, and repairing it needs a data fix, not only a code fix. For a tester, each constraint is a precise requirement: one statement must be rejected, one must be accepted, and a missing constraint can be shown with a single statement.

Six constraints and what each one refuses

You met primary and foreign keys in the lesson on keys and relationships; here they are two of six constraints. The order_lines table at the end of this section declares all six, and the rest of the lesson tests it.

  • NOT NULL: the column must hold a value. An empty string '' is a value, so NOT NULL alone accepts a blank SKU (Oracle is the known exception: it stores '' as NULL).
  • UNIQUE: no two rows may share the value, or the combination: UNIQUE (order_id, sku) allows each SKU once per order. NULL is not equal to NULL, so PostgreSQL, MySQL and SQLite accept several NULLs in a UNIQUE column; SQL Server accepts one.
  • PRIMARY KEY: UNIQUE plus NOT NULL, at most one per table.
  • FOREIGN KEY: the value must exist in the referenced key, or be NULL. Without an ON DELETE clause, deleting a parent that still has children is refused; ON DELETE CASCADE or SET NULL changes that, and the requirement decides which is right.
  • CHECK: a condition every row must satisfy. It fails only when the condition is false; a comparison with NULL is unknown, so CHECK (quantity > 0) lets NULL through unless the column is also NOT NULL.
  • DEFAULT: the value stored when an insert omits the column. It refuses nothing, and an insert that sends NULL explicitly does not get the default.
-- orders(id) already exists
CREATE TABLE order_lines (
  id        INTEGER      PRIMARY KEY,
  order_id  INTEGER      NOT NULL REFERENCES orders (id),
  sku       VARCHAR(20)  NOT NULL,
  quantity  INTEGER      NOT NULL CHECK (quantity BETWEEN 1 AND 50),
  status    VARCHAR(10)  NOT NULL DEFAULT 'open'
            CHECK (status IN ('open', 'shipped', 'returned')),
  UNIQUE (order_id, sku)
);

Four kinds of data integrity

Textbooks split logical data integrity, the correctness of the stored values, into four kinds; physical integrity, data surviving crashes and disk faults, belongs to backup and recovery testing. Use the four as a checklist: for each kind, find what protects it.

  • Entity integrity: every row is identifiable. Protected by PRIMARY KEY, and by UNIQUE on natural keys such as an email address.
  • Referential integrity: every reference points to an existing row. Protected by FOREIGN KEY; its violations are orphan rows.
  • Domain integrity: every value is valid for its column. Protected by the data type and length, NOT NULL, CHECK and DEFAULT.
  • User-defined (business) integrity: rules such as "a doctor cannot have two overlapping appointments". A one-row rule can be a CHECK; a rule across rows or tables usually needs a trigger or application code (see the lesson on stored procedures and triggers).

How to test a constraint

Test at two layers. At the database, prove the rule exists and is enforced. Through the product, prove the refusal becomes a clear message: a declared rule whose error reaches the user as a 500 page is still a defect.

Start from the running schema, not the design document: read the migrations, or query information_schema, the standard views in which the database describes its own tables, columns and constraints, as at the end of this section (PostgreSQL, MySQL and SQL Server provide it). Then, for each rule:

  • One rule, two tests, as in boundary value analysis: the value that must be rejected and the closest one that must be accepted — 0 and 1, 51 and 50 for BETWEEN 1 AND 50; 21 and 20 characters for VARCHAR(20).
  • Break one column at a time and confirm which rule fired: a row that breaks two rules is refused by whichever constraint the engine checks first. PostgreSQL reports SQLSTATE, the standard five-character error code, 23502 for NOT NULL, 23505 for UNIQUE and PRIMARY KEY, 23503 for FOREIGN KEY and 23514 for CHECK, all in class 23, integrity constraint violation.
  • Repeat each violation through the UI and the API: expect a clear message or the 4xx status the API contract defines, such as 409 or 422, never a 500 that shows table or constraint names.
  • Test the paths that skip application validation: imports, batch jobs, admin tools and two identical requests at the same moment. A "does this email exist?" check before the insert can let both racing requests through; only UNIQUE stops the second.
  • Know your engine: MySQL enforces CHECK only from version 8.0.16, and before 9.0 it parses but ignores a REFERENCES written inside a column definition, as in order_lines, so there the key must be declared separately: FOREIGN KEY (order_id) REFERENCES orders (id). SQLite enforces foreign keys only after PRAGMA foreign_keys = ON and ignores the length in VARCHAR(20).
  • Where no constraint protects a rule, audit the stored data with the duplicate and orphan queries of the SQL for Testers guide.
SELECT constraint_name, constraint_type
FROM information_schema.table_constraints
WHERE table_name = 'order_lines';

SELECT column_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'order_lines';

Try it: Boundary Value Generator Try it: String Length Counter

Worked example: a constraint test script

Order 7 exists in orders; order 9999 does not. T0 inserts a valid row; each later statement breaks exactly one rule, with the expected PostgreSQL result in its comment.

Run the statements one at a time in a disposable test database. In PostgreSQL a failed statement aborts its transaction, so inside one BEGIN block every statement after T1 would fail for the wrong reason.

-- Disposable test database, autocommit: run one statement at a time.
-- T0 valid row -> accepted; status gets the DEFAULT 'open'
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (1, 7, 'A-100', 2);
-- T1 PRIMARY KEY: id 1 again -> rejected, 23505
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (1, 7, 'B-200', 2);
-- T2 UNIQUE (order_id, sku): A-100 on order 7 again -> rejected, 23505
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (2, 7, 'A-100', 1);
-- T3 FOREIGN KEY: order 9999 does not exist -> rejected, 23503
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (3, 9999, 'C-300', 1);
-- T4 NOT NULL: no SKU -> rejected, 23502
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (4, 7, NULL, 1);
-- T5 CHECK quantity: 0 -> rejected, 23514 (also 51; 1 and 50 are accepted)
INSERT INTO order_lines (id, order_id, sku, quantity)
VALUES (5, 7, 'D-400', 0);
-- T6 CHECK status: 'lost' is not in the list -> rejected, 23514
INSERT INTO order_lines (id, order_id, sku, quantity, status)
VALUES (6, 7, 'E-500', 1, 'lost');
-- T7 explicit NULL skips the DEFAULT -> rejected by NOT NULL, 23502
INSERT INTO order_lines (id, order_id, sku, quantity, status)
VALUES (7, 7, 'F-600', 1, NULL);
Constraint test matrix for order_lines: for each of the six rules, the change the database must reject, the closest value it must accept and the gap to check next.
ConstraintMust be rejectedClosest value that must be acceptedThen check
NOT NULLskuNULLT4 · error 23502'A-100'An empty string or spaces pass NOT NULL: should they?
UNIQUE(order_id, sku)A-100 twice on order 7T2 · error 23505A-100 on another orderThe same line twice through the API, also at the same moment: 409 or 422, and one row
PRIMARY KEYidid 1 a second timeT1 · error 23505A new idid sent as NULL is refused too: PRIMARY KEY includes NOT NULL
FOREIGN KEYorder_idOrder 9999, which does not existT3 · error 23503Order 7Delete order 7 while it has lines: refused unless ON DELETE CASCADE
CHECKquantity0 / 51T5 · error 235141 / 50CHECK lets NULL through; here NOT NULL stops it
DEFAULTstatus 'open'status sent as NULLT7 · NOT NULL refuses itstatus omitted → openDoes the API omit the field or send null?
  • Not a constraint in the strict sense: DEFAULT supplies a value and refuses nothing

Expected results and error codes as PostgreSQL reports them. Engines differ in details such as NULLs in a UNIQUE column and letter-case comparison.

Try it: SQL INSERT Generator Try it: SQL Query Validator Try it: SQL Dataset Generator

Reading the results: the gaps behind the passes

Eight expected results prove that the declared rules work. The findings worth raising usually come from what the rules miss:

  • VALUES (8, 7, '', 1) is accepted, because an empty SKU is not NULL. Ask whether the schema needs CHECK (sku <> '') or the API should refuse it.
  • T7 hides an API trap: a service that turns a missing JSON field into an explicit NULL fails every request without a status. Send the request once with the field omitted and once with "status": null.
  • DELETE FROM orders WHERE id = 7 must be refused while lines exist, unless the requirement says lines go with their order (ON DELETE CASCADE).
  • Send the T2 line twice through the API: expect 409 or 422 with a message the user understands, and still one row in the table.
  • Whether the status CHECK accepts 'Open' with a capital O depends on the column's collation: PostgreSQL rejects it, while MySQL's default collation accepts and stores it. Test it on the engine you ship on.

Common mistakes

  • Testing rules only through the user interface, whose validation hides whether the database enforces anything.
  • Treating NOT NULL as "required": it refuses NULL but accepts an empty string.
  • Counting any error as a pass. If the row breaks two rules or the statement has a syntax error, the refusal proves nothing about the rule under test.
  • Forgetting that CHECK and a nullable foreign key column both let NULL through.
  • Trusting the design document over the running database, where a constraint may be missing, disabled or unsupported by the engine version.
  • Accepting a 500 response or a raw database error because "the bad data was not saved". The data is safe; the error handling is a defect.

The QA perspective

Constraint testing starts in refinement and code review. For each rule in a story, such as "email must be unique", ask where it is enforced — UI, API, database or all three — and what the user should see when it fails. Read the migrations in pull requests: a missing NOT NULL or foreign key is cheapest to fix before data is written. Adding a UNIQUE or CHECK constraint to a table that already breaks it normally fails, so ask how existing rows will be cleaned up.

Run violation tests only in a test database you are allowed to change, never in production, and keep them as a data-driven table (constraint, statement, expected error, expected API response) to rerun after schema changes. In a defect report, give the exact statement or request, the expected and actual result, and the engine version.

Practice exercise

Design the constraint tests for a clinic’s appointments table

Your task

A clinic stores appointments in the table below. For each constraint, write one change to a valid row that must be rejected and the closest change that must be accepted, with expected results.

Then name the rules a receptionist would expect that no constraint enforces, and how you would test them.

Material
-- patients 12 and 13 and doctors 3 and 4 exist
CREATE TABLE appointments (
  id          INTEGER      PRIMARY KEY,
  patient_id  INTEGER      NOT NULL REFERENCES patients (id),
  doctor_id   INTEGER      NOT NULL REFERENCES doctors (id),
  starts_at   TIMESTAMP    NOT NULL,
  minutes     INTEGER      NOT NULL DEFAULT 15 CHECK (minutes BETWEEN 10 AND 60),
  status      VARCHAR(12)  NOT NULL DEFAULT 'booked'
              CHECK (status IN ('booked', 'attended', 'no_show', 'cancelled')),
  UNIQUE (doctor_id, starts_at)
);
Steps
  1. List the constraints column by column, including the two-column UNIQUE and both defaults.
  2. Write a valid baseline row, then break one constraint at a time.
  3. Add the closest accepted value for each, plus minutes omitted and sent as NULL.
  4. Note the rules the schema leaves unprotected.
Show the model answer
Model answer

Baseline, accepted: patient 12, doctor 3, 2026-11-02 10:00, 30 minutes; status omitted, stored as booked.

PRIMARY KEY: the same id → rejected, a new one → accepted. FOREIGN KEY: patient or doctor 999 → rejected, patient 13 with doctor 4 → accepted. NOT NULL: starts_at NULL → rejected.

CHECK: minutes 9 and 61 → rejected, 10 and 60 → accepted; status done → rejected, no_show → accepted. DEFAULT: minutes omitted → 15; minutes or status sent as NULL → rejected by NOT NULL.

UNIQUE (doctor_id, starts_at): doctor 3 at 10:00 again → rejected; doctor 4 at 10:00 with patient 13, or doctor 3 at 10:30 → accepted.

Not enforced: overlaps (doctor 3 at 10:00 for 30 minutes and again at 10:15 are both accepted, because UNIQUE compares only identical start times) and one patient with two doctors at once. Test them through the booking screen and the API, and audit stored rows with a self-join: a query that joins appointments to itself to find pairs of one doctor’s appointments whose times overlap. Ask whether the rule belongs in application code, a trigger or a PostgreSQL exclusion constraint, which can reject a row whose time range overlaps another row’s for the same doctor.

Frequently asked questions

If the application already validates input, why test the database constraints?

Because the application is not the only writer. Imports, scheduled jobs, admin tools, other services and concurrent requests reach the tables without the same validation. Application checks give users good messages; constraints guarantee the data. A tester checks both.

Is DEFAULT really a constraint?

Not strictly. In the SQL standard and the PostgreSQL documentation a default belongs to the column definition, not to the constraints, because it never refuses anything; SQL Server, however, calls it a DEFAULT constraint. It is taught with them because it shapes stored data, and it needs two tests of its own: the column omitted, and the column sent as NULL.

Can I test constraints without access to the database?

Partly. Through the API you can send duplicates, missing fields, out-of-range values and unknown references and check the responses, but you cannot tell which layer refused the data or find bad rows that are already stored. Ask for the schema and read access to a test database, or pair with a developer for the database-level tests.

Should every business rule become a database constraint?

No. Rules that depend on other services, on the current time or on a policy that changes often usually live in application code. Core invariants such as uniqueness, valid references and non-negative amounts benefit from a constraint because they must hold whatever writes the data. Know which layer enforces each rule, test that layer, and test the paths around it.

References

Practise with Mutqan tools

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

Database

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

Database

Generate boundary values (min-1, min, min+1, nominal, max-1, max, max+1) for numeric and length constraints.

Test Design

Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.

Test Data

Measure string length in characters, code points, UTF-8 bytes and UTF-16 units.

Formatting

Go deeper

Your progress on this topic

Part of the QA Roadmap