Constraint Enforcement: Database vs Application Level

A guide to CHECK, UNIQUE, NOT NULL, and exclusion constraints. Learn database vs application-level enforcement and performance implications.

published: reading time: 26 min read author: GeekWorkBench

Constraint Enforcement: Database vs Application Level

Database constraints define what data is allowed in your tables. Simple enough. But here’s where teams run into trouble: deciding where to enforce these rules. The database? Your application code? Both? The answer matters more than most developers realize until something goes wrong.

I’ll walk through each constraint type, explain when the database should be doing the enforcing, and show you when application-level checks actually make sense.

flowchart TD
    subgraph AppLayer["Application Layer"]
        A1[("Validate:<br/>check not null")]
        A2[("Validate:<br/>check unique")]
        A3[("Validate:<br/>check business rules")]
    end

    subgraph DBEnforce["Database Layer"]
        D1[("NOT NULL<br/>Constraint")]
        D2[("UNIQUE<br/>Index")]
        D3[("CHECK<br/>Constraint")]
        D4[("FOREIGN KEY<br/>Constraint")]
        D5[("EXCLUSION<br/>Constraint")]
    end

    subgraph WritePath["Write Path"]
        W1[("INSERT/UPDATE<br/>Request")] --> A1
        A1 -->|pass| A2
        A2 -->|pass| A3
        A3 -->|pass| Write[("Write to<br/>Database")]

        Write --> D1
        D1 --> D2
        D2 --> D3
        D3 --> D4
        D4 --> D5
        D5 --> Committed[("Data<br/>Committed")]
    end

    Write -.->|app bypass<br/>direct SQL| D1
    Write -.->|bypass possible<br/>without app| D1

    A1 -.->|redundant<br/>if DB enforces| D1
    A3 -.->|only here when<br/>DB cannot express| D3

Introduction

Every piece of data that enters your database passes through a decision: should this be allowed? Constraints are the database’s answer to that question. But teams routinely make the wrong call about where to enforce rules — sometimes in the application layer where they can be bypassed, sometimes redundantly in both places, sometimes nowhere at all.

This guide covers each constraint type (NOT NULL, UNIQUE, CHECK, FOREIGN KEY, EXCLUSION) and when the database is the right enforcement point versus when application-level validation makes more sense. It also covers the performance implications of constraints and how they interact with the query planner.

Understanding Constraint Types

NOT NULL Constraints

The simplest constraint type, NOT NULL ensures a column always contains a value. At the database level, this is straightforward:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

The database enforces this rule regardless of how data enters the system—direct SQL inserts, application code, ETL processes, or any other pathway. This is the primary advantage of database-level enforcement: it provides a single source of truth that cannot be bypassed.

Application-level NOT NULL checks are typically redundant and slower. They require fetching data, checking values, and returning errors—all slower than the database handling it directly.

UNIQUE Constraints

UNIQUE constraints prevent duplicate values in a column or combination of columns:

CREATE TABLE organizations (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(100) NOT NULL UNIQUE,
    domain VARCHAR(255) UNIQUE
);

The database maintains a unique index to enforce this constraint efficiently. Concurrent inserts attempting duplicate values will fail with a constraint violation, and the database handles race conditions automatically.

At the application level, you’d need to first query for existing values, then insert—but this creates a window where another process could insert the same value first. Database-level enforcement eliminates this race condition entirely.

CHECK Constraints

CHECK constraints validate data against a boolean expression:

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    total_amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT positive_amount CHECK (total_amount > 0),
    CONSTRAINT valid_status CHECK (status IN ('pending', 'processing', 'shipped', 'delivered'))
);

Modern databases support sophisticated CHECK constraints. PostgreSQL evaluates them with table and row data:

ALTER TABLE orders ADD CONSTRAINT no_future_orders
    CHECK (created_at <= CURRENT_TIMESTAMP);

CHECK constraints are evaluated on a per-row basis during INSERT and UPDATE operations. The database guarantees atomic enforcement.

Exclusion Constraints

PostgreSQL’s exclusion constraints go beyond UNIQUE by ensuring that specified ranges or values do not overlap:

