Chapter 05 · Constraints, Foreign Keys, Conflict Handling, and Integrity
Generated Columns, Integrity Checks, and Enforcing Cross-Column Rules
Use row-local generated values and constraints, audit physical and relational integrity with the right PRAGMAs, and recognize when a rule requires triggers or application transactions.
Learning outcomes
The chapter has built integrity from simple column rules through relationship and failure semantics. The final step is operational: derive row-local values safely, inspect hidden generated-column metadata, run the correct integrity audits, and recognize the boundary where ordinary declarative constraints are no longer expressive enough.
Create VIRTUAL and STORED generated columns and explain when SQLite computes each value.
Use only deterministic, row-local generated expressions and recognize unsupported subqueries/aggregates/window/table-valued functions.
Apply supported NOT NULL, CHECK, UNIQUE, foreign-key, and index rules to generated columns where appropriate.
Distinguish PRAGMA integrity_check, quick_check, and foreign_key_check by what each validates and omits.
Use PRAGMA table_xinfo to inspect generated columns that table_info does not report.
Build a repeatable FieldNotes integrity audit and identify rules that require triggers or application transactions.
Generated columns derive data from the same row
A generated column is computed from other columns rather than supplied directly by INSERT/UPDATE. SQLite added generated-column support in version 3.31.0. The expression is part of the schema contract, so every writer gets the same derived result.
DROP TABLE IF EXISTS work_measure;CREATE TABLE work_measure ( work_id INTEGER PRIMARY KEY, duration_minutes INTEGER NOT NULL CHECK (duration_minutes >= 0), duration_hours REAL GENERATED ALWAYS AS (duration_minutes / 60.0) VIRTUAL);INSERT INTO work_measure(work_id, duration_minutes) VALUES (1, 150);SELECT work_id, duration_minutes, duration_hoursFROM work_measure;The expected derived value is 2.5 hours. Application code does not write duration_hours; changing duration_minutes changes what the generated column returns.
VIRTUAL versus STORED
A VIRTUAL generated column is computed when read. A STORED generated column is computed when the row is written and occupies database space. Both look like columns to SQL queries, and both can often participate in constraints/indexes. The performance tradeoff is workload-specific, so measure rather than assuming STORED is always faster.
DROP TABLE IF EXISTS note_metric;CREATE TABLE note_metric ( note_id INTEGER PRIMARY KEY, duration_minutes INTEGER NOT NULL CHECK (duration_minutes >= 0), duration_hours REAL AS (duration_minutes / 60.0) VIRTUAL, is_long INTEGER AS (duration_minutes >= 120) STORED CHECK (is_long IN (0,1)));CREATE INDEX idx_note_metric_is_long ON note_metric(is_long);INSERT INTO note_metric(note_id, duration_minutes) VALUES (1, 90), (2, 180);SELECT note_id, duration_hours, is_long FROM note_metric ORDER BY note_id;SQLite does not permit adding a STORED generated column later with ordinary ALTER TABLE ADD COLUMN; VIRTUAL can be added. Treat STORED generated columns as a schema-compatibility decision, not a cosmetic optimization.
Generated expressions are intentionally limited
SQLite requires generated expressions to be deterministic and row-local. They may reference other columns in the row (including earlier generated dependencies that do not form a cycle), constants, and deterministic scalar functions. They may not use subqueries, aggregate functions, window functions, or table-valued functions. They cannot directly reference rowid, though an INTEGER PRIMARY KEY alias may be referenced by its declared name.
| Wanted rule | Generated column? | Reason |
|---|---|---|
duration_minutes / 60.0 | Yes | Deterministic and row-local. |
upper(code) | Yes | Deterministic scalar function of this row. |
random() | No | Non-deterministic. |
(SELECT count(*) FROM note) | No | Subquery / other rows. |
sum(value) OVER (...) | No | Window/aggregate expression. |
| Current wall-clock time | No as a generated expression contract | Time changes independently of row inputs; use a write-time value/default/application workflow instead. |
Constraints and indexes can make derived meaning searchable
Generated columns may carry datatype/affinity and several normal constraints, and they may be indexed. This is useful when a repeated row-local expression has stable business meaning.
DROP TABLE IF EXISTS device_code_rule;CREATE TABLE device_code_rule ( device_id INTEGER PRIMARY KEY, site_code TEXT NOT NULL, local_code TEXT NOT NULL, canonical_code TEXT AS (upper(site_code || ':' || local_code)) VIRTUAL NOT NULL UNIQUE);INSERT INTO device_code_rule(site_code, local_code)VALUES ('north','pump-007');SELECT device_id, canonical_code FROM device_code_rule;PRAGMA table_info('device_code_rule');PRAGMA table_xinfo('device_code_rule');table_info omits generated columns, while table_xinfo includes them with a hidden/generated indicator. That is another reason Chapter 3 taught table_xinfo as the more complete inspection tool.
Cross-column CHECK rules are powerful but still row-local
Generated columns are optional; sometimes a direct CHECK is clearer. For FieldNotes, a lifecycle row can require a closing time only when status is closed and can enforce ordering between timestamps.
DROP TABLE IF EXISTS lifecycle_rule;CREATE TABLE lifecycle_rule ( note_id INTEGER PRIMARY KEY, status TEXT NOT NULL CHECK (status IN ('open','closed')), opened_at TEXT NOT NULL, closed_at TEXT, CHECK ( (status = 'open' AND closed_at IS NULL) OR (status = 'closed' AND closed_at IS NOT NULL AND closed_at >= opened_at) ));INSERT INTO lifecycle_rule VALUES(1,'open','2026-08-12 06:00:00',NULL),(2,'closed','2026-08-12 06:00:00','2026-08-12 07:00:00');-- Invalid: closed but no closed_atINSERT INTO lifecycle_rule VALUES(3,'closed','2026-08-12 06:00:00',NULL);This rule is an excellent database constraint because every necessary fact is in one row. A rule such as “a device may have at most three open notes” requires inspecting other rows, so CHECK/generated-column expressions cannot enforce it.
Three integrity PRAGMAs answer different questions
Do not treat the word “integrity” as one test. SQLite separates low-level/database-structure checks from foreign-key relationship checks.
| Audit | What it checks | Important omission / cost |
|---|---|---|
PRAGMA integrity_check | Database structure, page/index consistency, UNIQUE/CHECK/NOT NULL errors, freelist and related invariants; STRICT tables also receive type validation. | Does not report foreign-key violations; more thorough and potentially more expensive. |
PRAGMA quick_check | Many structural checks in a faster O(N) pass. | Skips UNIQUE verification and table/index-content consistency checks performed by integrity_check. |
PRAGMA foreign_key_check | Stored rows that violate declared foreign-key relationships. | Does not replace structural integrity checks. |
PRAGMA quick_check;PRAGMA integrity_check;PRAGMA foreign_key_check;A healthy database typically returns one row ok from quick/integrity checks and zero rows from foreign_key_check. If foreign keys were disabled while bad data was written, integrity_check can still report ok while foreign_key_check reports orphans.
FieldNotes integrity audit script
Save repeatable operational checks in a version-controlled SQL script. The CLI directives below make failures visible and keep the audit easy to rerun. Chapter 16 will cover backup/recovery before any repair operation.
-- sql/audit_integrity.sql.bail on.headers on.mode boxSELECT sqlite_version() AS sqlite_version;PRAGMA foreign_keys;SELECT 'quick_check' AS audit, * FROM pragma_quick_check;SELECT 'integrity_check' AS audit, * FROM pragma_integrity_check;-- Zero rows is the desired result:PRAGMA foreign_key_check;-- Schema evidence for generated/hidden columns:PRAGMA table_xinfo('maintenance_note');-- Domain-level row-local checks that are useful even if constraints exist:SELECT note_id, device_id, occurred_at, severity, note_textFROM maintenance_noteWHERE severity NOT IN ('info','warning','critical') OR trim(note_text) = '';The last query is not a substitute for a CHECK constraint; it is operational evidence and can help diagnose a legacy or migrated database. For a new database, prefer preventing invalid rows first and auditing second.
What ordinary constraints cannot express
SQLite constraints cover much more than many applications use, but they are not a general rule engine. UNIQUE can compare rows for uniqueness and foreign keys can enforce parent existence; CHECK/generated expressions cannot run arbitrary queries across tables or across time.
| Business rule | Best enforcement direction |
|---|---|
| Every device code is unique within its site | Composite UNIQUE constraint. |
| Every note references a real device | Foreign key with enforcement initialized on every connection. |
| Closed time cannot precede opened time | Row-local CHECK. |
| Derived duration in hours | Generated column if it materially improves queries/contracts. |
| At most 3 open maintenance notes per device | Likely a carefully designed trigger or serialized/application transaction; ordinary CHECK cannot count peer rows. |
| A maintenance window may not overlap any other window for the device | Requires cross-row logic; consider transaction+query/trigger and concurrency semantics. |
| A status transition is allowed only after an external approval event | Application/domain transaction, possibly supported by triggers/audit tables; depends on external state. |
Triggers arrive in Chapter 12. Transaction/concurrency chapters will also matter because a cross-row invariant is only reliable if concurrent writers cannot both validate stale state and then commit conflicting changes.
Chapter lab: integrity audit with an intentionally created orphan
Use a disposable database. First create valid rows with enforcement on. Then deliberately turn enforcement off outside a transaction to simulate a legacy/import mistake, insert one orphan, and compare the audits.
PRAGMA foreign_keys = ON;DROP TABLE IF EXISTS audit_note;DROP TABLE IF EXISTS audit_device;CREATE TABLE audit_device ( device_id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE);CREATE TABLE audit_note ( note_id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL REFERENCES audit_device(device_id), severity INTEGER NOT NULL CHECK (severity BETWEEN 1 AND 5));INSERT INTO audit_device VALUES (1,'PUMP-007');INSERT INTO audit_note VALUES (10,1,3);PRAGMA foreign_keys = OFF;INSERT INTO audit_note VALUES (11,999,4);PRAGMA foreign_keys = ON;PRAGMA integrity_check;PRAGMA foreign_key_check;-- Cleanup the intentional orphan:DELETE FROM audit_note WHERE note_id = 11;PRAGMA foreign_key_check;The critical observation is that integrity_check can say ok while foreign_key_check reports the orphan. After cleanup, foreign_key_check should return zero rows.
Chapter 5 final checkpoint
Choose the correct integrity mechanism.
- Why does
table_xinfomatter for generated-column schemas? - Can a generated column contain
SELECT count(*)over another table? - What does
quick_checkskip compared withintegrity_check? - Does
integrity_checkprove that all foreign keys are valid? - What mechanism should you consider for a rule that depends on multiple rows and must remain correct under concurrent writes?
Review the answers
table_xinfo includes generated/hidden columns that table_info omits. Generated expressions are row-local and cannot contain subqueries. quick_check skips UNIQUE and index-content consistency checks. integrity_check does not validate foreign keys, so run foreign_key_check separately. Cross-row invariants may require triggers or application transactions designed together with concurrency/isolation behavior.
Production review checklist
| Question | Evidence to require |
|---|---|
| Are required fields actually mandatory? | NOT NULL plus tests that try NULL. |
| Are business keys encoded at the correct scope? | PRIMARY KEY/UNIQUE definitions, including composite keys. |
| Are row-local domain rules enforced? | CHECK/generated expressions with negative tests. |
| Are foreign keys active on every production connection? | Initialization code plus PRAGMA foreign_keys=1 verification tests. |
| Are parent lifecycle actions intentional? | Documented ON DELETE/UPDATE rationale and deletion tests. |
| Does conflict handling preserve the intended state? | Tests for partial statement/transaction behavior; avoid generic IGNORE/REPLACE. |
| Are integrity audits complete? | quick/integrity check plus separate foreign_key_check. |
| Are cross-row rules concurrency-safe? | Trigger/application-transaction design reviewed alongside locking/isolation chapters. |
Summary and bridge to Chapter 6
Chapter 5 turned integrity into observable engine behavior: constraints reject invalid row states, foreign keys protect relationships only when enforcement is active, lifecycle actions encode ownership, deferred constraints move validation to COMMIT, conflict algorithms determine failure scope, and generated columns derive row-local meaning. You also learned that no single audit proves everything. Chapter 6 now uses this protected schema as the target for deliberate INSERT, UPDATE, DELETE, UPSERT, and RETURNING operations.