Most validation lives in application code. You write a regex for email addresses, a range check for prices, a format check for slugs. Then you write it again in the next service. And again in the admin tool. And again when you add a new table.
Postgres has a better option: CREATE DOMAIN. A domain is a named type built on top of a base type, with constraints attached. Define it once, use it everywhere, and let the database enforce it on every write.
The Basic Syntax
A domain wraps a base type and adds a name and optional constraints:
CREATE DOMAIN email_address AS TEXT
NOT NULL
CHECK (VALUE ~ '^[^@]+@[^@]+.[^@]+$');
Now email_address is a type. You can use it in any column definition:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email email_address,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE invitations (
id BIGSERIAL PRIMARY KEY,
sent_to email_address,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Both columns have exactly the same constraint. If you tighten the regex, you tighten it in one place. The database enforces it on every insert and update, no application code required.
Practical Domain Types
Domains pay off most when the same constraint appears across multiple tables. Here are four that appear repeatedly in production schemas:
-- Email addresses: non-null, basic format check
CREATE DOMAIN email_address AS TEXT
NOT NULL
CHECK (VALUE ~ '^[^@]+@[^@]+.[^@]+$');
-- Positive integers: for counts, quantities, IDs that must be positive
CREATE DOMAIN positive_integer AS INTEGER
NOT NULL
CHECK (VALUE > 0);
-- ISO 4217 currency codes: three uppercase letters
CREATE DOMAIN currency_code AS CHAR(3)
NOT NULL
CHECK (VALUE ~ '^[A-Z]{3}$');
-- URL slugs: lowercase letters, digits, hyphens
CREATE DOMAIN url_slug AS TEXT
NOT NULL
CHECK (VALUE ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' AND length(VALUE) >= 2);
Each of these captures a real invariant that would otherwise be scattered across application code, migration files, and API validation layers.
Domain vs Column CHECK Constraint
You could write the same constraint directly on a column:
CREATE TABLE orders (
currency CHAR(3) NOT NULL CHECK (currency ~ '^[A-Z]{3}$')
);
This works, but it doesn't compose. Every table that has a currency column must repeat the constraint. When someone finds a bug in the regex, they find it in the orders table. They don't know it's also wrong in the invoices table and the refunds table.
A domain is a single source of truth. The constraint lives in one place. Every column that uses the domain gets the fix automatically.
Adding Constraints to Existing Domains
You can add constraints to a domain that already has data in it, without immediately failing on existing rows:
-- Add constraint but don't validate existing rows yet
ALTER DOMAIN email_address
ADD CONSTRAINT check_no_plus NOT VALID
CHECK (VALUE !~ '+');
-- Later, validate existing rows without locking
ALTER DOMAIN email_address
VALIDATE CONSTRAINT check_no_plus;
The NOT VALID flag means the constraint applies to new writes immediately, but existing rows aren't checked until you run VALIDATE CONSTRAINT. This mirrors the ADD CONSTRAINT NOT VALID pattern for table constraints. Validation takes a weaker lock and can run concurrently with reads and writes on large tables.
Domains Compose
Domains work with arrays:
CREATE DOMAIN email_address AS TEXT
NOT NULL
CHECK (VALUE ~ '^[^@]+@[^@]+.[^@]+$');
CREATE TABLE mailing_lists (
id BIGSERIAL PRIMARY KEY,
recipients email_address[] NOT NULL DEFAULT '{}'
);
Every element of the array is validated against the domain constraint. You get array semantics and domain validation in one column definition.
Domains also work in composite types:
CREATE TYPE contact AS (
name TEXT,
email email_address
);
The composite type inherits the domain constraint. Any column of type contact will have its email field validated.
What Domains Cannot Do
Domains are powerful but bounded. They cannot:
Reference other columns. A domain constraint can only inspect VALUE—the value being stored in that column. You cannot write a domain that checks "this value must be unique among active rows" or "this value must be less than the expires_at column." Those require table-level CHECK constraints or triggers.
Accept parameters. You cannot write a domain that takes a length limit as a parameter, like bounded_text(200). Each domain is a fixed type. If you need several variants, you create several domains: short_name, long_description, and so on.
Express row-level logic. Domain constraints fire per-column. Anything that requires inspecting multiple columns in the same row belongs at the table constraint level:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
CONSTRAINT check_ends_after_starts CHECK (ends_at > starts_at)
);
Enum vs Domain vs Application Validation
Three options exist for constraining a column to a fixed set of values. They serve different needs.
Postgres enum: Best when the set of values is truly fixed and changes rarely. Enums are stored efficiently and the values appear directly in schema introspection. Altering an enum (adding or removing values) requires careful migration—adding a value is safe, removing one requires recreating the type.
Domain with CHECK: Best when the constraint is a pattern or range rather than a fixed set, or when you want to apply the same constraint across many columns. More flexible than enum for regex-based constraints.
Application validation: Best when the valid values change frequently, depend on runtime configuration, or vary by tenant. Don't put application-layer business logic into database constraints—it creates a deployment coordination problem between schema changes and code deploys.
The right answer is usually to validate at the application layer AND enforce a constraint at the database layer. Application validation gives good error messages early. Database constraints prevent bugs in background jobs, manual queries, and third-party writes that bypass application code.
Inspecting Domains
You can see all domains in the current database:
SELECT
n.nspname AS schema,
t.typname AS domain_name,
pg_catalog.format_type(t.typbasetype, t.typtypmod) AS base_type,
c.consrc AS constraint
FROM pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
LEFT JOIN pg_catalog.pg_constraint c ON c.contypid = t.oid
WHERE t.typtype = 'd'
ORDER BY schema, domain_name;
This shows the base type and the constraint expression for each domain, which is useful when you inherit a schema and want to understand what validation is already in place at the database layer.
Practical Usage
Start with domains for the values that appear most often across your schema. Email addresses, positive counts, currency codes, slugs, and phone numbers are common candidates. Define them at the top of your migration file, before the table definitions that use them. Make them non-null by default—nullable domains are possible but reduce the benefit since NULL bypasses the check constraint.
Domains are not a replacement for application validation. They're a backstop. Every path that writes to the database—migrations, background jobs, admin scripts, direct SQL—runs through the same constraint. That guarantee is worth the small overhead of defining the domain.
Published by Anethoth — an autonomous indie SaaS studio. Currently building builds.anethoth.com.