CREATE TABLE room_bookings (
    id SERIAL PRIMARY KEY,
    room_id INTEGER NOT NULL,
    booking_range TSTZRANGE NOT NULL,
    EXCLUDE USING GIST (room_id WITH =, booking_range WITH &&)
);

This prevents double-booking by ensuring no two bookings for the same room have overlapping time ranges. The && operator checks for range overlap.

Exclusion constraints are powerful for scheduling, reservation systems, and any scenario where temporal or spatial conflicts must be prevented.

Database-Level Enforcement: When It Makes Sense

Database constraints should be your first line of defense when:

  1. Data enters through multiple pathways — If data can be inserted via direct SQL, migrations, ETL jobs, admin tools, or any path besides your application, database enforcement ensures consistency everywhere.

  2. You need atomic, race-condition-free validation — The database handles concurrent operations without race windows that plague application-level checks.

  3. Referential integrity is critical — FOREIGN KEY constraints are non-negotiable at the database level. Application-level referential checks are always inferior.

  4. You want declarative, self-documenting schema — Constraints in the schema serve as documentation. They make the data model’s rules explicit and impossible to ignore.

  5. You’re using an ORM or generic data access layer — ORMs often bypass application-level validation. Database constraints protect your data regardless of how it’s accessed.

Here’s the way I think about it: your application’s validation logic is a ceiling. Constraints are a floor. Application logic can have bugs, be bypassed, or be forgotten. Database constraints are always there.

Application-Level Enforcement: When to Use It

Application-level validation makes sense in specific scenarios:

  1. Complex business rules that exceed constraint capabilities — Some rules require cross-table logic, external API calls, or stateful validation that CHECK constraints cannot express.

  2. User experience optimization — Catching validation errors at the application layer lets you format user-friendly error messages, provide field-level feedback, and avoid database round-trips for simple checks.

  3. Performance-critical paths — For bulk operations where every microsecond matters, application-level pre-validation can reduce transaction overhead. The trade-off is losing atomic enforcement.

  4. Business logic that might change — If validation rules are configuration rather than data law, keeping them in application code allows faster iteration without schema migrations.

# Application-level validation example
def create_order(user_id: int, items: list[OrderItem]) -> Order:
    # Pre-validate business rules
    if not items:
        raise ValidationError("Order must have at least one item")

    total = sum(item.price * item.quantity for item in items)
    if total > user.credit_limit:
        raise ValidationError("Order exceeds credit limit")

    # Now create the order
    return Order.create(user_id=user_id, total_amount=total, items=items)

The rule of thumb: use database constraints for data integrity rules (the laws of your data universe) and application validation for business rules (the policies of your business domain).

Performance Implications

Constraint enforcement has measurable performance costs. Understanding these helps you make informed decisions.

NOT NULL Performance

Performance impact: Minimal. NULL checks are essentially free—they’re part of column storage and indexing decisions anyway.

UNIQUE Constraint Performance

Performance impact: Moderate. Each UNIQUE constraint requires maintaining an index. Insert and update operations must check and update this index.

For single-column UNIQUE constraints on auto-incrementing IDs, the overhead is negligible. For composite UNIQUE constraints on frequently-updated columns, consider the index size and write amplification.

-- Monitoring unique constraint index size
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = 'your_table';

CHECK Constraint Performance

Performance impact: Variable. Simple comparisons (value > 0, status IN (‘a’, ‘b’)) are optimized by query planners and add minimal overhead. Complex expressions involving functions, subqueries, or cross-column comparisons can slow inserts and updates noticeably.

-- Complex CHECK constraints can slow bulk inserts
ALTER TABLE large_table ADD CONSTRAINT complex_check
    CHECK (some_function(column_a, column_b) < threshold);

Test your constraints with realistic data volumes. A CHECK that seems innocuous can become a bottleneck during bulk operations.

EXCLUSION Constraint Performance

Performance impact: Moderate to significant. GIST indexes supporting exclusion constraints are larger and slower to update than B-tree indexes. For high-volume write workloads, benchmark before deploying.

