Chapter 05 · Constraints, Foreign Keys, Conflict Handling, and Integrity

NOT NULL, UNIQUE, PRIMARY KEY, CHECK, and DEFAULT

Turn schema declarations into enforceable statements about valid database state, then prove each rule with deliberately invalid rows and SQLite-specific edge cases.

Beginner90–110 minutesConstraint-proof labSQLite 3.53.4 baselineForeign keys require supported build + connection policyGenerated columns require SQLite 3.31.0+Last reviewed: August 2026

Learning outcomes

In the prerequisite modeling course, constraints represented facts such as “a device code must be unique” or “severity must stay within a valid range.” SQLite turns those facts into executable rules. This lesson treats constraints as part of the database’s state contract: if a row would make the database invalid, SQLite should reject the write before that state becomes durable.

01

Use NOT NULL, UNIQUE, PRIMARY KEY, CHECK, and DEFAULT as statements about valid state rather than decoration.

02

Distinguish column-level and table-level declarations, including composite UNIQUE and PRIMARY KEY constraints.

03

Explain SQLite’s historical PRIMARY KEY/NULL nuance in ordinary rowid tables and the special INTEGER PRIMARY KEY case.

04

Predict CHECK behavior when an expression evaluates to zero, non-zero, or NULL, and recognize that CHECK expressions cannot contain subqueries.

05

Explain when DEFAULT is used and when default expressions are evaluated.

06

Prove each rule with controlled invalid inserts and diagnose the constraint that rejected the row.

Constraints describe states the database refuses to store

A constraint is more useful than an application-side if statement because every writer that goes through SQLite—CLI, migration, script, test, or application connection—meets the same rule. Application validation still matters for friendly error messages and richer business logic, but durable invariants belong as close to the data as SQLite can express them.

RequirementSQLite schema ruleState that becomes impossible
Every device has a codeNOT NULLA durable row whose code is SQL NULL.
No two devices in one site share a codeUNIQUE(site_id, code)Two rows with the same site/code key.
Every row has a stable identityPRIMARY KEYDuplicate key values; exact NULL behavior depends on table form.
Severity is 1 through 5CHECK(severity BETWEEN 1 AND 5)A non-NULL severity outside the permitted range.
New notes start openDEFAULT 'open'Omitting status no longer means an unspecified application guess.
Constraint versus validation

A database constraint protects persistent state. Application validation protects the user experience and may enforce rules SQLite cannot express directly. Production systems often need both layers.

NOT NULL: absence is not valid for this column

NOT NULL is a column constraint in SQLite. It means an INSERT or UPDATE may not leave that column as SQL NULL. An empty string, zero, and the word unknown are not NULL, so use CHECK or application validation if those values are also invalid.

sql · NOT NULL rejects an invalid state
DROP TABLE IF EXISTS constraint_note;CREATE TABLE constraint_note (    note_id     INTEGER PRIMARY KEY,    summary     TEXT NOT NULL,    severity    INTEGER NOT NULL DEFAULT 1);INSERT INTO constraint_note(summary) VALUES ('Inspect seal');SELECT note_id, summary, severity FROM constraint_note;-- Deliberately invalid:INSERT INTO constraint_note(summary) VALUES (NULL);

The first insert succeeds and uses the default severity of 1. The second fails with a NOT NULL constraint error. The row is rejected by the SQLite engine itself; the application does not need to remember a separate validation rule just to protect the file.

UNIQUE and PRIMARY KEY: identity and alternate keys

A UNIQUE constraint says that no two rows may have the same non-NULL key combination. A table can have many UNIQUE constraints but only one PRIMARY KEY. For multi-column rules, a table-level declaration is usually clearer.

sql · single-column and composite uniqueness
DROP TABLE IF EXISTS device_rule_demo;CREATE TABLE device_rule_demo (    device_id INTEGER PRIMARY KEY,    site_id   INTEGER NOT NULL,    code      TEXT NOT NULL,    serial_no TEXT,    UNIQUE (site_id, code),    UNIQUE (serial_no));INSERT INTO device_rule_demo(site_id, code, serial_no)VALUES (1, 'PUMP-007', 'SN-A1');-- Same code is allowed at another site:INSERT INTO device_rule_demo(site_id, code, serial_no)VALUES (2, 'PUMP-007', 'SN-B1');-- Same site + code is not allowed:INSERT INTO device_rule_demo(site_id, code, serial_no)VALUES (1, 'PUMP-007', 'SN-C1');

The composite UNIQUE constraint models the actual business key: site_id plus code. This is stronger than making code globally unique when the requirements only promise uniqueness within a site.

