How do you use a CHECK constraint to enforce a rule that spans several columns of the same row — for example that an end date is never earlier than a start date, or that a discounted price never exceeds the list price?
answer
- Multi-column → table-level clause, not inline
- UPDATE revalidates the whole row image
- Implication = NOT p OR q
- Exactly one of: (a IS NULL) <> (b IS NULL)
- One rule per named constraint, not one giant AND
basics
~20 sWrite the constraint at table level rather than beside a single column, so the expression can reference every column of the row: CONSTRAINT ck_period_order CHECK (ends_at >= starts_at). It is evaluated against the whole new row image on every INSERT and UPDATE of that row.
solid answer
~60 sDeclare it as a **table-level** CHECK — a separate clause in CREATE TABLE or an ALTER TABLE ADD CONSTRAINT — rather than inline next to a column, because a column-level check is conventionally limited to that column and a table-level one can reference the whole row. Two things to get right: - **NULLs.** `CHECK (ends_at >= starts_at)` passes whenever either endpoint is NULL, since the comparison is UNKNOWN. For an open-ended interval that is exactly right; if both endpoints are mandatory, declare NOT NULL and do not try to fold it into the predicate. - **Conditional requirements.** The frequent shape is 'if column A has value X then column B must be present'. Write it as an implication, `CHECK (kind <> 'SHIPPED' OR shipped_at IS NOT NULL)`, using IS NULL / IS NOT NULL so the predicate stays two-valued. Because an UPDATE re-validates the whole new row image, a statement that touches only one of the columns still gets the rule enforced — you cannot break the pair by moving one half. Name the constraint explicitly so the error message identifies which rule fired.
code
sql · 11 linesCREATE TABLE booking (
id bigint PRIMARY KEY,
starts_at timestamptz NOT NULL,
ends_at timestamptz,
status text NOT NULL,
shipped_at timestamptz,
CONSTRAINT ck_booking_end_after_start
CHECK (ends_at >= starts_at),
CONSTRAINT ck_booking_shipped_at_presence
CHECK (status <> 'SHIPPED' OR shipped_at IS NOT NULL)
);go deeper
Show the table-level syntax and one example such as ends_at >= starts_at, and name the constraint.
Cover the NULL behaviour of each shape, the implication pattern for conditional presence, and the fact that UPDATE validates the full row image.
Emphasise one named constraint per user-visible rule so errors map to messages, and draw the line where the rule stops being per-row.
Treat these constraints as the executable part of the domain model and set a convention — naming scheme, one rule per constraint, nullability declared separately — so schemas stay reviewable as the team grows.
## Column-level versus table-level A CHECK can be written in two places. Inline, next to a column definition, it is a column constraint and is understood to constrain that column. As a separate comma-separated clause in the table definition, or added later with ALTER TABLE, it is a table constraint and may reference any columns of the row. Functionally engines are lenient about the inline form, but the conventional and portable rule is simple: **if the rule involves more than one column, write it at table level**. It also reads better — a reader scanning the column list is not surprised by a predicate that mentions a column defined thirty lines below. ## The evaluation model is unchanged A multi-column CHECK is still a per-row predicate evaluated against the candidate row version at write time. The important consequence is that an UPDATE touching only one of the involved columns still re-validates the constraint, because the engine tests the complete post-update image rather than the delta. So the pair cannot be broken by updating one half: shifting `starts_at` past a fixed `ends_at` fails just as an insert with a bad pair would. ## The three recurring shapes **Ordering between two values.** `CHECK (ends_at >= starts_at)`, `CHECK (min_qty <= max_qty)`, `CHECK (discount_price <= list_price)`. Decide deliberately whether equality is allowed — a zero-length interval is often a bug and often deliberate, and the constraint is where that decision becomes explicit and permanent. **Conditional presence — an implication.** 'If status is SHIPPED there must be a shipped_at.' In boolean terms `p implies q` is `NOT p OR q`, so the constraint is `CHECK (status <> 'SHIPPED' OR shipped_at IS NOT NULL)`. If you also want the reverse — shipped_at must be absent otherwise — state both directions, typically as a disjunction of the two legal shapes. Use IS NULL / IS NOT NULL rather than equality comparisons against NULL, which are always UNKNOWN. **Exactly-one-of / mutually exclusive columns.** A row must carry exactly one of `user_id` or `service_account_id`. Written directly: `CHECK ((user_id IS NULL) <> (service_account_id IS NULL))`, which is TRUE only when exactly one is populated — both comparisons are two-valued because IS NULL never returns UNKNOWN. Some teams prefer the more explicit form with an OR of the two legal combinations. Either is fine; the explicit form is easier for a reviewer to verify at a glance, the compact form is easier to extend badly, so prefer clarity. ## NULL handling is the whole game Every multi-column check must answer: what happens when one of the columns is NULL? The rule is unchanged — the row is rejected only when the predicate evaluates to FALSE, and a NULL operand in a comparison yields UNKNOWN, which passes. Consequences: - `CHECK (ends_at >= starts_at)` on an interval with a nullable `ends_at` correctly models 'still open'. Nothing extra is needed. - If both endpoints are mandatory, the answer is `NOT NULL` on both columns, and the CHECK stays a pure ordering rule. Do not write `CHECK (starts_at IS NOT NULL AND ends_at IS NOT NULL AND ends_at >= starts_at)` — it produces one confusing error for two different problems and hides nullability from anyone reading the column list. - If a mixed rule is genuinely intended — 'either both are present or neither is' — that is itself an exactly-one-of style predicate over IS NULL tests, and it belongs in the CHECK. ## Determinism still applies The temptation grows with multi-column rules: 'the end date must be in the future', 'the amount must not exceed today's limit'. Anything referring to the current time or to a value outside the row makes the constraint non-deterministic and therefore unsafe — the row was valid when written but a later revalidation, restore or table rewrite may reject it. Keep the predicate a pure function of the row's own columns. ## Naming and error handling Give each rule its own named constraint rather than bundling several unrelated conditions into one giant AND. Two reasons. First, the error message names the constraint, and `ck_booking_end_after_start` tells the on-call engineer what happened while `ck_booking_valid` does not. Second, application code that maps constraint names to user-facing messages needs one name per user-visible rule; a bundled predicate collapses several distinct messages into one. ## Where this rule shades into other mechanisms Multi-column CHECKs cover the row. The moment the rule needs a second row — 'these two bookings must not overlap' — it is no longer a CHECK problem; that is exclusion-constraint or index territory. Keeping that boundary clear is what stops a schema accumulating triggers that reimplement constraints badly.
- How do you write 'exactly one of user_id and service_account_id must be populated'?Compare the two nullability tests: CHECK ((user_id IS NULL) <> (service_account_id IS NULL)). IS NULL always returns TRUE or FALSE, never UNKNOWN, so the predicate is two-valued and no row slips through, and it is TRUE precisely when the two columns differ in populated-ness. A more verbose but equally correct form spells out the two legal combinations joined by OR.
- Should you combine several unrelated rules into one CHECK constraint with AND?No. The violation error names the constraint, so a bundled predicate tells you only that something in a large expression failed, and application code cannot map it to a specific user-facing message. Declare one named constraint per rule; the enforcement cost is essentially identical and the diagnosability is far better.
saying these in an interview costs you the question
- Assuming a multi-column check only fires when every involved column is updated
- Folding NOT NULL into the CHECK instead of declaring it on the columns
- Using = NULL or <> NULL instead of IS NULL / IS NOT NULL
- Adding CURRENT_DATE to the predicate to express 'must be in the future'
- Bundling every table rule into a single unnamed CHECK