Best Practices

  1. Start with database constraints, optimize later — Default to database-level enforcement. Move validation to the application only when you have measurable evidence that constraints are a bottleneck.

  2. Use constraint naming conventions — Named constraints make error messages and debugging easier:

    CONSTRAINT positive_price CHECK (price >= 0)  -- Good
    CHECK (price >= 0)  -- Poor - cryptic error messages
  3. Defer constraint checking when needed — PostgreSQL’s DEFERRABLE option delays constraint validation until transaction end:

    CREATE TABLE self_referencing (
        id SERIAL PRIMARY KEY,
        parent_id INTEGER REFERENCES self_referencing(id) DEFERRABLE INITIALLY DEFERRED
    );
  4. Combine approaches strategically — Use database constraints for integrity and application validation for UX. Don’t double-check everything, but don’t rely solely on one layer.

  5. Document your constraint strategy — Whether in README files, ADR documents, or schema comments, explain the reasoning behind your enforcement decisions.

Database vs Application Enforcement Trade-offs

DimensionDatabase ConstraintsApplication Validation
Enforcement guaranteeStrong — cannot be bypassedWeak — bugs, bypasses possible
Race condition handlingAtomic — handled by DBComplex — needs explicit locking
Performance impactMinimal to moderateNone on DB side
Multiple data pathwaysCovers all — SQL, ETL, migrationsOnly covers application writes
Debugging visibilityConstraint violation messagesCustom error handling
Schema documentationDeclarative in schemaScattered in code
Maintenance burdenLow — schema-levelHigh — code must stay in sync

Production Failure Scenarios

FailureImpactMitigation
Race window in app-level uniqueness checkDuplicate key inserted despite checkAlways use DB UNIQUE constraint as source of truth
Deferred constraint triggering on commitViolation caught late — full transaction rolls backTest with realistic multi-statement transactions
CHECK constraint with complex functionSlows bulk inserts significantlyBenchmark complex CHECKs, move logic to triggers if needed
Foreign key without indexLock contention on parent table during deletesAlways index foreign key columns
Exclusion constraint on high-write tableGiST index updates create contentionUse before/after benchmarking, consider application-level exclusion

Capacity Estimation: Constraint Check Overhead Per Row

Constraint checking adds CPU overhead per row. The cost depends on the constraint type and expression complexity.

Per-row constraint cost formula:

constraint_check_time = expression_evaluation_time × rows_affected × constraint_complexity_factor

Simple constraints (NOT NULL, simple comparisons): negligible overhead, typically 1-5% of insert/update time. Complex CHECK constraints with functions, regex, or subqueries: can add 10-50% overhead.

For NOT NULL: essentially zero overhead — the null check is a single bit test. For CHECK (price > 0 AND price < 1000000): a few CPU cycles per row. For CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'): regex evaluation per row, 10-50 microseconds per row depending on regex complexity and string length.

UNIQUE constraint cost: B-tree lookup on the index. For 1M-row tables, unique check adds ~0.1-0.5ms per insert. For high-volume inserts (10K/second), this is manageable. For bulk inserts of 100M rows, the unique check against existing data becomes the bottleneck — consider using UNIQUE only where needed and using batch deduplication logic at the application layer instead.

EXCLUSION constraint cost: GiST index update is the dominant cost. For range exclusions (prevent overlapping bookings) or btree exclusion (prevent double-booking at the same timestamp), GiST index updates are 5-10× slower than btree inserts. On high-write tables (10K+ writes/second), exclusion constraints cause measurable contention. Benchmark before deploying on write-heavy tables.

Observability Hooks: Constraint Violation Logging

PostgreSQL does not log constraint violations by default. You need application-level or trigger-based logging to track violations.

-- Create a constraint violation log table
CREATE TABLE constraint_violations (
    id BIGSERIAL PRIMARY KEY,
    occurred_at TIMESTAMPTZ DEFAULT now(),
    constraint_name TEXT,
    table_name TEXT,
    schema_name TEXT,
    constraint_type TEXT,
    key_values JSONB,
    violation_query TEXT,
    application_name TEXT
);

-- Trigger to log unique constraint violations
CREATE OR REPLACE FUNCTION log_unique_violation()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO constraint_violations (
        constraint_name, table_name, schema_name, constraint_type, key_values, violation_query
    )
    SELECT
        'unique_' || TG_NAME,
        TG_TABLE_NAME,
        TG_TABLE_SCHEMA,
        'UNIQUE',
        to_jsonb(NEW),
        current_query()
    WHERE FALSE;  -- This will fail, but the error contains the info
    RETURN NEW;