SQLite PRIMARY KEY nuance: do not assume every form is identical

Chapter 3 showed that an exact INTEGER PRIMARY KEY in a normal rowid table aliases the rowid. There is another SQLite-specific historical behavior: in an ordinary non-STRICT rowid table, a PRIMARY KEY that is not an INTEGER PRIMARY KEY can accept NULL unless you also declare NOT NULL. WITHOUT ROWID and STRICT tables enforce PRIMARY KEY non-nullability, and an INTEGER PRIMARY KEY never stores NULL—supplying NULL asks SQLite to allocate a rowid value.

sql · historical NULL behavior in an ordinary rowid table
DROP TABLE IF EXISTS legacy_pk;CREATE TABLE legacy_pk (    code TEXT PRIMARY KEY,    label TEXT);INSERT INTO legacy_pk(code, label) VALUES (NULL, 'first null key');INSERT INTO legacy_pk(code, label) VALUES (NULL, 'second null key');SELECT rowid, quote(code), label FROM legacy_pk;DROP TABLE IF EXISTS explicit_pk;CREATE TABLE explicit_pk (    code TEXT PRIMARY KEY NOT NULL,    label TEXT);-- This now fails:INSERT INTO explicit_pk(code, label) VALUES (NULL, 'rejected');

For new schemas, do not build a design around the historical NULL allowance. If a non-integer primary key is logically mandatory, say so explicitly with NOT NULL, or use STRICT/WITHOUT ROWID where appropriate.

INTEGER PRIMARY KEY is different

INSERT INTO t(id, ...) VALUES(NULL, ...) on an id INTEGER PRIMARY KEY does not store a NULL key. SQLite allocates an integer rowid and stores that integer through the alias.

CHECK: row-local predicates, with SQL NULL semantics

A CHECK expression is evaluated when a row is inserted or updated. SQLite treats numeric zero as a violation. A non-zero result passes. Importantly, a NULL result also passes. Therefore a range check alone does not imply NOT NULL.

sql · CHECK is true/non-zero or NULL; zero fails
DROP TABLE IF EXISTS reading_rule;CREATE TABLE reading_rule (    reading_id INTEGER PRIMARY KEY,    temperature_c REAL,    severity INTEGER,    CHECK (severity BETWEEN 1 AND 5),    CHECK (temperature_c IS NULL OR temperature_c > -273.15));INSERT INTO reading_rule(temperature_c, severity) VALUES (20.5, 3);  -- passINSERT INTO reading_rule(temperature_c, severity) VALUES (NULL, NULL); -- both CHECKs passINSERT INTO reading_rule(temperature_c, severity) VALUES (-300, 2); -- failINSERT INTO reading_rule(temperature_c, severity) VALUES (10, 9);   -- fail

The second row is valid because the first CHECK sees NULL and the second explicitly allows NULL. If severity is required, add severity INTEGER NOT NULL CHECK(...). CHECK expressions may refer to columns in the same row, but SQLite does not allow subqueries inside a CHECK constraint. Rules such as “no more than three open notes per device” therefore need a different mechanism.

DEFAULT fills omitted values; it does not override explicit values

A default is used when an INSERT omits a column (or explicitly requests DEFAULT where the syntax permits). It is not a repair rule for an invalid explicit value. If a column is NOT NULL DEFAULT 1, explicitly inserting NULL still violates NOT NULL under the normal ABORT behavior.

sql · defaults are applied at insertion time
DROP TABLE IF EXISTS default_demo;CREATE TABLE default_demo (    event_id    INTEGER PRIMARY KEY,    status      TEXT NOT NULL DEFAULT 'open',    created_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,    token       INTEGER NOT NULL DEFAULT (40 + 2));INSERT INTO default_demo(event_id) VALUES (1);INSERT INTO default_demo(event_id, status) VALUES (2, 'closed');SELECT event_id, status, created_at, typeof(token) AS token_typeFROM default_demoORDER BY event_id;

Literal defaults are reused as values. Parenthesized constant expressions are evaluated for each inserted row, and CURRENT_TIME, CURRENT_DATE, and CURRENT_TIMESTAMP are special supported defaults. Default expressions cannot depend on another column, a table lookup, a bound parameter, or a subquery.

Column-level versus table-level syntax

Choose the form that communicates the rule. NOT NULL belongs to a column. A one-column UNIQUE or CHECK may be easiest to read beside the column. Composite keys and cross-column checks naturally belong at table level.

