Constraints: Rules the Database Can Enforce
Introductions, exercises and summaries stay visible.
11.0 What this chapter gives you#
- You will explain a constraint as an enforced acceptance rule, not merely a note beside a field.
- You will distinguish a required value from a check on values that are supplied.
- You will test primary keys and uniqueness without assuming that every database treats missing values identically.
- You will follow a foreign key from a child record to its parent, including a reference made of two components.
- You will demonstrate why individual row bounds do not necessarily protect a total across several rows.
- You will distinguish checking at a statement boundary from checking at a transaction boundary.
- You will inspect an actual failed deferred commit, then compare repairing that transaction with rolling it back.
- You will distinguish a failed statement, a rolled-back transaction and a structurally successful database check.
- You will outline a constraint-change review without pretending that the in-memory exercises perform a production migration.
The previous chapter separated intended rules from implemented protection. This chapter examines the protection itself. The executed examples use SQLite in disposable databases; PostgreSQL 17 comparisons are explicitly documented rather than executed. The original shop’s four agreed lines remain unchanged. CAP-DEMO, the optional composite receipt links and the parent/child timing tables are isolated teaching examples, not newly discovered business events.
11.1 Not-null and check rules#
11.1.1 PLAIN — in simple words#
- A constraint is a rule the engine checks when relevant data is written. If the operation would violate an enforced rule, the engine reports a failure instead of accepting that prohibited result.
NOT NULLsays that a column must contain a supplied value rather than SQL’s missing-value marker. It does not say that a text value is nonempty, a number is positive or the information is truthful.CHECKtests an expression. For example, a supplied agreed quantity must be between 1 and 10,000 in our storage adapter.- These are different jobs. A check such as “value greater than zero” is not a complete way to require a value, because a missing value does not behave like the number zero.
- In the SQLite examples, a check that evaluates to NULL is not a violation. Use an explicit presence rule when absence is prohibited. [S61]
- The same field can need several rules at once: required, appropriate storage type and within the permitted range. None of those independently proves the real-world claim behind the record.
- Treat a successful insert as evidence that the applicable enforced rules accepted it. Do not translate it into “every requirement was checked” unless every relevant requirement actually has a mechanism.
11.1.2 PLAIN — a picture in your head#
- Imagine an entry desk with separate questions. First: “Did you supply the required document?” Second: “Does its stated number fall within the accepted range?”
- Skipping the first question cannot be repaired merely by looking at the range on documents that arrived. An absent document is not a document showing zero.
- Similarly, “a value must be present” and “a supplied value must be positive” should be written as separate promises when both matter.
- A clear label on the desk is helpful, but it is not the same as someone actually checking the condition. A comment in SQL resembles the label; a declared constraint resembles the implemented check.
- Where the comparison breaks: SQL’s truth rules are precise, not a human clerk’s intuition. A CHECK expression and a WHERE filter treat an unknown result differently for their respective purposes.
- The entry-desk picture should therefore lead to actual truth cases, not replace them. Test missing, boundary, valid and invalid values directly.
11.1.3 PLAIN — a worked example#
- The new lab creates two isolated tables. They are not modifications
to
order_lines.
CREATE TABLE check_only (
value INTEGER CHECK (value > 0)
) STRICT;
CREATE TABLE required (
value INTEGER NOT NULL CHECK (value > 0)
) STRICT;- Try the following inputs. “Accepted” means accepted by these deliberately small definitions, not approved as a complete shop record.
| Input | check_only |
required |
Explanation |
|---|---|---|---|
| NULL | Accepted | Rejected | Presence is enforced only in the second table |
| 0 | Rejected | Rejected | Zero is not greater than zero |
| 1 | Accepted | Accepted | Present positive integer |
| -1 | Rejected | Rejected | Negative value fails the check |
- For the original agreed quantity, the stronger declaration uses
INTEGER NOT NULLtogether with the chosen positive range. For the separate draft quantity, absence is permitted but a supplied value must satisfy the same range. - An empty text value
''is not SQL NULL. A field needing nonempty text must specify that condition;NOT NULLalone does not express it. - Nor does
NOT NULLdistinguish an honest zero price from a made-up zero inserted to hide an absent quotation. The schema can enforce the stored representation, but the input workflow must preserve meaning. - These tests are small enough to reason through before running them. The surprising NULL case is exactly why rules should be demonstrated rather than memorised from their English names.
11.1.4 PLAIN — what is really happening inside#
- The engine receives a proposed row, applies the relevant type handling and evaluates its declared conditions. It does not infer additional rules from neighbouring prose.
- An ordinary comparison involving NULL often produces an unknown
result rather than true or false. The check-only example therefore does
not evaluate
NULL > 0as false. - The purpose of a CHECK constraint is to exclude rows that violate its expression under the engine’s constraint semantics. The purpose of a WHERE filter is to retain rows whose predicate is true. These are related uses of expressions with different acceptance decisions.
- The later SQL chapter demonstrates the distinction: a NULL quantity
can pass a nullable positive CHECK but will not be selected by
WHERE quantity > 0. - A check can relate columns of the same row. An optional pair can require both components absent or both present. This remains a row rule even though it mentions two columns.
- An arbitrary rule about totals across many rows is different. Do not hide a query inside a check and assume that all future changes elsewhere will trigger a correct re-evaluation. The whole-system invariant needs an appropriate mechanism.
11.1.5 TECHNICAL — the engineer’s version#
- The lab’s row-domain checks distinguish three boundaries: original runtime input validation, SQLite storage acceptance and business interpretation. Python type distinctions lost during binding cannot be reconstructed from a stored integer alone.
- In SQLite, CHECK failure is based on an expression result interpreted numerically as zero; a NULL result is not a constraint violation. PostgreSQL’s Boolean CHECK semantics also allow an unknown result, but expression typing is not identical between the engines. [S61] [S02]
- Prefer direct, explicit declarations for introductory invariants.
quantity INTEGER NOT NULL CHECK (quantity BETWEEN 1 AND 10000)states presence and range without relying on a reader to infer either from a column name. - Use a separate text predicate when needed. For example, a local
nonempty-label exercise could use
CHECK (length(label) > 0)withNOT NULL. That still does not define whitespace policy, identity normalization or permitted characters; those require additional decisions. - Boundary testing should include values just below and above the accepted interval. For agreed quantity, 0 and 10,001 fail while 1 and 10,000 pass. For agreed price, 0 is allowed, -1 and 100,000,001 are not.
- Requiring an integer in SQLite STRICT storage does not prohibit
every incoming textual representation. The documented lossless-coercion
rules and the lab’s observed
"2"example keep that distinction explicit. [S60] - Avoid volatile or external truth hidden behind a row check. PostgreSQL’s documentation explains why CHECK conditions should not depend on changing data in other rows. Such a function can make the declaration appear stronger than the maintained invariant actually is. [S02]
- The tests establish selected acceptance cases on the recorded SQLite runtime. They do not prove that every engine, driver or future schema revision applies identical coercion and error behaviour.
11.1.6 WORDS — remember these#
Constraint: a rule enforced at a defined database boundary — a declared condition whose violation prevents a relevant operation from succeeding under the engine’s semantics.
Not-null rule: a requirement that a value be present — a column condition excluding SQL NULL, distinct from nonempty text or positive numeric value.
Check predicate: an expression used to reject prohibited row values — a condition evaluated under an engine’s CHECK semantics.
Unknown result: a comparison that cannot be classified as true or false from the supplied values — the third truth outcome commonly produced by SQL expressions involving NULL.
Boundary test: a test at an acceptance limit — a selected case at, immediately below or immediately above a declared domain boundary.
11.2 Unique and primary keys#
11.2.1 PLAIN — in simple words#
- A uniqueness rule prevents repeated values, or repeated combinations of values, under its declared scope and comparison rules.
- A primary key is the chosen key used to identify rows. In our teaching tables it is required and unique. Some tables use one column; order lines use a pair.
- A key on
(order_id, line_no)prevents two records with exactly the same complete line identity. It does not prohibit line number 1 on two different orders. - It also does not say that two separate lines cannot name the same product. Adding that restriction would be a new business policy rather than a technical consequence of having a key.
- “Unique” and “required” should not be treated as interchangeable words. Missing-value behaviour in a uniqueness constraint depends on the database and declaration.
- A generated identifier is not the whole duplicate-delivery solution. Two retries may receive two different generated identifiers while still representing one attempted business action.
- Uniqueness protects a declared representation of identity. Deciding what should count as the same thing remains a modelling and workflow responsibility.
11.2.2 PLAIN — a picture in your head#
- Imagine labelled lockers. A rule says that no two assigned lockers may have the same complete label.
- If a label consists of building and locker number, Building A locker 17 and Building B locker 17 can both exist. The number alone is incomplete.
- Blank labels raise a separate question. Are blanks prohibited, allowed many times, or allowed only once? The word “unique” alone is not enough to infer a portable answer.
- Where the comparison breaks: database equality can depend on types and text-comparison rules, and generated keys are not physical locker tags. A visually similar spelling is not automatically the same database value under every configuration.
- The analogy also cannot detect whether somebody created two different labels for one real-world request. That problem needs the application’s identity and retry policy.
- Keep complete-key uniqueness separate from real-world duplicate detection, just as Chapter 5 separated repeated delivery from a genuinely new event.
11.2.3 PLAIN — a worked example#
- Start from the canonical line identity O-1042, line 1. A second insert with that same pair is rejected by the key even if it tries to supply a different quantity.
- O-1043, line 1 is accepted because the order component differs. O-1042, line 2 is accepted because the line component differs.
- The earlier repeated-product exercise can contain two P-NOTE lines with different line numbers. The key protects line identity rather than product uniqueness within an order.
- A separate table demonstrates missing references:
CREATE TABLE refs (
reference TEXT UNIQUE
) STRICT;- The lab inserts NULL, NULL and the present reference R-1. All three inserts succeed. A second R-1 is rejected. The result contains two missing references, not evidence that those missing references identify the same thing.
- That observed outcome is SQLite’s behaviour for this definition.
PostgreSQL 17 provides an explicit
NULLS NOT DISTINCToption when a different uniqueness treatment is desired; that syntax is not used in the SQLite lab. [S61] [S02] - Do not “repair” this by replacing every missing reference with the
text
UNKNOWN. That would invent a present value and then make all unknown cases collide with it.
11.2.4 PLAIN — what is really happening inside#
- The engine compares the constrained values against its maintained key structure. It can therefore reject a competing write that violates the same declared uniqueness rule.
- A separate application query saying “does this identifier exist?” is not a replacement. Another writer could act after that query and before the insert unless the complete operation has appropriate protection.
- The key check removes one class of race: accepting duplicate constrained identities. It does not automatically coordinate every other effect associated with a request.
- A key collision can mean different things. It might be an exact retry, conflicting content under the same identity or an actual identifier-allocation error. The database’s rejection does not choose the application’s response policy.
- The earlier file-import and event-consumer exercises already use deliberately different duplicate policies for different boundaries. Do not collapse those into one generic “ignore duplicates” instruction.
- When a write fails, compare the right evidence under the chosen operation’s policy. Automatically overwriting the existing record would change a uniqueness rejection into an unreviewed mutation.
11.2.5 TECHNICAL — the engineer’s version#
- Candidate keys describe possible unique identifiers; a primary key designates one for the table. Additional unique constraints can enforce other candidate identifiers where the business contract actually requires them.
- In this lab, explicit
NOT NULLdeclarations accompany the text and composite keys. SQLite has historical primary-key nullability exceptions outside some table forms, so do not generalise from this STRICT schema to every legacy SQLite table. [S61] [S60] - The constrained columns define scope.
PRIMARY KEY (order_id, line_no)is not equivalent to separate uniqueness constraints on each component. The latter would wrongly allow only one line per order and only one use of each line number across the table. - PostgreSQL’s primary and unique constraints are associated with supporting unique indexes. The broader indexing mechanisms are treated later; at this stage the important distinction is between the declared invariant and an application’s preliminary existence query. [S74]
- Input normalization must be chosen before a uniqueness promise is interpreted. Trimming or case-folding an identifier after acceptance can merge previously distinct labels. Our key examples use their exact stated strings and do not invent a general normalization policy.
- For retries, a unique request key can be one part of an idempotency design, but the design must also define scope, payload agreement, retained result and transactional effects. None is established merely by adding an auto-generated row identifier.
- SQLite’s
INSERT OR REPLACEis not a neutral spelling of “update this existing record”. For a relevant key conflict, its replacement behaviour can remove the conflicting row. Do not use it as an unexplained repair for failed uniqueness in these exercises. [S76] - The new tests retain failed duplicate-key inserts as failures and demonstrate valid alternate line identities separately. That preserves the distinction between a rejected conflict and an accepted new business record.
11.2.6 WORDS — remember these#
Unique constraint: a prohibition on repeated constrained values — an invariant over one column or a composite value under the engine’s comparison and NULL rules.
Candidate key: a possible way to identify each row uniquely — a minimal identifying attribute set under the model’s constraints.
Primary key: the chosen required identifier for a table — the designated key used as a principal row identity in the current schema.
Key collision: two attempted records using the same constrained identity — a uniqueness conflict whose business interpretation requires the surrounding policy.
Existence precheck: asking whether a record is present before attempting a write — a read that does not by itself prevent a concurrent conflicting insert.
11.3 Foreign keys#
11.3.1 PLAIN — in simple words#
- A foreign key connects a stored reference to a record that must exist. A line naming P-NOTE should refer to an existing product record rather than an arbitrary spelling that merely looks plausible.
- The referencing record is often called the child. The record it refers to is the parent. These names describe the direction of the reference, not human family relationships.
- If the child reference is required, it needs a presence rule as well as a foreign key. An optional reference may be absent without requiring a made-up parent.
- Removing a referenced parent raises a policy question. Should deletion be blocked, dependent children be deleted, or the link become absent? The right answer depends on what the records mean.
- The existing shop uses restrictive deletion for the referenced products and orders. It does not silently delete agreed lines because somebody removes a catalogue entry.
- A foreign key proves a structural relationship under the active enforcement conditions. It does not prove that a person is the rightful customer, that a product is currently sellable or that a payment was authorized.
- A reference can contain several components. Every component’s meaning and missingness must be specified, or a half-filled reference can evade the assumption a reader thought the diagram expressed.
11.3.2 PLAIN — a picture in your head#
- Imagine slips that refer to cards in a catalogue. Before accepting a required slip, a clerk checks that the named catalogue card exists.
- Before removing a card, the clerk checks what still points to it. Depending on policy, the removal may be refused or coordinated with changes to the slips.
- An optional blank reference means no card is linked. It should not cause the clerk to create a card named “nobody” and pretend a real link exists.
- If a reference is “branch plus local number”, a slip showing only the branch is incomplete. The desk needs a clear rule for that half-filled case.
- Where the comparison breaks: SQL engines have specified null-matching, deletion-action and timing semantics. A drawing of an arrow contains none of those details unless the actual declaration supplies them.
- The clerk also does not authenticate the real-world story behind the card. Existence and authority are different claims.
11.3.3 PLAIN — a worked example#
- Reuse Chapter 5’s scoped receipt identities as a new relationship
demonstration. The parent table contains
(BR-A, 17)and(BR-B, 17). No amounts or order links are inferred from those labels. - An optional child reference uses two columns: branch and local number. The intended policy is “both absent, or both supplied and naming a parent”.
- A plain nullable composite foreign key is not sufficient for that policy in SQLite. A row with branch BR-MISSING and a NULL local number is accepted in the deliberately loose table.
- The stronger example adds an all-or-none check:
CHECK ((branch IS NULL) = (local_no IS NULL)),
FOREIGN KEY (branch, local_no)
REFERENCES receipts(branch, local_no)- The check compares two known yes-or-no answers about absence. Both absent gives true; both present gives true; only one absent gives false.
| Proposed optional reference | Checked example result | Why |
|---|---|---|
| NULL, NULL | Accepted | No linked receipt |
| BR-A, 17 | Accepted | Complete reference with a parent |
| BR-B, 17 | Accepted | Different valid scope |
| BR-A, NULL | Rejected | Half-specified reference |
| BR-X, 17 | Rejected | Complete reference but missing parent |
- If the relationship were required, both columns would instead need not-null rules as well. Allowing a completely absent optional link is a deliberate policy, not an accident.
- None of these tests proves that a linked receipt belongs to an authorized actor. The identifiers demonstrate referential structure only.
11.3.4 PLAIN — what is really happening inside#
- The engine uses the child values to look for an eligible parent key. The declaration must reference a suitable identifying set of parent columns, not arbitrary unrelated values.
- Composite matching is a match on the complete tuple. BR-A’s receipt 17 and BR-B’s receipt 17 remain distinguishable even though the numeric component is equal.
- In the nullable SQLite case, any missing child component avoids the ordinary parent-match requirement. That is why the separate all-or-none check is needed for this exercise’s intended optional link. [S59]
- Deletion actions control consequences when an existing parent is removed. Restriction blocks the conflicting removal; cascade propagates a deletion to children; setting a reference to NULL still has to respect the other declared rules.
- The existing child foreign key does not require every parent to have children. A catalogue product can be unused, and a parent order can be empty in the current model.
- SQLite foreign-key enforcement is a connection setting that the helper enables and verifies before the exercises. A declaration alone is not a sufficient description of an uninspected connection’s behaviour. [S59]
- The default helper-created practice database keeps enforcement enabled. A deliberately unprotected legacy-data demonstration later uses a different newly created in-memory database and is clearly marked as a counterexample.
11.3.5 TECHNICAL — the engineer’s version#
- A foreign key combines a child column list, a parent target and referential actions. It is separate from application authorization, business-state eligibility and parent participation minima.
- The lab’s
order_lineshas non-nullorder_idandproduct_id, referencingordersandproductswithON DELETE RESTRICT. The optionalorders.customer_idhas different presence semantics. - PostgreSQL supports
MATCH FULLfor an all-or-none nullable composite foreign key. SQLite does not enforce the MATCH clause in that way, so the executed example uses the explicit row CHECK rather than copying unsupported semantics. [S74] [S59] - Test a missing parent, an absent optional reference and a half-filled composite reference independently. A successful test of one does not establish the other two.
- A child-side index is a performance consideration rather than the definition of referential correctness. PostgreSQL does not automatically create every useful child-side index merely because a foreign key exists; later chapters study those access costs. [S74]
- Avoid using cascade as a default housekeeping shortcut. Deleting a product and automatically deleting historical agreed lines would discard evidence the current model intends to preserve. Whether any cascade is appropriate must follow the represented objects’ lifecycle.
- Check actual connection setup with
PRAGMA foreign_keys, and inspect existing data withPRAGMA foreign_key_checkwhen validating the specific SQLite relationship state. Enabling a setting and checking old data are different operations. [S75] - These exercises do not change the owner’s database, access a server or disable checks in an existing file. Every deliberately defective reference is confined to a new disposable in-memory database.
11.3.6 WORDS — remember these#
Foreign key: a stored link required to match an eligible parent — a referential constraint on child values with defined missingness, actions and checking time.
Composite reference: a link made from more than one value — a tuple of child columns identifying a parent within the corresponding scope.
Referential action: what happens to a relationship when a parent changes — a declared response such as restriction, cascading deletion or setting a reference to NULL.
Half-specified reference: only part of a multi-component link is supplied — an incomplete child tuple that needs an explicit acceptance policy.
Orphan row: a child with no required matching parent — an existing record violating the intended referential relationship.
11.4 Cross-row invariants#
11.4.1 PLAIN — in simple words#
- An invariant is something that must remain true after the operations covered by a rule. Some invariants describe one row; others describe a collection of rows together.
- “Every allocation is between one and ten” is a row rule. “The sum of all allocations never exceeds capacity ten” is a collection rule. The first does not imply the second.
- Every individual entry can look acceptable while their combined result is unacceptable. This is why a collection of green validation marks can still hide an over-allocation.
- Uniqueness and foreign keys already enforce particular relationships across rows. But they do not express every possible sum, minimum count, time overlap or workflow rule.
- A complete solution must coordinate the operation that changes the collection. Reading the current total and later inserting a new row without adequate coordination can leave a race between writers.
- Do not claim that a transaction automatically chooses the right coordination policy. The operation, engine isolation and write conditions must match the invariant being protected.
- This chapter demonstrates the gap using a small counterexample. It does not claim to implement a production reservation service or explain the earlier shop’s unresolved stock discrepancy.
11.4.2 PLAIN — a picture in your head#
- Imagine a room with ten chairs. Each group booking must request between one and ten chairs.
- A booking for seven passes that individual rule. A booking for six also passes. Together they need thirteen chairs.
- A clerk checking only each form’s range has not checked the room’s total. A second clerk may make the same mistake even more quickly.
- To protect the total, the booking process must make decisions against a shared, appropriately coordinated capacity state rather than accept each form in isolation.
- Where the comparison breaks: different database designs coordinate writes differently, and not every invariant should be represented by a mutable chair counter. The analogy demonstrates arithmetic insufficiency, not one universal implementation.
- It also does not settle cancellation, expiration, no-shows or authorization. Those are additional workflow requirements, not consequences of the number ten.
11.4.3 PLAIN — a worked example#
- The lab creates a separate capacity record CAP-DEMO with total 10. Its allocation rows each require a present integer quantity between 1 and 10.
- Allocation A-1 requests 7. Its row check passes. Allocation A-2 requests 6. Its row check also passes.
- Both references point to the existing capacity record, so the foreign-key checks pass as well.
| Check | A-1 | A-2 | Combined state |
|---|---|---|---|
| Quantity | 7 | 6 | 13 |
| Individual bound 1–10 | Pass | Pass | Not a total constraint |
| Referenced capacity exists | Pass | Pass | Same CAP-DEMO |
| Allocated sum no greater than capacity 10 | Not established alone | Not established alone | Fails by 3 |
- No simultaneous execution is needed to demonstrate this first defect. The two inserts can happen one after the other and still produce a total of 13.
- That matters diagnostically. Before blaming a complicated race, first check whether the sequential operation even enforces the intended rule.
- A later concurrency design must also ensure that two operations cannot each make decisions against an inadequately coordinated view. Correctness under one sequential fixture is necessary evidence, not a complete concurrency proof.
- CAP-DEMO is an isolated arithmetic example. Its excess of three has no connection to the older expected-stock-10, observed-stock-9 case; the cause of that older observation remains unstated.