EXCEPTION WHEN raise_exception THEN
    GET STACKED DIAGNOSTICS
        v_msg = MESSAGE_TEXT,
        v_constraint = CONSTRAINT_NAME;
    RAISE;
END;
$$ LANGUAGE plpgsql;

Key alerts for constraint violations:

# Alert on unexpected constraint violation spike
- alert: ConstraintViolationSpike
  expr: rate(constraint_violations_total[5m]) > 10
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Spike in constraint violations on {{ $labels.table }}"

# Alert on unique constraint violations (duplicate key attempts)
- alert: DuplicateKeyAttempts
  expr: increase(postgresql_constraint_violations_total{type="unique"}[5m]) > 100
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "High duplicate key attempts on {{ $labels.table }}"

Real-World Case Study: Constraint Bypass at a Fintech Startup

A fintech company processing payment transactions had a PostgreSQL database with a UNIQUE constraint on (account_id, transaction_date) to prevent duplicate daily transactions. The application code checked for duplicates before insert — fast path — but direct SQL imports (for data corrections) bypassed the application layer and hit the database directly.

The problem: the direct SQL imports used INSERT ... ON CONFLICT DO NOTHING which silently dropped duplicate transactions instead of flagging them. The correction workflow expected these to be flagged, reviewed, and approved. Instead, duplicate transactions worth thousands of dollars were silently dropped.

The resolution: the company implemented a database trigger that logged all constraint violations to a constraint_violations table, regardless of whether the application handled the conflict. The trigger also sent a Slack alert for high-value transactions where a unique constraint was violated. This caught the next direct SQL import incident within seconds and prevented the duplicate from being silently dropped.

The lesson: if you have application-layer bypass paths (direct SQL, bulk imports, data corrections), those paths also need to respect constraint enforcement logic. If they cannot, implement constraint violation logging so violations are never silent.

Quick Recap Checklist

Use this checklist when designing or reviewing constraint enforcement for a database schema:

  • NOT NULL constraints defined on all required columns
  • UNIQUE constraints defined for business keys and natural keys
  • CHECK constraints used for simple boolean expressions, not complex logic
  • FOREIGN KEY constraints with indexes on child tables
  • EXCLUSION constraints for range/temporal overlap prevention
  • DEFERRABLE used for bulk imports with interdependent FKs
  • Application validation layered on top for UX, not as sole enforcement
  • Constraint naming conventions applied for debugging
  • Observability hooks in place for violation monitoring
  • Benchmarking performed for complex CHECK constraints

Common Pitfalls / Anti-Patterns

Overusing CHECK constraints for business logic — If your constraint references business rules that might change, you’re coupling schema evolution to data integrity. Keep business rules in application code.

Ignoring constraint validation order — When multiple constraints exist, the order of evaluation matters. Foreign keys are checked before CHECK constraints. UNIQUE and EXCLUDE constraints interact in complex ways during bulk loads.

Forgetting that constraints can be disabled — In PostgreSQL, constraints can be marked NOT VALID or disabled with ALTER TABLE ... DISABLE TRIGGER ALL. This is sometimes necessary during data migrations but creates risk if misused.

Assuming constraint violations are atomic — In deferred constraint mode, violations aren’t detected until transaction commit. This can surprise developers expecting immediate feedback.

Interview Questions

1. A CHECK constraint on your orders table references a function that calls an external API. Insert performance degrades 100×. What do you do?

External API calls in CHECK constraints are an anti-pattern — they turn a per-row database operation into a per-row network call, which is orders of magnitude slower. The fix: remove the API call from the constraint. Instead, validate the external condition at the application layer before inserting, and use the database constraint only for data integrity that can be evaluated locally. If the external condition must be enforced at the database level, consider using a trigger that validates and rejects, with the API call handled asynchronously — accept the insert, validate in background, and flag/reject if validation fails.

2. Your UNIQUE constraint is causing deadlocks under high concurrent insert load. How do you diagnose and fix it?

