Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 2 – The relational model, keys and integrity

Integrity constraints and foreign keys in practice

How NOT NULL, UNIQUE, CHECK, primary and foreign keys reject bad rows, which referential action to choose, deferred checks, and SQLite and PostgreSQL rules.

  • Beginner
  • 30 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Declare NOT NULL, UNIQUE, CHECK, PRIMARY KEY and FOREIGN KEY constraints and predict which rows they reject
  • Choose referential actions (CASCADE, SET NULL, SET DEFAULT, RESTRICT, NO ACTION) and deferred checking
  • Explain engine differences: SQLite foreign keys, flexible typing and STRICT tables
  • Use PostgreSQL 18 temporal constraints to forbid overlapping ranges

Before you start

What you’ll learn · Before you start

What you will learn

  • Declare NOT NULL, UNIQUE, CHECK, PRIMARY KEY and FOREIGN KEY constraints and predict which rows they reject
  • Choose referential actions (CASCADE, SET NULL, SET DEFAULT, RESTRICT, NO ACTION) and deferred checking
  • Explain engine differences: SQLite foreign keys, flexible typing and STRICT tables
  • Use PostgreSQL 18 temporal constraints to forbid overlapping ranges

Before you start

On this page

An integrity constraint is a rule about the data that the database itself checks on every insert, update and delete; a change that would break the rule is refused with an error, and the data stays as it was. SQL has five everyday constraints: NOT NULL (a value must be given), UNIQUE (no two rows share a value), CHECK (each row must satisfy a condition), PRIMARY KEY (unique and never NULL) and FOREIGN KEY (a value must match a key of another table). Declaring a rule in the schema, rather than in the program that writes the data, means that every program obeys it, including the import script someone writes next year. This lesson runs each constraint in SQLite, compares the referential actions a foreign key can take, shows how deferred checking allows circular references, and covers the places where SQLite and PostgreSQL 18 differ, up to PostgreSQL’s new constraints against overlapping time ranges.

Kinds of constraint, and why they belong in the schema

The constraints of a relational database fall into four families:

  • Domain constraints: column types, NOT NULL, CHECK and domains. They refuse a value outside the allowed set for its column.
  • Key constraints: PRIMARY KEY and UNIQUE. They refuse a second row with the same key value.
  • Referential constraints: FOREIGN KEY. They refuse a reference to a row that does not exist.
  • Rules across rows: exclusion and temporal constraints, triggers and assertions. They refuse combinations of rows, such as two overlapping bookings of one room.

Codd’s 1985 articles name two rules that apply to every relational database. Entity integrity: no component of a primary key may be null. Referential integrity: for each distinct non-null foreign key value there must exist a matching primary key value from the same domain. His rule 10, integrity independence, adds the reason to declare everything else in the database too: constraints should be defined in the database language and “storable in the catalog, not in the application programs”. A check in application code protects only the paths that go through that code.

NOT NULL, UNIQUE and CHECK at work

Here is a student table with a constraint on every column, and seven rows that test them:

Four bad rows refused, three rows kept SQL · constraints_at_work.sql
CREATE TABLE student (
  roll_no   INTEGER PRIMARY KEY,
  name      TEXT    NOT NULL,
  email     TEXT    UNIQUE,
  programme TEXT    NOT NULL CHECK (programme IN ('BTech', 'MSc', 'PhD')),
  score     REAL    CHECK (score BETWEEN 0 AND 100)
);
INSERT INTO student VALUES (101, 'Asha',   'asha@uni.example',  'BTech', 81.5);
INSERT INTO student VALUES (102, NULL,     'bilal@uni.example', 'MSc',   74.0);  -- no name
INSERT INTO student VALUES (103, 'Chitra', 'asha@uni.example',  'PhD',   90.0);  -- Asha's email
INSERT INTO student VALUES (104, 'Dev',    'dev@uni.example',   'MBA',   65.0);  -- not a programme
INSERT INTO student VALUES (105, 'Esha',   'esha@uni.example',  'BTech', 104.0); -- score too high
INSERT INTO student VALUES (106, 'Farah',  NULL,                'MSc',   NULL);  -- no email, no score yet
INSERT INTO student VALUES (107, 'Gopal',  NULL,                'PhD',   NULL);  -- a second missing email

SELECT roll_no, name, quote(email) AS email, programme, quote(score) AS score FROM student;

Output (exit status 1)

┌─────────┬───────┬────────────────────┬───────────┬───────┐
│ roll_no │ name  │       email        │ programme │ score │
├─────────┼───────┼────────────────────┼───────────┼───────┤
│ 101     │ Asha  │ 'asha@uni.example' │ BTech     │ 81.5  │
│ 106     │ Farah │ NULL               │ MSc       │ NULL  │
│ 107     │ Gopal │ NULL               │ PhD       │ NULL  │
└─────────┴───────┴────────────────────┴───────────┴───────┘

Printed as an error (standard error)