Figure 11 — Different rules protect different properties. Row checks, unique identities and parent references can all pass while the combined allocation of 13 exceeds capacity 10.
11.4.4 PLAIN — what is really happening inside#
- The declared check is evaluated against each proposed allocation row. It sees a permitted quantity, not an automatically maintained sum over every related allocation.
- The foreign key answers “does CAP-DEMO exist?”. It does not
interpret the parent’s
totalcolumn as an allocation ceiling. - A report can calculate the sum and reveal the violation after it exists. Detection is useful, but it is not prevention.
- An application-only precheck can also calculate a sum before a write. Its usefulness depends on whether the subsequent write is coordinated with every relevant competing change.
- Possible designs include a guarded update of authoritative capacity state, an appropriately locked transaction or a different representation that makes the desired invariant directly enforceable. The details and trade-offs belong to the later transaction and concurrency chapters.
- A trigger is code invoked by a database event. Naming a trigger as a solution does not show that its read/write sequence, isolation assumptions and error handling actually protect the invariant.
- The immediate design task is to state the complete invariant and identify which operations can change it. Only then can the mechanism and tests be judged against the right problem.
11.4.5 TECHNICAL — the engineer’s version#
- Write the two predicates separately. The row predicate is
1 <= q_i <= 10for each allocation i. The capacity invariant issum(q_i for active allocations) <= capacity. “Active” itself requires a lifecycle definition in a real system; the toy example treats its two rows as the represented allocations. - The first predicate allows the assignment
q_1 = 7, q_2 = 6. The second rejects it when capacity is 10. That explicit assignment disproves the implication from row bounds to total safety. - PostgreSQL’s CHECK mechanism is not a supported way to query arbitrary other table rows and continuously maintain such a condition. Its constraints documentation directs attention to appropriate mechanisms for cross-row relationships. [S02]
- A separate stored
allocated_totalwould create another invariant: the counter must equal the represented allocations. Protecting the counter’s upper bound while allowing it to drift from its underlying records is not a complete solution. - For any proposed implementation, enumerate every mutation: add allocation, change quantity, cancel allocation, expire it, reduce capacity and repair historical data. An invariant is only maintained if relevant write paths preserve it under their declared ordering and failure rules.
- PostgreSQL isolation levels and retry behaviour are product-specific. The book’s future concurrent examples must specify those conditions rather than importing SQLite’s single-writer observations as a cross-engine guarantee. [S66]
- The current test intentionally asserts that the row checks pass and the global rule fails. That is a regression test for the explanation’s counterexample, not a passing safety test for an allocation service.
- The same reasoning applies to minimum participation. A child-to-parent foreign key alone does not express “every final order has a line”. A finalisation boundary and its mechanism must be designed before testing that stronger claim.
11.4.6 WORDS — remember these#
Invariant: a condition that must keep holding — a predicate preserved across the operations within its specified scope.
Cross-row invariant: a rule about several records together — a condition involving relationships, aggregates or coordinated state beyond one proposed row.
Detection versus prevention: finding a violation versus stopping it from being committed — distinct responsibilities requiring different evidence.
Authoritative counter: a stored total used to make decisions — state whose agreement with the underlying events or allocations needs its own maintained invariant.
Coordination boundary: the operations that must be ordered or made indivisible together — the scope within which an invariant’s read, decision and write are protected.
11.5 Deferred enforcement#
11.5.1 PLAIN — in simple words#
- An operation can consist of several statements. Sometimes an intermediate statement temporarily leaves a relationship incomplete, while the completed transaction restores it before becoming accepted work.
- Immediate checking rejects a violation at the relevant earlier boundary. Deferred checking allows a supported rule to be checked later, commonly at transaction commit.
- Deferring a rule is not disabling it. The completed transaction must still satisfy the deferred requirement or commit fails.
- Consider adding a child and its parent in one transaction. A deferred foreign key can allow the child insert first, provided the required parent exists by successful commit.
- A statement returning successfully is therefore not always evidence that the whole transaction can commit. The remaining deferred check may still reject the batch.
- Failure handling matters too. In the SQLite demonstration, a failed commit caused by the missing deferred parent leaves the transaction open. The application must decide how to resolve or abandon it.
- Not every constraint supports deferral, and referential actions can have different timing. Learn the exact engine’s rules instead of assuming that one keyword postpones everything.
11.5.2 PLAIN — a picture in your head#
- Imagine assembling an application packet. During assembly, one sheet can refer to another sheet that has not yet been placed in the folder.
- The submission desk checks the complete folder before accepting it. A temporary gap while assembling is allowed; a gap in the submitted packet is not.
- Immediate checking resembles checking every sheet as soon as it enters the folder. Deferred checking resembles waiting for the declared submission boundary for the supported relationship.
- If submission fails, the folder may still be on your desk rather than having vanished. You must either add the missing material under the allowed process or discard the unfinished packet.
- Where the comparison breaks: transactions have engine-defined visibility, locking and error states. A paper folder does not model those details or establish that an application is entitled to repair every failure automatically.
- The analogy explains timing, not weaker correctness. The final acceptance condition is still required.
11.5.3 PLAIN — a worked example#
- The lab creates a new parent/child pair with a deferred foreign key. These are isolated teaching tables, not the canonical shop tables.
CREATE TABLE batch_parent (
id TEXT NOT NULL PRIMARY KEY
) STRICT;
CREATE TABLE batch_child (
id TEXT NOT NULL PRIMARY KEY,
parent_id TEXT NOT NULL REFERENCES batch_parent(id)
DEFERRABLE INITIALLY DEFERRED
) STRICT;- Start a transaction. Insert CHILD-1 referencing PARENT-1 while PARENT-1 is absent. The insert returns successfully in this demonstration.
- Try to commit without adding the parent. Commit fails with a foreign-key constraint error. The lab observes that the connection is still in a transaction.
- Compare two separately executed outcomes:
| Stage | Repair branch | Abandon branch |
|---|---|---|
| Insert child with deferred missing parent | Returns successfully | Returns successfully |
| First COMMIT | Fails | Fails |
| Transaction after failed COMMIT | Still open | Still open |
| Chosen next action | Insert PARENT-1, then COMMIT | ROLLBACK |
| Child rows at the end | 1 | 0 |
| Open transaction at the end | No | No |
- The repair branch is allowed because this particular demonstration deliberately supplies the missing parent. It is not a rule to manufacture parent records in a real application merely to silence an error.
- A second timing test uses
ON DELETE RESTRICTon a declared deferred reference. Deleting the referenced parent is rejected during the delete operation, before an attempted commit. - That second result prevents an overbroad conclusion: “this foreign key is deferred” does not mean every associated referential action is postponed until commit. [S59]
11.5.4 PLAIN — what is really happening inside#
- The engine tracks whether the transaction’s supported deferred relationships are satisfied. Intermediate statement success and final transaction acceptance are distinct stages.
- The child-first insert leaves a pending relationship problem. Inserting the matching parent within the same transaction removes it; leaving the parent absent causes the commit check to fail.
- In the observed SQLite failure, the transaction remains available for a valid repair or explicit rollback. Ignoring the exception could leave locks or uncommitted work around longer than intended.
- A rollback abandons the changes within the transaction. It does not mean an earlier committed parent or unrelated database was erased.
- Restrictive deletion answers a different immediate question: a referenced parent is not to be removed by that operation. Its action timing must be understood separately from the ordinary deferred relationship check.
- The application needs clear ownership of the transaction. A helper should not silently commit or roll back a transaction started by its caller unless its contract explicitly permits that behaviour.
- The new guarded-draft helper therefore refuses to start when the caller already has an active transaction. That makes its narrow ownership rule visible rather than guessing what the surrounding application intended.
11.5.5 TECHNICAL — the engineer’s version#
- The executed child example declares
DEFERRABLE INITIALLY DEFERREDon a SQLite foreign key and runs inside an explicit transaction. With no surrounding explicit transaction, a single statement’s automatic transaction boundary would not provide the same multi-statement construction window. [S59] - The lab records statement return, first commit failure,
Connection.in_transaction, final child count and final transaction state. Those are distinct observations; a single “error raised” assertion would not establish the complete recovery behaviour. - SQLite documents that a failed commit due to deferred foreign-key violations leaves the transaction open. Its RESTRICT action is processed promptly even when the corresponding foreign key is deferred. The two isolated tests exercise those specific documented conditions. [S59]
- PostgreSQL 17 provides
SET CONSTRAINTSto change checking mode for eligible deferrable constraints in the current transaction. Switching from deferred to immediate can force outstanding work to be checked then. This is a documented comparison, not an operation run by our SQLite helper. [S73] - Do not assume that not-null and CHECK conditions can all be deferred with that command. PostgreSQL documents the eligible classes; the CREATE TABLE declaration must also support the intended timing. [S73] [S74]
- Deferred enforcement can simplify coordinated insertion of related data, but it changes where an error can surface. Callers must handle failure at commit as well as at execute time.
- A correct retry or repair policy depends on the failure cause and transaction state. Reissuing one statement blindly can be inappropriate after other failures; read the exact engine/driver documentation and retain the failed operation’s context.
- No power interruption, multi-host transaction or PostgreSQL runtime was tested here. These results establish selected SQLite transaction-boundary behaviour in the disclosed local environment, not a general durability or cross-engine guarantee.
11.5.6 WORDS — remember these#
Immediate constraint: a rule checked at its ordinary early boundary — an invariant enforced without the supported transaction-level postponement.
Deferred constraint: a rule whose check is postponed to a declared later boundary — an eligible constraint allowed to be temporarily unsatisfied within a transaction but required by successful final checking.
Commit-time failure: rejection when trying to complete a transaction — an error arising at the commit boundary rather than necessarily during an earlier statement.
Transaction ownership: responsibility for starting and ending a transaction — a contract defining which component may commit, roll back or leave the connection active.
Repair branch: a deliberate resolution of incomplete transactional work — an explicitly permitted continuation that restores the failed condition before a later commit.
11.6 Error handling and migration#
11.6.1 PLAIN — in simple words#
- An error is information about an operation, not a universal description of everything the database has done. Ask what failed, what remains and whether a transaction is still open.
- A failed statement can be undone while earlier statements in the same transaction remain pending. A complete rollback is a different event.
- A key conflict, a missing parent and a locked database have different causes. Showing users the same “something went wrong” message hides useful distinctions; guessing a cause from incomplete evidence is no better.
- A constraint change also has a before and after. Adding a rule raises two questions: will future operations obey it, and do existing records already satisfy it?
- Enabling foreign-key enforcement on a connection is not a retroactive cleanup operation. Old orphan rows do not magically gain parents or disappear.
- A migration is a controlled change to the database structure or representation. It needs a plan for existing data, compatible readers and writers, verification and failure handling.
- The exercises here illustrate error states and validation gaps in new in-memory databases. They do not run a migration on a real shop database or authorize changes to any existing installation.
11.6.2 PLAIN — a picture in your head#
- Imagine writing three entries in pencil before signing the page. The second entry is rejected and erased, but the first entry may still remain unsigned on the page.
- “The second entry failed” does not tell you whether the whole page was discarded. You need to inspect the actual procedure.
- Now imagine introducing a new rule that every old folder must have a matching index card. Announcing the rule today does not manufacture index cards for yesterday’s incomplete folders.
- Where the comparison breaks: database engines define precisely which statement effects and transaction effects survive each failure class. The pencil analogy cannot replace checking those rules and actual connection state.
- It also says nothing about safe restoration after an irreversible external action. A transaction rollback in this lab does not retract an email, refund a payment or reverse a file sent elsewhere.
- Use the picture to ask the right questions: what is pending, what is committed, what is invalid, and which recovery action is actually allowed?
11.6.3 PLAIN — a worked example#
- The statement-failure demonstration starts an explicit SQLite transaction in a new table with a primary key.
- Its first insert adds identifier 1. A later single statement tries to insert identifiers 2 and 1. The repeated 1 causes a primary-key failure under the default ABORT behaviour.
- The lab then inspects the same connection. Identifier 1 from the earlier successful statement is still present inside the transaction; identifier 2 from the failed statement is not.
- The transaction is still open. An explicit rollback removes the earlier uncommitted identifier 1 as well. The final table is empty.
- A separate deliberately unprotected database is created only for a legacy-data counterexample. It stores one orphan child while enforcement is off, then enables enforcement before inspection.
| Observation in the isolated legacy example | Actual result | What it does not establish |
|---|---|---|
PRAGMA foreign_keys after enabling |
1 | That old rows were retroactively repaired |
PRAGMA integrity_check |
ok |
That all foreign-key relationships are valid |
PRAGMA foreign_key_check |
One violation | The correct business repair or missing parent identity |
| Existing child count | 1 | That the orphan is an approved record |
- The specific relationship check finds the problem that the general integrity check does not cover. SQLite documents their different scopes. [S75]
- Neither demonstration is an instruction to disable checks in an existing database. Both create their own disposable in-memory structures, and no user file is opened.
11.6.4 PLAIN — what is really happening inside#
- A statement has an execution boundary; a transaction has a larger acceptance boundary. The engine’s conflict handling determines which effects are undone for a particular error.
- In the tested default ABORT case, the failing statement’s changes are backed out, while earlier statements in the active transaction remain pending. Explicit rollback abandons that transaction’s earlier work too. [S76]
- The application must not report a successful complete operation merely because the first statement succeeded. It must observe the required final boundary and handle the actual error state.
- A migration adds another kind of boundary: the database and its consumers move from an old contract to a new contract. Existing rows can conflict with the new rule even when future writes are well validated.
- Inspecting existing records produces evidence, but an inspection result can go stale while writers continue changing the database. A real migration plan must coordinate validation and enforcement rather than assume a one-time query remains true forever.
- Any repair needs a meaning-preserving policy. Automatically deleting orphan rows or inventing missing parents may silence structural errors while destroying evidence or creating false claims.
- For the book’s fictional examples, the correct action is to report the deliberate violation and discard the disposable exercise database. There is no production repair decision to infer.
11.6.5 TECHNICAL — the engineer’s version#
- The statement-failure test records the SQLite extended error name
SQLITE_CONSTRAINT_PRIMARYKEY, the remaining rows and transaction state. Programmatic error codes or classes are preferable to treating the exact English wording of one driver message as a permanent interface. [S77] [S86] - Do not generalise this one ABORT case to every SQLite error or conflict algorithm. Other failure classes and policies have different rollback effects. The lab intentionally uses ordinary statements and reports the tested scope. [S76]
- For a planned constraint change, prepare an inventory of old data violations, the proposed resolution policy, the final declaration and the verification query. Keep the evidence before and after the change separate.
- PostgreSQL’s ALTER TABLE facilities include distinct ways of adding and validating supported constraints. Which operations scan existing rows and which locks or compatibility effects arise depends on the exact operation. Those details require a version-specific migration plan rather than a generic “run ALTER TABLE” recipe. [S78]
- A portable design discussion does not imply portable syntax. SQLite and PostgreSQL expose different table-change capabilities. None of the PostgreSQL migration examples is executed in the companion labs.
- Test both desired rejections and preserved valid cases after a proposed rule change. A rule that blocks all writes will reject bad data while also destroying the intended workflow; rejection counts alone do not establish correctness.
- Keep validation queries and the data snapshot they examined identifiable. A report saying “zero violations” without its database, schema version, timing and concurrent-write assumptions is incomplete evidence.
- The current lab provides no backup orchestration, online migration, multi-client deployment or production rollback plan. Its contribution is to make the error and validation boundaries observable before later operational chapters build on them.
11.6.6 WORDS — remember these#
Statement rollback: undoing effects of one failed statement — a failure response distinct from abandoning all earlier work in the active transaction.
Transaction rollback: abandoning a transaction’s pending changes — an explicit or engine-triggered reversal within that transaction’s defined scope.
Constraint validation: checking records against a declared rule — an operation whose data scope and timing must be distinguished from future enforcement.
Schema migration: a controlled change to database structure or representation — a transition requiring data compatibility, verification and a defined failure disposition.
Extended error code: a machine-readable refinement of a failure category — an implementation-specific identifier that distinguishes more precise error causes.
11.97 Practice and worked answers#
- Presence versus range. A column is declared
INTEGER CHECK (value > 0). Predict the results for NULL, 0 and 3 in the executed SQLite profile. Then state what declaration makes a positive value required. - Scope of uniqueness. Why can both O-1042 line 1 and
O-1043 line 1 exist? Would two separate UNIQUE rules on
order_idandline_noexpress the same policy as the composite primary key? - Complete optional links. A receipt reference has branch BR-A but no local number. Explain why the loose nullable composite foreign key accepts it and how the checked example rejects it without forbidding a completely absent optional link.
- Find the missing invariant. CAP-DEMO has capacity 10. Allocations 7 and 6 both pass their row checks. Calculate the violation and explain why neither the row checks nor the parent reference protects the required total.
- Trace the failed commit. A deferred child insert returns successfully, but its missing parent is never inserted. What happens at COMMIT in the lab, and how do the repair and rollback branches differ?
- Interpret the checks. An isolated database reports
foreign_keys = 1andintegrity_check = ok, butforeign_key_checkreturns one violation. Are those observations contradictory? What business repair do they establish? - Do not overread an error. The second statement of a transaction fails with a primary-key conflict under the tested ABORT policy. Does that establish that the first statement’s pending change was rolled back too?
Worked answers
- NULL is accepted because the CHECK’s unknown result does not violate
that definition. Zero is rejected and three is accepted.
INTEGER NOT NULL CHECK (value > 0)adds the missing presence rule. The accepted integer still needs an explicit unit and workflow meaning. - The pairs differ in their order component. Separate uniqueness on
order_idwould prohibit two lines on one order; separate uniqueness online_nowould prohibit line number 1 on another order. Neither is our intended line identity rule. - In the loose SQLite definition, a NULL component avoids the ordinary
composite parent-match requirement.
CHECK ((branch IS NULL) = (local_no IS NULL))rejects a half-filled pair, while both NULL values pass as an intentionally absent reference. The foreign key then rejects complete pairs without a parent. - The sum is 13, exceeding 10 by 3. The row conditions restrict each allocation individually, and the foreign key only establishes that CAP-DEMO exists. A coordinated cross-row invariant is missing. This sequential counterexample does not establish a production concurrency solution.
- The first COMMIT fails and the tested connection remains in a transaction. The repair branch inserts the deliberate matching parent and commits, leaving one child. The abandon branch rolls back, leaving no child. Both finish without an open transaction. A real repair would need authority and truthful parent evidence rather than automatic invention.
- They are not contradictory. The first reports the connection setting; the second checks its documented structural scope; the third specifically reports relationship violations. None decides whether to remove the child, supply a parent or correct the reference. That decision needs the original meaning and evidence.
- No. In the observed default ABORT case, the failing multi-row statement is undone, but the earlier successful insert remains pending. Explicit transaction rollback removes that earlier uncommitted work. Other error classes must be interpreted using their own documented behaviour.
11.98 Common wrong ideas#
- Wrong: a positive CHECK automatically requires a value. Better: combine range and presence explicitly when absence is prohibited.
- Wrong: not-null text cannot be empty or meaningless. Better: NULL, empty text and semantic correctness are different questions.
- Wrong: UNIQUE has one portable missing-value policy everywhere. Better: inspect the engine and declaration; the SQLite example permits multiple NULL references.
- Wrong: two individually unique columns are the same as a unique pair. Better: separate uniqueness is a different and often much stronger restriction.
- Wrong: a foreign key proves a transaction is authorized. Better: it checks a structural relationship, not a person’s authority or the truth of an agreement.
- Wrong: any composite foreign key requires all components. Better: optional and half-filled references need explicit null policy.
- Wrong: safe individual quantities imply a safe total. Better: 7 and 6 each fit within 1–10, but their sum exceeds capacity 10.
- Wrong: a deferred constraint is switched off. Better: its supported check is postponed, and the required final boundary can still reject the transaction.
- Wrong: a successful execute call proves commit will succeed. Better: deferred checks and other failures can surface later.
- Wrong: a failed statement always rolls back the entire transaction. Better: observe the specific error policy and actual transaction state.
- Wrong: turning on foreign keys repairs old orphan rows. Better: enabling enforcement and validating existing relationships are separate operations.
- Wrong: an integrity check is a complete audit of every business rule. Better: every check has a defined scope; unimplemented semantic and workflow rules remain outside it.
11.99 Chapter summary in 20 lines#
- A constraint is an enforced rule with a defined checking boundary.
- Presence, type, range and real-world meaning are separate requirements.
- A nullable positive CHECK can accept NULL in the demonstrated engines.
- NOT NULL excludes missingness, not empty text or false information.
- A composite key constrains the complete tuple rather than each component separately.
- Our line key preserves order scope and allows repeated products on separate lines.
- Missing-value treatment in uniqueness needs an engine-specific declaration.
- A generated identity is not a complete duplicate-delivery policy.
- Foreign keys maintain declared child-to-parent relationships.
- Optional composite links may need an all-or-none presence check.
- Referential actions must follow record lifecycles, not cleanup convenience.
- A required child reference does not guarantee every parent has children.
- Individual allocation bounds do not protect a combined capacity total.
- State the cross-row invariant before choosing its coordination mechanism.
- Deferred checking postpones a supported rule rather than removing it.
- In the SQLite demonstration, a failed deferred commit leaves the transaction open.
- A restrictive parent deletion can fail before commit even with a deferred reference.
- Statement rollback and transaction rollback can have different scopes.
- Existing-data validation is distinct from enabling future enforcement.
- A migration needs evidence, compatibility and recovery planning beyond these disposable tests.