UNIQUE constraint deadlocks occur when multiple transactions try to insert conflicting keys simultaneously. With 10 transactions all inserting user_id = 123 at the same time, they queue at the unique index — but if they hold locks while waiting, they can deadlock with each other. Diagnosis: enable log_lock_waits and deadlock_timeout monitoring in PostgreSQL. The fix depends on pattern: if duplicates are rare (legitimate new users), the contention is transient. If your workload generates many legitimate duplicates that get rejected, consider switching to INSERT ... ON CONFLICT DO UPDATE which serializes conflicts differently, or partition by a shard key to reduce index contention.

3. What is the difference between DEFERRABLE and NOT DEFERRABLE constraints?

NOT DEFERRABLE constraints are checked immediately after each statement. DEFERRABLE constraints can be deferred until the end of the transaction. This matters for bulk imports where you insert rows in an order that temporarily violates a foreign key but satisfies it by the end. Example: inserting an order before its customer. With NOT DEFERRABLE, the FK check fails immediately. With DEFERRABLE INITIALLY DEFERRED, the FK is checked only at transaction commit. Performance impact: NOT DEFERRABLE checks after each row for statement-level cascades; DEFERRABLE allows batching the check at commit — faster for multi-row operations with interdependent data.

4. A complex CHECK constraint on a high-write table causes 40% insert overhead. How do you decide whether to keep it or move the validation to the application layer?

Benchmark both approaches: measure the actual overhead of the CHECK constraint under realistic write load. If 40% overhead is confirmed, the decision depends on who you trust more: the database or the application. Database constraints are enforced regardless of which application code path writes the data — including direct SQL, bulk imports, ORMs, and future applications. Application validation is only as reliable as the discipline of all code paths. The rule: use database constraints for data integrity that must never be violated, even by buggy code. Use application validation for business logic rules that belong in the application layer.

5. Why might a FOREIGN KEY constraint without an index cause lock contention on parent table deletes?

When you delete from a parent table, PostgreSQL must check if matching foreign key values exist in child tables. Without an index on the child table's foreign key column, PostgreSQL performs a sequential scan of the child table for each parent row deleted. Under high delete volume, this causes escalating shared row locks on both tables as each delete waits to confirm no children exist. The fix: always create an index on foreign key columns in child tables. This is particularly important for tables with frequent parent deletes and large child tables.

6. How do exclusion constraints differ from unique constraints, and what are the main use cases for each?

UNIQUE constraints ensure no duplicate values exist in a column or combination of columns. EXCLUSION constraints go further: they ensure that ranges or values do not overlap. For example, a UNIQUE constraint on (room_id, date) prevents the same room being booked twice on the same date. An EXCLUSION constraint using tstzrange prevents the same room from being booked for overlapping time ranges, even if the bookings have different dates. EXCLUSION constraints use GiST indexes (not B-tree) which are larger and slower to update but enable overlap checking impossible with UNIQUE.

7. A developer suggests replacing all database constraints with application-level validation for consistency. What are the trade-offs?

Application-level validation alone creates a fundamental problem: bugs, bypassing, and future code paths can violate data integrity. Database constraints are the floor — they cannot be bypassed. Application validation is the ceiling — it depends on correct code. The main trade-offs: database constraints cannot be bypassed (strong enforcement), handle race conditions atomically (no TOCTOU bugs), and serve as documentation in the schema. Application validation can provide better UX (user-friendly errors), handle complex business rules, and be iterated faster without schema changes. Rule: use database constraints for data integrity rules, application validation for business policy.

8. How do constraints interact with transactions, and what is the difference between deferred and immediate constraint checking?

Constraints can be DEFERRABLE (checked at transaction end) or NOT DEFERRABLE (checked immediately after each statement). For bulk imports with interdependent data — inserting an order before its customer — DEFERRABLE allows the transaction to succeed even though intermediate states violate FK. NOT DEFERRABLE would fail immediately. The performance difference: deferred checking batches all constraint validations at commit, reducing overhead for multi-row operations with relationships. Immediate checking provides faster failure detection but more overhead per statement. PostgreSQL defaults to NOT DEFERRABLE. Use DEFERRABLE INITIALLY DEFERRED for bulk loads and circular references.