Runtime error near line 9: NOT NULL constraint failed: student.name
Runtime error near line 10: UNIQUE constraint failed: student.email
Runtime error near line 11: CHECK constraint failed: programme IN ('BTech', 'MSc', 'PhD')
Runtime error near line 12: CHECK constraint failed: score BETWEEN 0 AND 100

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < constraints_at_work.sql

Each refused row was refused by a different constraint, and SQLite names it in the message: NOT NULL on the name, UNIQUE on the email, and the two CHECK conditions on the programme and the score. Two results surprise people:

  • Two students without an email were both accepted. For a UNIQUE constraint, SQLite’s documentation says, NULL values are “distinct from all other values, including other NULLs”, and PostgreSQL behaves the same by default. Since PostgreSQL 15, UNIQUE NULLS NOT DISTINCT makes it treat NULLs as equal, so that only one row may lack the value. When a value must be present and unique, declare it NOT NULL UNIQUE.
  • A missing score passed CHECK (score BETWEEN 0 AND 100). A CHECK fails only when its condition is false; for NULL the condition is unknown, and both engines document that true or unknown passes. Add NOT NULL when the value is required.

A CHECK sees one row at a time. PostgreSQL’s documentation rules out subqueries in a CHECK expression and lets it read only the columns of the current row; SQLite does not allow subqueries in them either, so “the total of all payments may not exceed the invoice” needs a trigger or application code. A CHECK with an IN list, as on the programme column, is the closest thing to the domains of the relational model. PostgreSQL also has named domains, a type with constraints of its own that many columns can share, for example CREATE DOMAIN percentage AS numeric CHECK (VALUE BETWEEN 0 AND 100). Its documentation advises keeping NOT NULL off the domain and putting it on the columns that need it.

SQLite Online (SQL Playground) Run the examples of this lesson in a full SQLite editor in your browser, then add your own constraints and see which inserts they refuse.

Foreign keys and referential actions

A foreign key is checked from both ends. Inserting or updating a child row checks that the parent exists. Deleting a parent row, or changing its key, raises a question: what happens to the rows that refer to it? The foreign key’s referential action answers it, separately ON DELETE and ON UPDATE:

  • NO ACTION, the default: the change is refused if references are left dangling when the constraint is checked, at the end of the statement or, for a deferred constraint, at commit.
  • RESTRICT: the change is refused at once if any row refers to the parent.
  • CASCADE: the referring rows are deleted too, or updated to the new key value.
  • SET NULL: the referring columns become NULL.
  • SET DEFAULT: the referring columns get their default value, which must itself match a parent row unless it is NULL.
Deleting hostel 1: CASCADE deletes its rooms, SET NULL clears residents, SET DEFAULT resets, RESTRICT and NO ACTION refuse.DELETE FROM hostelWHERE hostel_id = 1Rows that referto hostel 1room:ON DELETE CASCADEits rooms aredeleted tooresident:ON DELETE SET NULLresidents stay;hostel_id becomes NULLON DELETE SET DEFAULThostel_id becomes itsdefault, which must matcha hostel unless NULLwarden_duty:ON DELETE RESTRICTthe DELETE isrefused at onceNO ACTION, the defaultrefused when the checkruns, unless the referencewas fixed firstevery foreign keythat refers to itis checked

What each referential action does when a referenced row is deleted

Text description of the diagram

At the top is the statement DELETE FROM hostel WHERE hostel_id = 1. An arrow labelled "every foreign key that refers to it is checked" leads to a box of five actions, one under the other:

  1. room, declared ON DELETE CASCADE: its rooms are deleted too.
  2. resident, declared ON DELETE SET NULL: the residents stay and their hostel_id becomes NULL.
  3. ON DELETE SET DEFAULT: hostel_id becomes the column's default value, which must itself match a hostel unless it is NULL.
  4. warden_duty, declared ON DELETE RESTRICT: the DELETE is refused at once.
  5. NO ACTION, the default: the DELETE is refused when the constraint is checked, unless the reference was fixed first.

The choice follows the meaning of the data. Rooms exist only as parts of a hostel, so they go with it. Residents remain students when their hostel closes, so their reference is cleared. A scheduled warden duty means that the hostel is still in use, so the delete is refused:

