Back to Blog

PostgreSQL Constraints and Validation: CHECK, EXCLUDE, Deferrable Foreign Keys, and Domain Types

Use PostgreSQL constraints for database-level validation. Covers CHECK constraints with complex expressions, EXCLUDE constraints for non-overlapping ranges, deferrable foreign keys for circular references, domain types for reusable validation, and constraint naming conventions.

Viprasol Tech Team
May 25, 2027
11 min read

Application-level validation can be bypassed — by direct database access, migrations, batch scripts, or bugs. Database constraints cannot. PostgreSQL's constraint system goes far beyond NOT NULL: CHECK validates business rules, EXCLUDE prevents overlapping ranges (perfect for bookings and reservations), deferrable constraints handle circular references, and domain types let you define reusable validation logic once.

CHECK Constraints

-- Basic CHECK: single column rule
CREATE TABLE products (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  price_cents INTEGER NOT NULL CHECK (price_cents > 0),
  tax_rate    NUMERIC(5, 4) NOT NULL
    CHECK (tax_rate >= 0 AND tax_rate <= 1),   -- 0-100%

  -- Named constraint: error message includes constraint name
  discount_pct NUMERIC(5, 2) NOT NULL DEFAULT 0
    CONSTRAINT chk_discount_valid CHECK (discount_pct >= 0 AND discount_pct <= 100)
);

-- Multi-column CHECK: cross-column rules
CREATE TABLE subscriptions (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  starts_at    TIMESTAMPTZ NOT NULL,
  ends_at      TIMESTAMPTZ,

  -- ends_at must be after starts_at if provided
  CONSTRAINT chk_subscription_dates
    CHECK (ends_at IS NULL OR ends_at > starts_at),

  plan         TEXT NOT NULL CHECK (plan IN ('free', 'starter', 'growth', 'enterprise')),
  mrr_cents    INTEGER NOT NULL DEFAULT 0,

  -- Free plan can't have MRR
  CONSTRAINT chk_free_plan_no_mrr
    CHECK (plan != 'free' OR mrr_cents = 0)
);

-- Computed column validation
CREATE TABLE invoices (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  subtotal_cents  INTEGER NOT NULL CHECK (subtotal_cents >= 0),
  tax_cents       INTEGER NOT NULL CHECK (tax_cents >= 0),
  total_cents     INTEGER NOT NULL,

  -- Total must equal subtotal + tax
  CONSTRAINT chk_invoice_total_correct
    CHECK (total_cents = subtotal_cents + tax_cents)
);

EXCLUDE Constraints: Non-Overlapping Ranges

EXCLUDE is PostgreSQL's most powerful constraint — it prevents overlap between rows using GiST index operators. Essential for booking/reservation systems:

-- Enable btree_gist for EXCLUDE with non-range types
CREATE EXTENSION IF NOT EXISTS btree_gist;

-- Resource bookings: prevent double-booking
CREATE TABLE bookings (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  resource_id UUID NOT NULL REFERENCES resources(id),
  user_id     UUID NOT NULL REFERENCES users(id),
  booked_for  TSTZRANGE NOT NULL,  -- e.g., [2027-05-21 09:00, 2027-05-21 10:00)

  -- Prevent overlapping bookings for the same resource
  -- Two ranges overlap if they share any time
  CONSTRAINT excl_booking_no_overlap
    EXCLUDE USING GIST (
      resource_id WITH =,      -- Same resource
      booked_for  WITH &&      -- Overlapping time ranges
    )
);

-- Insert a booking
INSERT INTO bookings (resource_id, user_id, booked_for)
VALUES (
  'resource-uuid',
  'user-uuid',
  '[2027-05-21 09:00+00, 2027-05-21 10:00+00)'
);

-- This insert fails with "conflicting key value violates exclusion constraint":
INSERT INTO bookings (resource_id, user_id, booked_for)
VALUES (
  'resource-uuid',
  'user-uuid-2',
  '[2027-05-21 09:30+00, 2027-05-21 11:00+00)'  -- Overlaps with existing
);