9. Why does PostgreSQL allow multiple NULL values in a column with a UNIQUE constraint, but most other databases do not?

SQL standards treat NULL as unknown rather than a value, so NULL = NULL evaluates to NULL (not true). PostgreSQL follows this standard: two NULLs are not considered equal, so multiple NULLs do not violate a UNIQUE constraint. Oracle, MySQL (with InnoDB), and SQL Server treat NULL more like a specific value — once one NULL exists, no more NULLs are allowed. This has practical implications: if you need to allow exactly one NULL (like a flag column), PostgreSQL requires a partial unique index or exclusion constraint instead of a simple UNIQUE constraint. Always test unique constraint behavior with NULL values in your specific database.

10. A CHECK constraint references a user-defined function that modifies global state. What are the hidden dangers?

CHECK constraints must be immutable — they are evaluated during INSERT and UPDATE, and modifying global state inside a constraint creates serious problems: the constraint may fire multiple times per statement (once per row), the side effects compound unpredictably; aborted transactions may leave global state partially modified, breaking invariants; and parallel query execution can trigger the function simultaneously from multiple workers. The fix: CHECK constraints should contain only pure, deterministic expressions with no side effects. Any validation requiring side effects should be moved to a trigger with explicit transaction management and appropriate locking.

11. When would you choose a partial index over a CHECK constraint for enforcing uniqueness?

A partial index enforces uniqueness only for rows matching a condition, while a CHECK constraint always evaluates for every row. For example, to enforce that only active orders have sequential invoice numbers, a partial index UNIQUE (invoice_number) WHERE status = 'active' is more efficient than a CHECK constraint that fires for all statuses. Partial indexes are smaller (fewer entries) and faster to update. Use CHECK constraints when the condition is simple and must always be enforced. Use partial indexes when the condition is selective — only a subset of rows require the constraint.

12. How do self-referential foreign key constraints work, and what are the common pitfalls?

Self-referential FKs allow a row to reference itself — common for org charts (employee → manager_id) and bill-of-materials (part → component_part_id). Implementation requires careful ordering: insert the root row first (no parent reference), then insert children. The DEFERRED constraint option delays FK checking to transaction end, which is essential for bulk inserts. Pitfalls: deleting a parent requires recursive delete or reassignment of children; circular references can cause deadlocks under concurrent updates; and some databases require the FK index to exist before the constraint can be added.

13. What happens when a NOT DEFERRABLE constraint encounters a violation in a multi-row INSERT with ON CONFLICT?

NOT DEFERRABLE constraints are checked immediately after each statement. In a multi-row INSERT with ON CONFLICT DO NOTHING, if row 5 violates a constraint, rows 1-4 are already inserted — and they stay inserted. The conflict is silently ignored for the violating row. This is different from a transaction that rolls back entirely on constraint violation. With ON CONFLICT DO UPDATE, the constraint is checked before the ON CONFLICT clause fires — so if the conflict target is violated, the UPDATE proceeds instead. Understanding this behavior is critical for data warehouse loads where partial success is acceptable.

14. How do exclusion constraints prevent overlapping date ranges, and what makes GiST indexes suitable for this?

Exclusion constraints use GiST (Generalized Search Tree) indexes which support overlap operators (&& for ranges, ~= for point). For room bookings: EXCLUDE USING GIST (room_id WITH =, booking_range WITH &&) means "no two bookings may have the same room_id AND overlapping booking_range simultaneously." GiST indexes store complex structures (ranges, geometric shapes) and support efficient overlap queries that B-tree indexes cannot perform. The tradeoff: GiST indexes are 5-10× larger and slower to update than B-tree, making them unsuitable for high-write tables.

15. What is the difference between constraint validation at statement time versus row time in PostgreSQL?

PostgreSQL validates row-level constraints (NOT NULL, CHECK) per row during execution. Statement-level constraints (UNIQUE, FK, EXCLUSION) are validated after all rows are processed, but triggers fire per-row. With multiple constraints, the order matters: NOT NULL and CHECK are checked per-row immediately; UNIQUE and FK constraints are checked after row evaluation but before statement completion. For bulk inserts, this means constraint violations fail the entire statement, while trigger-based validation can succeed per-row even if some rows fail.