Three children, three ON DELETE actions SQL · referential_actions.sql
PRAGMA foreign_keys = ON;
CREATE TABLE hostel (hostel_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE room (
  room_id   INTEGER PRIMARY KEY,
  hostel_id INTEGER NOT NULL REFERENCES hostel ON DELETE CASCADE      -- rooms go with their hostel
);
CREATE TABLE resident (
  roll_no   INTEGER PRIMARY KEY,
  hostel_id INTEGER REFERENCES hostel ON DELETE SET NULL             -- residents stay, unassigned
);
CREATE TABLE warden_duty (
  duty_id   INTEGER PRIMARY KEY,
  hostel_id INTEGER NOT NULL REFERENCES hostel ON DELETE RESTRICT     -- duties block the delete
);
INSERT INTO hostel VALUES (1, 'North'), (2, 'South');
INSERT INTO room VALUES (11, 1), (12, 1), (21, 2);
INSERT INTO resident VALUES (101, 1), (102, 2);
INSERT INTO warden_duty VALUES (900, 2);

DELETE FROM hostel WHERE hostel_id = 1;   -- cascades to rooms, sets residents' hostel to NULL
DELETE FROM hostel WHERE hostel_id = 2;   -- refused: a warden duty still refers to it

SELECT 'hostels' AS t, count(*) AS rows_left FROM hostel
UNION ALL SELECT 'rooms', count(*) FROM room
UNION ALL SELECT 'residents', count(*) FROM resident;
SELECT roll_no, quote(hostel_id) AS hostel_id FROM resident ORDER BY roll_no;

Output (exit status 1)

┌───────────┬───────────┐
│     t     │ rows_left │
├───────────┼───────────┤
│ hostels   │ 1         │
│ rooms     │ 1         │
│ residents │ 2         │
└───────────┴───────────┘
┌─────────┬───────────┐
│ roll_no │ hostel_id │
├─────────┼───────────┤
│ 101     │ NULL      │
│ 102     │ 2         │
└─────────┴───────────┘

Printed as an error (standard error)

Runtime error near line 21: FOREIGN KEY constraint failed

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < referential_actions.sql

Deleting the North hostel removed its two rooms and set resident 101’s hostel to NULL. Deleting the South hostel was refused because a warden duty still refers to it, and nothing about it changed. SQLite reports every foreign key violation with the same short message, FOREIGN KEY constraint failed; PostgreSQL’s messages name the constraint and the key value.

NO ACTION and RESTRICT both refuse to leave a dangling reference, so the difference only shows inside a transaction. PostgreSQL’s documentation puts it in one sentence: unlike NO ACTION, RESTRICT “does not allow the check to be deferred”. SQLite’s documentation says the same: even on a deferred foreign key, RESTRICT “causes SQLite to return an error immediately”:

NO ACTION waits, RESTRICT does not SQL · restrict_or_no_action.sql
PRAGMA foreign_keys = ON;
CREATE TABLE parent (id INTEGER PRIMARY KEY);
CREATE TABLE child_no_action (
  parent_id INTEGER REFERENCES parent (id) ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED);
CREATE TABLE child_restrict (
  parent_id INTEGER REFERENCES parent (id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED);
INSERT INTO parent VALUES (1), (2);
INSERT INTO child_no_action VALUES (1);
INSERT INTO child_restrict VALUES (2);

-- NO ACTION: the check waits until COMMIT, so the transaction may repair the reference first.
BEGIN;
DELETE FROM parent WHERE id = 1;
UPDATE child_no_action SET parent_id = NULL;
COMMIT;

-- RESTRICT: the delete is refused at once, even though the constraint is deferrable.
BEGIN;
DELETE FROM parent WHERE id = 2;
UPDATE child_restrict SET parent_id = NULL;
COMMIT;

SELECT id AS parents_left FROM parent;

Output (exit status 1)

┌──────────────┐
│ parents_left │
├──────────────┤
│ 2            │
└──────────────┘

Printed as an error (standard error)

Runtime error near line 19: FOREIGN KEY constraint failed

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < restrict_or_no_action.sql

With NO ACTION, the transaction deleted parent 1, repaired its child before committing, and the commit succeeded. With RESTRICT, the delete of parent 2 failed on the spot, so the parent is still there. Choose RESTRICT when no transaction should ever be allowed to remove a parent that is in use, and NO ACTION when a transaction may delete and repair in either order.

Two more details matter in practice. A composite foreign key whose columns are partly NULL is not checked at all under the default matching rule; PostgreSQL’s MATCH FULL refuses such half-filled references, while SQLite treats every foreign key as MATCH SIMPLE. And PostgreSQL does not create an index on the referring columns, although every delete of a parent must find its children; its documentation suggests indexing them, which is usually right for tables you join or delete from often.

SQL to ER Diagram Paste the CREATE TABLE statements of this lesson to see each foreign key drawn as a relationship between the tables.

SQLite: foreign keys are off until you switch them on

SQLite parsed REFERENCES clauses long before it enforced them, and by the time enforcement arrived, in version 3.6.19, a great many databases already declared foreign keys, some of them incorrectly. To keep those databases working, SQLite leaves enforcement off on every new connection:

An orphan gets in, then the check is switched on SQL · foreign_keys_off.sql
CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer TEXT NOT NULL);
CREATE TABLE order_line (
  order_id INTEGER NOT NULL REFERENCES orders (order_id),
  item     TEXT    NOT NULL
);
INSERT INTO orders VALUES (1, 'Asha');

-- Foreign keys are off on a new SQLite connection: this orphan line is accepted.
PRAGMA foreign_keys;
INSERT INTO order_line VALUES (7, 'pen');

-- The pragma does nothing inside a transaction...
BEGIN;
PRAGMA foreign_keys = ON;
PRAGMA foreign_keys;
COMMIT;

-- ...and works outside one. New orphans are refused from now on, old ones stay.
PRAGMA foreign_keys = ON;
INSERT INTO order_line VALUES (8, 'ink');
INSERT INTO order_line VALUES (1, 'notebook');
PRAGMA foreign_key_check;
SELECT order_id, item FROM order_line;

Output (exit status 1)

┌──────────────┐
│ foreign_keys │
├──────────────┤
│ 0            │
└──────────────┘
┌──────────────┐
│ foreign_keys │
├──────────────┤
│ 0            │
└──────────────┘
┌────────────┬───────┬────────┬──────┐
│   table    │ rowid │ parent │ fkid │
├────────────┼───────┼────────┼──────┤
│ order_line │ 1     │ orders │ 0    │
└────────────┴───────┴────────┴──────┘
┌──────────┬──────────┐
│ order_id │   item   │
├──────────┼──────────┤
│ 7        │ pen      │
│ 1        │ notebook │
└──────────┴──────────┘

Printed as an error (standard error)

Runtime error near line 20: FOREIGN KEY constraint failed

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < foreign_keys_off.sql

The first order line refers to order 7, which does not exist, and SQLite stored it. Switching enforcement on inside a transaction did nothing, as the documentation warns (“This pragma is a no-op within a transaction”). Outside the transaction the pragma took effect, and the next orphan was refused. The old orphan stayed: turning the check on does not look at existing rows. PRAGMA foreign_key_check does, and it lists the table, the rowid, the parent table and the number of the broken foreign key. PRAGMA integrity_check does not report foreign key errors at all.

The habit that follows is simple. Make the code that opens a connection run PRAGMA foreign_keys = ON every time, and run PRAGMA foreign_key_check in tests and after migrations, because a single connection without the pragma, in a script or an admin tool, is enough to let orphans in.

Deferred constraints and circular references

Every department has a manager, and every employee, the manager included, belongs to a department. Each table now has a NOT NULL foreign key to the other, so whichever row you insert first refers to a row that does not exist yet. Constraints are checked at the end of each statement by default, which makes the first insert fail. A deferred constraint is checked at COMMIT instead, so both rows can be inserted in one transaction:

A department and its manager, inserted together SQL · deferred_cycle.sql
PRAGMA foreign_keys = ON;
-- Every department has a manager, and every employee belongs to a department.
CREATE TABLE department (
  dept_id    INTEGER PRIMARY KEY,
  name       TEXT    NOT NULL,
  manager_id INTEGER NOT NULL REFERENCES employee (emp_id) DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE employee (
  emp_id  INTEGER PRIMARY KEY,
  name    TEXT    NOT NULL,
  dept_id INTEGER NOT NULL REFERENCES department (dept_id) DEFERRABLE INITIALLY DEFERRED
);

-- On its own, each statement is a transaction, and the first insert finds no manager 501.
INSERT INTO department VALUES (10, 'Chemistry', 501);

-- In one transaction, the deferred checks run at COMMIT, when both rows exist.
BEGIN;
INSERT INTO department VALUES (10, 'Chemistry', 501);
INSERT INTO employee VALUES (501, 'Meera', 10);
COMMIT;

-- A COMMIT that still breaks a deferred check fails, and in SQLite the transaction stays open.
BEGIN;
INSERT INTO department VALUES (11, 'Biology', 777);
COMMIT;
SELECT count(*) AS departments_seen_inside FROM department;
ROLLBACK;
SELECT d.name AS department, e.name AS manager
FROM department AS d JOIN employee AS e ON e.emp_id = d.manager_id;

Output (exit status 1)

┌─────────────────────────┐
│ departments_seen_inside │
├─────────────────────────┤
│ 2                       │
└─────────────────────────┘
┌────────────┬─────────┐
│ department │ manager │
├────────────┼─────────┤
│ Chemistry  │ Meera   │
└────────────┴─────────┘

Printed as an error (standard error)

Runtime error near line 15: FOREIGN KEY constraint failed
Runtime error near line 26: FOREIGN KEY constraint failed

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < deferred_cycle.sql

Outside a transaction each statement commits on its own, so even a deferred constraint fails at once. Inside BEGIN and COMMIT, the department and its manager arrived one after the other, and the check at commit found both. The last transaction shows what happens when a deferred check still fails: the COMMIT is refused. In SQLite the transaction then stays open, which is why the count inside it still sees two departments, and only ROLLBACK ends it. PostgreSQL behaves differently: there, a COMMIT whose deferred check fails rolls the whole transaction back.

In SQLite only foreign keys can be deferred, with DEFERRABLE INITIALLY DEFERRED; the pragma defer_foreign_keys defers all of them for one transaction. PostgreSQL accepts DEFERRABLE on UNIQUE, PRIMARY KEY, EXCLUDE and foreign key constraints, but not on NOT NULL or CHECK, and SET CONSTRAINTS … DEFERRED or IMMEDIATE changes the timing inside a transaction. When deferral is not available, make one of the two references nullable, insert the department without a manager, and fill the manager in with an UPDATE once the employee exists.

Types: SQLite’s flexible columns and STRICT tables

SQLite’s documentation is frank that in an ordinary table “datatypes are advisory rather than mandatory”: a column declared INTEGER converts text that looks like a number and stores anything else as it is. Since version 3.37.0, a table declared STRICT refuses values that cannot be stored in the declared type without loss:

The same four values, in an ordinary and a STRICT table SQL · strict_typing.sql
-- An ordinary SQLite table converts what it can and stores the rest as it is.
CREATE TABLE marks_flexible (roll_no INTEGER, score INTEGER);
INSERT INTO marks_flexible VALUES (101, '87'), (102, 'abc'), (103, 91.0), (104, 91.5);
SELECT roll_no, score, typeof(score) AS stored_as FROM marks_flexible;

-- A STRICT table accepts only values that fit the declared type without loss.
CREATE TABLE marks_strict (roll_no INTEGER, score INTEGER) STRICT;
INSERT INTO marks_strict VALUES (101, '87');
INSERT INTO marks_strict VALUES (102, 'abc');
INSERT INTO marks_strict VALUES (103, 91.0);
INSERT INTO marks_strict VALUES (104, 91.5);
SELECT roll_no, score, typeof(score) AS stored_as FROM marks_strict;

Output (exit status 1)

┌─────────┬───────┬───────────┐
│ roll_no │ score │ stored_as │
├─────────┼───────┼───────────┤
│ 101     │ 87    │ integer   │
│ 102     │ abc   │ text      │
│ 103     │ 91    │ integer   │
│ 104     │ 91.5  │ real      │
└─────────┴───────┴───────────┘
┌─────────┬───────┬───────────┐
│ roll_no │ score │ stored_as │
├─────────┼───────┼───────────┤
│ 101     │ 87    │ integer   │
│ 103     │ 91    │ integer   │
└─────────┴───────┴───────────┘

Printed as an error (standard error)

Runtime error near line 9: cannot store TEXT value in INTEGER column marks_strict.score
Runtime error near line 11: cannot store REAL value in INTEGER column marks_strict.score

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < strict_typing.sql

The ordinary table converted '87' and 91.0 to integers, kept 'abc' as text and 91.5 as a real number, so the column now holds three types. The STRICT table converted the two values that fit and refused the other two. A STRICT table allows only the types INT, INTEGER, REAL, TEXT, BLOB and ANY, and it also keeps NULLs out of its primary key, which ends the quirk of the first lesson of this module. PostgreSQL is strict about types too, with one difference worth knowing: it refuses 'abc' for an integer column, but when it stores 91.5 in an integer column it converts the number to 92 instead of refusing it.

Changing the constraints of a table that holds data

A constraint added to a full table must check the rows already there. In PostgreSQL, ALTER TABLE … ADD CONSTRAINT scans the table while it blocks other writes. With NOT VALID, the scan is skipped and new rows are checked at once; VALIDATE CONSTRAINT checks the old rows later, while other transactions keep writing. This works for foreign key, CHECK and not-null constraints, and it is the usual way to add a rule to a large, busy table.

SQLite’s ALTER TABLE could not add or remove the NOT NULL and CHECK constraints of existing columns until version 3.53.0, whose release notes list “adding and removing NOT NULL and CHECK constraints”, for example with ALTER TABLE marks ALTER COLUMN score SET NOT NULL. The SQLite inside the browser runner here is version 3.49.1, so the example shows the error you get there, and the rebuild that works in every version:

Adding NOT NULL in an older SQLite SQL · alter_not_null.sql
CREATE TABLE marks (roll_no INTEGER NOT NULL PRIMARY KEY, score INTEGER);
INSERT INTO marks VALUES (101, 72), (102, 64);

-- SQLite 3.53.0 added this form; the SQLite 3.49.1 inside sql.js does not know it.
ALTER TABLE marks ALTER COLUMN score SET NOT NULL;

-- The way that works everywhere: build the new shape, copy the rows, swap the names.
CREATE TABLE marks_new (roll_no INTEGER NOT NULL PRIMARY KEY, score INTEGER NOT NULL);
INSERT INTO marks_new SELECT roll_no, score FROM marks;
DROP TABLE marks;
ALTER TABLE marks_new RENAME TO marks;
INSERT INTO marks VALUES (103, NULL);
SELECT name, "notnull" FROM pragma_table_info('marks');

Output (exit status 1)

┌─────────┬─────────┐
│  name   │ notnull │
├─────────┼─────────┤
│ roll_no │ 1       │
│ score   │ 1       │
└─────────┴─────────┘

Printed as an error (standard error)

Parse error near line 5: near "ALTER": syntax error
Runtime error near line 12: NOT NULL constraint failed: marks.score

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < alter_not_null.sql

The rebuild creates a table of the new shape, copies the rows, drops the old table and renames the new one; the catalog then shows score as NOT NULL and the next NULL is refused. On a real database, run those steps inside one transaction, and recreate any indexes, triggers and views that belonged to the old table.

Rules across rows: no overlapping bookings

“A room cannot be booked twice for the same time” is a rule about pairs of rows, and none of the constraints so far can say it: UNIQUE (room, start_time) still lets 10:00 to 12:00 and 11:00 to 13:00 both in. PostgreSQL 18 adds temporal constraints for this. A primary key or unique constraint whose last column is a range can end with WITHOUT OVERLAPS:

  • PRIMARY KEY (room, during WITHOUT OVERLAPS), where during is a tstzrange, allows many bookings per room as long as their ranges do not overlap. PostgreSQL enforces it like the exclusion constraint EXCLUDE USING gist (room WITH =, during WITH &&).
  • The other columns must be comparable for equality in a GiST index, so a text column such as room needs the btree_gist extension; without it, PostgreSQL 18 refuses to create the table because text has no default GiST operator class.
  • An overlapping insert is refused with “conflicting key value violates exclusion constraint”, and the message shows both bookings. Ranges such as [10:00, 12:00) include their start but not their end, so a booking from 12:00 is accepted next to one that ends at 12:00.
  • A foreign key can refer to such a key with PERIOD: FOREIGN KEY (room, PERIOD during) REFERENCES room_rate (room, PERIOD valid) requires the referenced rows’ periods, taken together, to cover the whole period of the referring row. Temporal foreign keys support only NO ACTION; PostgreSQL refuses CASCADE, SET NULL, SET DEFAULT and RESTRICT for them.

SQLite has nothing similar, so the rule needs a trigger that looks for an overlapping row before each insert:

A trigger that refuses overlapping bookings SQL · no_overlap_trigger.sql
-- Times are minutes after midnight; a booking runs from its start up to, not including, its end.
CREATE TABLE booking (
  booking_id INTEGER PRIMARY KEY,
  room       TEXT    NOT NULL,
  start_min  INTEGER NOT NULL,
  end_min    INTEGER NOT NULL,
  CHECK (start_min < end_min)
);

-- SQLite cannot declare "no two bookings of a room overlap", so a trigger checks every insert.
CREATE TRIGGER booking_no_overlap
BEFORE INSERT ON booking
WHEN EXISTS (
  SELECT 1 FROM booking AS b
  WHERE b.room = NEW.room AND b.start_min < NEW.end_min AND NEW.start_min < b.end_min
)
BEGIN
  SELECT RAISE(ABORT, 'room already booked for part of that time');
END;

INSERT INTO booking (room, start_min, end_min) VALUES ('R101', 600, 720);  -- 10:00 to 12:00
INSERT INTO booking (room, start_min, end_min) VALUES ('R101', 720, 780);  -- 12:00 to 13:00: touches only
INSERT INTO booking (room, start_min, end_min) VALUES ('R101', 690, 750);  -- 11:30 to 12:30: overlaps
INSERT INTO booking (room, start_min, end_min) VALUES ('R102', 690, 750);  -- the same time, another room

SELECT room,
       printf('%02d:%02d', start_min / 60, start_min % 60) AS starts,
       printf('%02d:%02d', end_min / 60, end_min % 60)     AS ends
FROM booking ORDER BY room, start_min;

Output (exit status 1)

┌──────┬────────┬───────┐
│ room │ starts │ ends  │
├──────┼────────┼───────┤
│ R101 │ 10:00  │ 12:00 │
│ R101 │ 12:00  │ 13:00 │
│ R102 │ 11:30  │ 12:30 │
└──────┴────────┴───────┘

Printed as an error (standard error)

Runtime error near line 23: room already booked for part of that time

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < no_overlap_trigger.sql

The trigger let in the booking that only touches the first one at 12:00 and the same time in another room, and refused the booking that overlaps. Two bookings overlap when each starts before the other ends; that one condition covers every way two ranges can overlap. A complete solution also needs a BEFORE UPDATE trigger, or a booking could be moved onto another.

A trigger or an application check is weaker than a declared constraint when several transactions write at once. PostgreSQL’s documentation calls it “very difficult to enforce business rules regarding data integrity” in Read Committed transactions, the default, because two concurrent transactions do not see each other’s uncommitted rows: both checks can find no overlap, and both bookings get in. An exclusion or temporal constraint is checked through its index, which does see concurrent insertions. SQLite avoids this race only because it allows “one writer at any instant in time”. The SQL standard also defines CREATE ASSERTION for rules that span tables, but PostgreSQL lists assertions among the features it does not support, and it answers the statement with “CREATE ASSERTION is not yet implemented”.

Constraints the database does not enforce

Sometimes a rule is declared but not enforced. PostgreSQL 18 lets CHECK and foreign key constraints be declared NOT ENFORCED; its documentation says the database then “will not check the constraint”, leaving it to the application, which makes such a constraint documentation that tools can read rather than a guarantee. SQLite has per-connection switches that go further: foreign keys are off unless enabled, PRAGMA ignore_check_constraints = ON skips every CHECK, and setting defer_foreign_keys back to 0 in the middle of a transaction can let it commit with violations, as its documentation cautions. These are the gaps that Codd’s rules 10 and 12 were written to close. Treat any of them as a temporary measure, for a bulk load whose data you check afterwards, never as a normal setting.

GATE CS

“Integrity constraints” is a topic of its own in the databases section of the GATE 2027 syllabus for Computer Science. This lesson covers it: which rows a set of constraints refuses, and what each referential action does to the rows that refer to a deleted one. The quiz below practises both.

Interview questions

What are entity integrity and referential integrity?

They are the two integrity rules that Codd stated for every relational database. Entity integrity says that no part of a primary key may be NULL, because the key is what tells rows apart and an unknown value cannot do that; SQL enforces it with PRIMARY KEY, which implies NOT NULL (in SQLite, add NOT NULL or use a STRICT table). Referential integrity says that every non-NULL foreign key value must match the key of an existing row in the table it refers to. SQL enforces it with FOREIGN KEY constraints, which check inserts and updates of the child and, through their ON DELETE and ON UPDATE actions, deletes and key changes of the parent.

Key takeaways

  • Declare rules as constraints so that every program obeys them: Codd’s rule 10, integrity independence.
  • UNIQUE allows any number of NULLs by default, and CHECK passes when its condition is unknown; add NOT NULL when a value is required.
  • A CHECK sees one row; rules across rows need exclusion or temporal constraints, triggers or application code.
  • Pick the referential action from the meaning of the data: CASCADE for parts, SET NULL for optional links, RESTRICT or NO ACTION to refuse. Only NO ACTION can wait for a deferred check.
  • In SQLite, run PRAGMA foreign_keys = ON on every connection and PRAGMA foreign_key_check after migrations.
  • Deferred constraints let circular references be inserted in one transaction; a failed COMMIT leaves an SQLite transaction open but rolls a PostgreSQL one back.
  • STRICT tables make SQLite refuse values of the wrong type; PostgreSQL refuses wrong text but rounds 91.5 to 92 in an integer column.
  • PostgreSQL 18’s WITHOUT OVERLAPS and PERIOD express “no overlapping bookings” and “covered for the whole period” as declared constraints.

Exercise

Exercise · Easy · SQL

Write the constraints that keep bad student rows out

A college copies applications into the table students. Rewrite its CREATE TABLE statement so that the database itself refuses every row that breaks these rules and accepts every row that keeps them:

- roll_no is a whole number greater than 0, and no two students share one. - name must be given and must not be blank (empty or only spaces). - email must be given, must contain an @ with at least one character before it and one after it, and no two students share one. - year must be given and is a whole number from 1 to 5. - score may be missing (NULL) until the first exam; otherwise it is from 0 to 100.

Keep the column names and types. Leave the last two statements as they are: they copy the applications in the order they arrived with INSERT OR IGNORE, which skips each row that breaks a NOT NULL, UNIQUE, PRIMARY KEY or CHECK constraint instead of stopping, and then list the roll numbers that got in. When two applications share a roll number or an email, the one that arrived first is kept.

Starter code · student_constraints.sql

-- Add the constraints that the rules ask for.
CREATE TABLE students (
  roll_no INTEGER,
  name    TEXT,
  email   TEXT,
  year    INTEGER,
  score   REAL
);

-- Leave these two statements as they are.
INSERT OR IGNORE INTO students (roll_no, name, email, year, score)
SELECT roll_no, name, email, year, score FROM applications ORDER BY arrival;
SELECT roll_no FROM students ORDER BY roll_no;
The sample tests · tests.yaml
# Sample tests: each one fills the table applications, runs your script and compares the roll numbers that got into
# students (in any order).
tests:
  - name: accepts the six good rows and refuses the six bad ones
    setup: |
      CREATE TABLE applications (arrival INTEGER, roll_no INTEGER, name TEXT, email TEXT, year INTEGER, score REAL);
      INSERT INTO applications VALUES
        (1,  101, 'Asha',   'asha@uni.example',   1, 81.5),
        (2,  102, 'Bilal',  'bilal@uni.example',  5, NULL),
        (3,  103, 'Chitra', 'chitra@uni.example', 3, 100),
        (4,  104, 'Dev',    'dev@uni.example',    2, 0),
        (5,  105, 'Esha',   'e@uni.example',      4, 64.25),
        (6,  106, 'Farah',  'farah@uni.example',  1, NULL),
        (7,  101, 'Gopal',  'gopal@uni.example',  2, 70),
        (8,  107, NULL,     'hema@uni.example',   2, 70),
        (9,  108, '   ',    'indu@uni.example',   2, 70),
        (10, 109, 'Jai',    'asha@uni.example',   2, 70),
        (11, 110, 'Kiran',  'kiran.uni.example',  2, 70),
        (12, 111, 'Lata',   'lata@uni.example',   6, 70);
    expected:
      columns: [roll_no]
      rows:
        - [101]
        - [102]
        - [103]
        - [104]
        - [105]
        - [106]
  - name: refuses the other ways a row can break the rules
    setup: |
      CREATE TABLE applications (arrival INTEGER, roll_no INTEGER, name TEXT, email TEXT, year INTEGER, score REAL);
      INSERT INTO applications VALUES
        (1, 201, 'Mira', 'mira@uni.example', 5, 100),
        (2, 0,   'Neel', 'neel@uni.example', 1, 50),
        (3, 202, 'Om',   NULL,               1, 50),
        (4, 203, 'Pia',  '@uni.example',     1, 50),
        (5, 204, 'Ravi', 'ravi@',            1, 50),
        (6, 205, 'Sara', 'sara@uni.example', 0, 50),
        (7, 206, 'Tara', 'tara@uni.example', 2, 100.5),
        (8, 207, 'Uma',  'uma@uni.example',  NULL, 50),
        (9, 208, 'Vik',  'vik@uni.example',  2, -1);
    expected:
      columns: [roll_no]
      rows:
        - [201]
  - name: keeps the first of two applications that share a roll number or an email
    setup: |
      CREATE TABLE applications (arrival INTEGER, roll_no INTEGER, name TEXT, email TEXT, year INTEGER, score REAL);
      INSERT INTO applications VALUES
        (1, 301, 'Wen',  'wen@uni.example', 1, 50),
        (2, 302, 'Xia',  'wen@uni.example', 2, 60),
        (3, 303, 'Yash', 'y@uni.example',   3, NULL),
        (4, 303, 'Zoe',  'z@uni.example',   3, NULL);
    expected:
      columns: [roll_no]
      rows:
        - [301]
        - [303]
A hint

Each rule becomes a constraint on its column: PRIMARY KEY, UNIQUE, NOT NULL or CHECK (…). Remember that a CHECK whose expression is NULL counts as satisfied, so a rule that a value "must be given" needs NOT NULL as well. trim(name) <> '' catches blank names, and email LIKE '_%@_%' asks for at least one character on each side of the @.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 7 A column is declared score REAL CHECK (score BETWEEN 0 AND 100). Which insert does the database accept?

    Choose one answer.

    Show the answer to question 1

    Answer: A row whose score is NULL

    For NULL, score BETWEEN 0 AND 100 is unknown, and a CHECK constraint fails only when its expression is false, so the row is accepted. PostgreSQL and SQLite both document this. Add NOT NULL when a value must be given.

  2. Question 2 of 7 A table has email TEXT UNIQUE and no other constraint. You insert five rows: NULL, NULL, 'a@x.example', NULL and 'a@x.example'. How many rows does the table keep?

    Type a number.

    Show the answer to question 2

    Answer: 4 rows

    By default both PostgreSQL and SQLite treat NULLs as distinct from each other in a UNIQUE constraint, so all three NULLs are kept. The second 'a@x.example' is refused. That leaves 3 + 1 = 4 rows. PostgreSQL's UNIQUE NULLS NOT DISTINCT would keep only one NULL.

  3. Question 3 of 7 Rooms belong to a hostel and are meaningless without it; residents should stay on record when their hostel closes. Which pair of actions fits room.hostel_id and resident.hostel_id?

    Choose one answer.

    Show the answer to question 3

    Answer: ON DELETE CASCADE for room, ON DELETE SET NULL for resident

    CASCADE deletes the rooms along with their hostel, which suits rows that only exist as parts of their parent. SET NULL keeps each resident and clears the reference, which needs resident.hostel_id to allow NULL. RESTRICT would make it impossible to close a hostel that still has rooms.

  4. Question 4 of 7 A foreign key is DEFERRABLE INITIALLY DEFERRED. Inside one transaction you delete a parent row, then point its child rows elsewhere, then COMMIT. With which ON DELETE action does this succeed?

    Choose one answer.

    Show the answer to question 4

    Answer: NO ACTION

    NO ACTION is checked when the constraint is checked, here at COMMIT, so the transaction can repair the children first. RESTRICT refuses the DELETE at once, even when the constraint is deferrable, as both PostgreSQL's and SQLite's documentation describe.

  5. Question 5 of 7 A new SQLite connection runs INSERT INTO order_line VALUES (7, 'pen'), and order 7 does not exist. What happens?

    Choose one answer.

    Show the answer to question 5

    Answer: The row is stored, because foreign keys are off until PRAGMA foreign_keys = ON

    SQLite turns foreign key enforcement off by default for compatibility, and each connection must run PRAGMA foreign_keys = ON, outside a transaction. Turning it on later does not check old rows: PRAGMA foreign_key_check finds them.

  6. Question 6 of 7 In a STRICT table, score is INTEGER. Which value is refused?

    Choose one answer.

    Show the answer to question 6

    Answer: 91.5

    A STRICT table first converts a value the usual way and refuses it only if it cannot be stored as the declared type without loss. The text '87' becomes the integer 87 and 91.0 becomes 91, but 91.5 would lose its fraction, so it is refused. NULL is allowed unless the column is NOT NULL.

  7. Question 7 of 7 A PostgreSQL 18 table has PRIMARY KEY (room, during WITHOUT OVERLAPS), with during a range [start, end). Room R101 is booked from 10:00 to 12:00. Which new booking of R101 is accepted?

    Choose one answer.

    Show the answer to question 7

    Answer: 12:00 to 13:00

    With half-open ranges a booking ends just before its end time, so [10:00, 12:00) and [12:00, 13:00) do not overlap and are both allowed. Each of the other three shares at least part of 10:00 to 12:00 and is refused.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.