-- Query: find available slots (what's NOT booked)
SELECT
  generate_series(
    '2027-05-21 08:00+00'::timestamptz,
    '2027-05-21 18:00+00'::timestamptz,
    '30 minutes'::interval
  ) AS slot_start
EXCEPT
SELECT lower(booked_for)
FROM bookings
WHERE resource_id = 'resource-uuid'
  AND booked_for && '[2027-05-21 08:00+00, 2027-05-21 18:00+00)'::tstzrange;
-- EXCLUDE with condition: allow overlaps only for cancelled bookings
CREATE TABLE room_bookings (
  id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  room_id   UUID NOT NULL,
  period    TSTZRANGE NOT NULL,
  status    TEXT NOT NULL DEFAULT 'confirmed'
    CHECK (status IN ('confirmed', 'cancelled')),

  -- Only exclude overlaps among non-cancelled bookings
  -- btree_gist needed for the status = column
  CONSTRAINT excl_room_no_double_book
    EXCLUDE USING GIST (
      room_id WITH =,
      period  WITH &&
    ) WHERE (status = 'confirmed')   -- ← Partial EXCLUDE: only applies to confirmed
);

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

Deferrable Constraints: Circular References

-- Problem: workspace references default_project; project references workspace
-- Normal FK creates a circular reference that blocks INSERT

CREATE TABLE workspaces (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name            TEXT NOT NULL,
  -- FK to projects — can't reference before projects table exists
  default_project_id UUID
    REFERENCES projects(id)
    DEFERRABLE INITIALLY DEFERRED   -- ← Check at COMMIT, not statement level
);

CREATE TABLE projects (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id UUID NOT NULL REFERENCES workspaces(id),
  name         TEXT NOT NULL
);

-- Now you can insert both in one transaction:
BEGIN;

INSERT INTO workspaces (id, name) VALUES ('ws-uuid', 'Acme Corp');
-- default_project_id is NULL here — FK deferred until COMMIT

INSERT INTO projects (id, workspace_id, name)
VALUES ('proj-uuid', 'ws-uuid', 'Default Project');

UPDATE workspaces
SET default_project_id = 'proj-uuid'
WHERE id = 'ws-uuid';

COMMIT;  -- FK checked here — both records exist, constraint passes

Domain Types: Reusable Validation

-- Define once, use everywhere
CREATE DOMAIN email_address AS TEXT
  CHECK (VALUE ~ '^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$');

CREATE DOMAIN positive_integer AS INTEGER
  CHECK (VALUE > 0);

CREATE DOMAIN percentage AS NUMERIC(5, 2)
  CHECK (VALUE >= 0 AND VALUE <= 100);

CREATE DOMAIN currency_code AS TEXT
  CHECK (VALUE ~ '^[A-Z]{3}$');   -- ISO 4217: USD, EUR, GBP

-- Use in tables
CREATE TABLE users (
  id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email email_address NOT NULL UNIQUE,   -- Domain type
  name  TEXT NOT NULL
);

CREATE TABLE pricing_tiers (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  price_cents positive_integer NOT NULL,    -- Must be > 0
  discount    percentage NOT NULL DEFAULT 0, -- 0-100
  currency    currency_code NOT NULL DEFAULT 'USD'
);

-- Domain violation gives a clear error:
-- INSERT INTO users (email) VALUES ('not-an-email')
-- ERROR: value for domain email_address violates check constraint "email_address_check"

-- Alter domain validation (applies to all columns using it):
ALTER DOMAIN email_address
  ADD CONSTRAINT email_no_plus
    CHECK (VALUE NOT LIKE '%+%');  -- Disallow + aliases if needed

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

Constraint Naming Conventions

-- Naming convention: {table}_{columns}_{constraint_type}
-- Makes error messages self-documenting

CREATE TABLE order_items (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  order_id    UUID NOT NULL,
  product_id  UUID NOT NULL,
  quantity    INTEGER NOT NULL,
  unit_price  INTEGER NOT NULL,

  -- Named constraints: error message includes the name
  CONSTRAINT order_items_quantity_positive   CHECK (quantity > 0),
  CONSTRAINT order_items_unit_price_positive CHECK (unit_price > 0),
  CONSTRAINT order_items_order_id_fk         FOREIGN KEY (order_id)   REFERENCES orders(id),
  CONSTRAINT order_items_product_id_fk       FOREIGN KEY (product_id) REFERENCES products(id),
  CONSTRAINT order_items_order_product_unique UNIQUE (order_id, product_id)
);

-- List all constraints on a table
SELECT
  conname        AS constraint_name,
  contype        AS type,  -- c=check, f=foreign key, p=primary key, u=unique, x=exclude
  pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = 'order_items'::regclass
ORDER BY contype, conname;

Handling Constraint Violations in TypeScript

// lib/db/constraint-errors.ts
import { Prisma } from "@prisma/client";

export interface ConstraintViolation {
  constraint: string;
  table:      string;
  detail:     string;
}

export function parseConstraintError(
  error: unknown
): ConstraintViolation | null {
  if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return null;

  // P2002: Unique constraint violation
  if (error.code === "P2002") {
    const fields = (error.meta?.target as string[]) ?? [];
    return {
      constraint: `unique_${fields.join("_")}`,
      table:      (error.meta?.modelName as string) ?? "unknown",
      detail:     `Unique constraint failed on fields: ${fields.join(", ")}`,
    };
  }

  // P2003: Foreign key constraint violation
  if (error.code === "P2003") {
    return {
      constraint: "foreign_key",
      table:      (error.meta?.modelName as string) ?? "unknown",
      detail:     `Foreign key constraint failed on field: ${error.meta?.field_name}`,
    };
  }

  return null;
}

// For raw SQL constraint violations (CHECK, EXCLUDE):
export function parseRawConstraintError(error: unknown): string | null {
  if (!(error instanceof Error)) return null;
  const match = error.message.match(/constraint "([^"]+)"/);
  return match ? match[1] : null;
}