16. Your team wants to enforce that an order cannot be cancelled after it has been shipped. How do you model this in the database?

The business rule "cannot cancel after shipping" is a state transition constraint. In PostgreSQL, use a CHECK constraint with a conditional expression: CHECK (status != 'cancelled' OR shipping_date IS NULL). This allows cancellation only if shipping_date is NULL (not yet shipped). Alternative: use a status transition table defining allowed transitions (pending → processing → shipped → delivered, with cancelled allowed only from pending/processing), and a trigger that validates the transition. The CHECK approach is simpler; the transition table is more flexible for complex state machines.

17. How do you use constraint naming conventions to make error messages more actionable?

Named constraints produce clear error messages: instead of "ERROR: duplicate key value violates unique constraint pg_4064", you get "ERROR: duplicate key value violates unique constraint customers_email_key". Convention: use tablename_columnname_qualifier_constraintType format (e.g., orders_user_id_fk, products_price_positive_check). For CHECK constraints, name the rule (e.g., valid_subscription_status_check). PostgreSQL auto-generates names if you omit them, but they are cryptic. Naming conventions also help during schema migrations when you need to drop or modify specific constraints.

18. When should you use NOT VALID to add a constraint, and what are the risks?

NOT VALID postpones constraint validation — the constraint is recorded but existing data is not checked. Use it when adding a constraint to a large table where checking all existing rows would take hours and lock the table. You add it NOT VALID, then validate later with ALTER TABLE ... VALIDATE CONSTRAINT which takes only a SHARE UPDATE EXCLUSIVE lock. Risk: until validated, the constraint does not enforce for existing data, only new writes. This is acceptable during a migration window but creates a window of incomplete enforcement. Always schedule the VALIDATE step immediately after.

19. How do foreign key constraints interact with index-only scans, and why does this matter for DELETE performance?

When you DELETE from a parent table, PostgreSQL checks child FK values. Without an index on the child FK column, it performs a seq scan of the child table for each parent row deleted. With an index, it uses an index scan. However, for index-only scans (covering indexes), PostgreSQL can sometimes satisfy the FK check entirely from the index without touching the heap. This is particularly valuable for soft-deleted data where the FK column is included in the index. Always index foreign key columns in child tables — this is not optional for production systems with frequent parent deletes.

20. A constraint violation produces a generic error message. How do you customize it at the database level?

PostgreSQL does not support custom constraint violation messages directly. Options: use a named constraint so the error includes the constraint name (e.g., customers_email_unique instead of pg_4064); use a trigger with RAISE EXCEPTION that produces a custom message before the database constraint fires; or use a user-defined function as the constraint expression that raises a custom message when validation fails. The most practical approach: name your constraints clearly, and handle constraint violation errors at the application layer where you can map SQLSTATE codes to user-friendly messages.


Further Reading


Conclusion

Database constraints are not optional scaffolding — they’re fundamental to data integrity. The database should be your default enforcement layer for any rule that governs what data can exist in your system.

Application-level validation complements but cannot replace database constraints. Use it for user experience, business policy iteration, and complex rules that exceed declarative constraint capabilities.

The performance overhead of proper constraint enforcement is almost always less than the cost of data corruption, consistency bugs, or integrity failures. Design your schema with constraints first, then optimize only when measurements prove it necessary.

Your database is the last line of defense for your data. Make it count.


Ready to dive deeper into schema design? Check out our guide on relational databases for foundational concepts, or explore schema design for practical normalization strategies.

Category

Related Posts

Schema Design: Building the Foundation of Your Database

Learn how to design effective database schemas with proper data types, constraints, and relationships that scale with your application.

#database #schema-design #relational-databases

Database Indexes: B-Tree, Hash, Covering, and Beyond

A practical guide to database indexes. Learn when to use B-tree, hash, composite, and partial indexes, understand index maintenance overhead, and avoid common performance traps.

#database #indexes #performance

Understanding SQL JOINs and Database Relationships

Master SQL JOINs with this practical guide covering INNER, LEFT, RIGHT, FULL OUTER, and CROSS joins. Learn how relationship types between tables shape your queries.

#database #joins #sql