sql · table-level rules express relationships among columns
CREATE TABLE maintenance_window (    window_id   INTEGER PRIMARY KEY,    device_id   INTEGER NOT NULL,    starts_at   TEXT NOT NULL,    ends_at     TEXT,    status      TEXT NOT NULL DEFAULT 'planned'                CHECK (status IN ('planned','active','done','cancelled')),    UNIQUE (device_id, starts_at),    CHECK (ends_at IS NULL OR ends_at >= starts_at));

The final CHECK is a cross-column rule but still row-local: one row supplies both timestamps. It does not need to inspect any other row or table.

Lab: prove the FieldNotes constraint contract

Create a disposable table and run each numbered write separately. Predict the result before execution. When a statement fails, verify that the invalid row did not appear.

sql · only the valid state should remain
DROP TABLE IF EXISTS note_guard;CREATE TABLE note_guard (    note_id      INTEGER PRIMARY KEY,    device_code  TEXT NOT NULL,    external_ref TEXT,    severity     INTEGER NOT NULL DEFAULT 1 CHECK (severity BETWEEN 1 AND 5),    opened_at    TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,    closed_at    TEXT,    UNIQUE (device_code, external_ref),    CHECK (closed_at IS NULL OR closed_at >= opened_at));-- 1 validINSERT INTO note_guard(device_code, external_ref, severity, opened_at)VALUES ('PUMP-007','WO-100',4,'2026-08-12 07:00:00');-- 2 duplicate composite key -> UNIQUE failureINSERT INTO note_guard(device_code, external_ref, severity, opened_at)VALUES ('PUMP-007','WO-100',2,'2026-08-12 08:00:00');-- 3 missing required device -> NOT NULL failureINSERT INTO note_guard(device_code) VALUES (NULL);-- 4 out-of-range severity -> CHECK failureINSERT INTO note_guard(device_code, severity) VALUES ('FAN-014', 9);-- 5 closed before opened -> CHECK failureINSERT INTO note_guard(device_code, opened_at, closed_at)VALUES ('SENS-003','2026-08-12 10:00:00','2026-08-12 09:00:00');SELECT note_id, device_code, external_ref, severity, opened_at, closed_atFROM note_guard;

After running the statements one at a time, the final SELECT should show only the first row. The database is demonstrating the difference between “our application intends this rule” and “SQLite refuses to persist violations of this rule.”

Constraint checkpoint

Use the engine behavior to answer each question.

  1. Why does CHECK(severity BETWEEN 1 AND 5) not by itself make severity mandatory?
  2. When is a table-level UNIQUE declaration necessary or clearer?
  3. Why should a new schema avoid relying on NULL primary keys in ordinary rowid tables?
  4. Does a DEFAULT value replace an explicitly supplied NULL under normal constraint handling?
  5. Can a CHECK expression query another table to count related rows?
Review the answers

A CHECK result of NULL passes, so NOT NULL is separate. Composite uniqueness belongs naturally at table level. The nullable-primary-key behavior is a historical SQLite compatibility quirk, not a sound new-design pattern. DEFAULT applies when a value is omitted, not as a general repair for explicit NULL. CHECK expressions cannot contain subqueries, so cross-row/table aggregate rules need another mechanism.

Production judgment: make constraints intentional and diagnosable

RiskWhy it happensProduction response
Schema permits NULL accidentallyA CHECK was mistaken for NOT NULL.Declare NOT NULL separately when absence is invalid.
Uniqueness is too broadA global UNIQUE was used for a tenant/site-local key.Model the actual composite business key.
Primary key accepts NULL unexpectedlyA legacy-compatible rowid-table PRIMARY KEY lacks NOT NULL.Use INTEGER PRIMARY KEY, explicit NOT NULL, STRICT, or WITHOUT ROWID as appropriate.
Default hides missing application inputA meaningful field received a convenient default without a business rule.Use defaults only when omission has a well-defined semantic value.
Constraint error reaches users as opaque textThe database correctly rejects state but the app does not map errors.Keep the constraint and add application-level validation/error translation.

Do not disable constraints to make imports or tests “work.” Stage questionable data separately, diagnose it, then move only valid rows into constrained final tables.

Summary and bridge

NOT NULL, UNIQUE, PRIMARY KEY, CHECK, and DEFAULT turn logical requirements into SQLite-enforced state rules. You also saw why SQLite-specific details matter: primary-key NULL behavior depends on table form, CHECK NULL results pass, and defaults are insertion-time values rather than universal repair logic. The next lesson adds relationships between tables—and an important SQLite requirement: foreign-key declarations and foreign-key enforcement are separate concerns.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.