// Usage in Server Action:
export async function createBooking(data: BookingInput) {
  try {
    return await prisma.$queryRaw`
      INSERT INTO bookings (resource_id, user_id, booked_for)
      VALUES (${data.resourceId}::uuid, ${data.userId}::uuid, ${data.range}::tstzrange)
      RETURNING id
    `;
  } catch (err) {
    const constraintName = parseRawConstraintError(err);
    if (constraintName === "excl_booking_no_overlap") {
      return { error: "This time slot is already booked. Please choose a different time." };
    }
    throw err;
  }
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
CHECK constraints on existing tables1 devHalf a day$150–300
EXCLUDE constraint for booking system1 dev1–2 days$400–800
Domain types + deferrable FK1 dev1 day$300–600
Full constraint audit + naming1 dev1–2 days$400–800

See Also


Working With Viprasol

Database constraints are the last line of defense against invalid data — they enforce rules even when application code has bugs, migrations run directly, or scripts bypass your API. Our team audits schemas for missing constraints, adds named CHECKs for business rules, implements EXCLUDE for booking/reservation systems (with btree_gist for mixed-type columns), and uses deferrable FKs to resolve circular reference deadlocks in complex schemas.

What we deliver:

  • Named CHECK constraints: price > 0, dates ordered, plan/MRR cross-column rule, total = subtotal + tax
  • btree_gist extension + EXCLUDE USING GIST for booking overlap prevention
  • Partial EXCLUDE with WHERE (status = 'confirmed') for soft-delete-compatible bookings
  • DEFERRABLE INITIALLY DEFERRED FK for circular workspace↔project reference
  • Domain types: email_address, positive_integer, percentage, currency_code (ISO 4217)
  • parseRawConstraintError for user-friendly constraint violation messages

Talk to our team about your database schema design →

Or explore our cloud and data services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.