Postgres domain types are one of the most underused features in the schema toolbox. They sit between raw text columns with CHECK constraints and full ENUM types, offering centralized constraint definition without the migration friction of ENUMs. Knowing when domains earn their cost is one of those schema decisions that pays back across years and is mostly forgotten in the discussions about type-system enforcement at the database level.
What a domain type is
A Postgres domain is a user-defined type built on top of a base type with optional default value, NOT NULL constraint, and CHECK constraint. CREATE DOMAIN email_address AS text CHECK (VALUE ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') produces a type that behaves like text but enforces the email format on every value. Columns declared as email_address inherit the constraint without restating it.
The domain looks superficially like a typedef but is more than a name alias. The constraints attach to the type and are enforced anywhere the type is used: column definitions, function parameters, function return values, and CAST expressions. The centralization is the value: changing the constraint in one place updates enforcement everywhere the domain appears.
The three things domains do well
The first thing domains do well is centralize a constraint that applies to multiple columns. An email_address domain ensures every email column in the schema follows the same format rule. A positive_amount domain ensures every monetary amount column is non-negative. A non_empty_text domain ensures every string column rejects empty strings. The savings compound across schemas with dozens of similar columns.
The second thing domains do well is document semantic intent in the schema. A column typed as customer_id is more informative than a column typed as integer. Future readers of the schema know that the column carries customer identity rather than an arbitrary integer. The information is useful for ER diagrams, schema documentation tools, and code generators that infer relationships from type usage.
The third thing domains do well is interoperate cleanly with the base type. A domain built on text behaves like text in every query that does not explicitly check the type. The interoperability means that domain types can be added to existing schemas without breaking application code that does not yet know about them. The migration story is the cleanest among the three constraint-enforcement options.
The migration story
Adding a domain to a new column is straightforward: CREATE DOMAIN then declare the column using the domain. The constraint is enforced from creation. No data is in conflict because the column starts empty.
Adding a domain to an existing column requires a type change: ALTER TABLE ... ALTER COLUMN ... TYPE domain_name. The change runs the constraint against every existing row and either succeeds or fails atomically. Failure produces a clear error pointing at the first violating row, which is useful for data cleanup. The ALTER takes ACCESS EXCLUSIVE lock for the duration, which is the cost of the change.
Changing a domain's constraint is the operation where domains shine compared to scattered CHECK constraints. ALTER DOMAIN ... DROP CONSTRAINT and ALTER DOMAIN ... ADD CONSTRAINT update enforcement across every column that uses the domain. The change is atomic and runs in a single transaction. The contrast with managing fifty per-column CHECK constraints is substantial when the underlying rule changes.
Removing a domain requires switching all referring columns to a different type first. The dependency tracking is handled by Postgres: DROP DOMAIN with the RESTRICT default fails if columns still reference it. The CASCADE option removes the columns, which is rarely what is intended. The right pattern is to migrate columns to the base type one at a time before dropping the domain.
What domains do not do
Domains do not provide the same type-system enforcement as ENUMs. A function that takes a parameter of type email_address will accept a text value implicitly cast to the domain, which is convenient but means the type system does not prevent passing arbitrary text where an email is expected. The CHECK constraint enforces validity at the call boundary, but the static-type-checking benefit is weaker than ENUMs provide.
Domains do not solve the value-set enumeration problem. A domain CAN enforce membership in a small set via CHECK (VALUE IN ('a', 'b', 'c')), but the rationale for the membership is buried in the constraint expression. An ENUM expresses the value set as the type definition and exposes it via pg_enum for introspection. A lookup table exposes the value set as data with full metadata. Domains are middle-of-the-road for this use case.
Domains do not catch logic errors that the constraint expression cannot encode. A NOT NULL email_address column does not catch values that match the format but point to deliverability problems. The constraint is a syntactic check, not a semantic one. Application-layer or external-service validation handles the deeper checks. The boundary between schema-level enforcement and application-level enforcement is the same boundary that applies to any CHECK constraint.
The composition pattern
Domain composition is a feature that is rarely used and is worth knowing about. A domain can be built on another domain, and the constraints stack. CREATE DOMAIN verified_email_address AS email_address CHECK (VALUE NOT LIKE '%@example.com') produces a type that enforces both the format rule and the example.com exclusion. The constraints accumulate; both must pass for a value to be accepted.
The composition pattern is useful for refining domains in specific contexts. A general email_address domain might be used for any email column, while a customer_email_address subdomain adds rules specific to customer-facing emails. The hierarchy is documented in the type system rather than scattered across column-level constraints.
The cost of composition is the same migration cost as the underlying domains: changing a base domain's constraint affects all subdomains, and changing a subdomain affects only that subdomain. The propagation behavior matches the inheritance structure and is reasonably predictable.
The case for CHECK constraints instead
The argument for column-level CHECK constraints instead of domains is simplicity. A column with an inline CHECK is self-contained: reading the column definition tells you the rule. A column with a domain type requires looking up the domain definition to see the rule. The indirection is small but real and affects schema readability for occasional reviewers.
The argument for inline CHECKs also applies when the constraint is genuinely one-off. A status column with a CHECK (status IN ('pending', 'active')) constraint is unlikely to be reused for other columns. Promoting it to a domain adds a new type for no centralization benefit. The right test is whether the constraint will appear on multiple columns: if yes, a domain is justified; if no, an inline CHECK is the simpler choice.
The case for ENUM types instead
The argument for ENUM types instead of domains is type-system strictness. ENUMs are full types with no implicit cast from the base type. Functions, parameters, and return values are strictly typed. The schema documents the value set in the type definition rather than in a CHECK expression that must be parsed. The introspection via pg_enum is cleaner than parsing CHECK constraint expressions from pg_constraint.
The argument also applies when the value set sorts in a non-alphabetical order that matters. ENUM declaration order is the sort order, which makes status-progression queries clean. A domain on text sorts alphabetically, which usually does not match the desired progression order. Adding a separate sort_order column to a lookup table is the alternative.
Three patterns where domains hurt
The first pattern that hurts is using a domain to encode constraints that should change frequently. A domain CHECK constraint that requires monthly updates is the wrong tool because the ALTER DOMAIN operation has the same operational cost as ALTER TABLE. The right pattern for frequently-changing rules is application-level validation backed by a lookup table that can be updated without DDL.
The second pattern that hurts is over-specifying domains. A schema with fifty domain types where ten would suffice produces type-system clutter without proportional benefit. The right heuristic is to promote a constraint to a domain only when it applies to at least three columns and the constraint expression is non-trivial.
The third pattern that hurts is treating domains as opaque types that must always be explicitly cast. The implicit cast from base type to domain is a feature, not a bug. Application code can pass text values where email_address is expected and the constraint enforces validity. Forcing explicit casts everywhere adds noise without protection benefit.
Our use across the four products
None of our four products currently use domain types. The CHECK-constraint-on-column approach has been adequate for the per-product schemas. The case for domains is the cross-product reuse: an email_address domain shared across DocuMint, CronPing, FlagBit, and WebhookVault would centralize the email validation rule across the four product schemas. The savings are limited at our scale because the email validation rule has not changed in a year and the per-product CHECK constraints are easy to keep aligned.
The case for domains is stronger as the schema grows. A schema with hundreds of columns benefits from the centralization in proportion to the column count. The threshold where domains earn their cost is somewhere around fifty columns sharing a constraint, which we have not yet hit on any single product but may approach as the consolidated cross-product analytics schema develops.
The deeper observation is that domain types are one of the schema features that exist in the SQL standard, work well in Postgres, and are mostly unknown to application developers because the type-system benefit is invisible until the centralization payoff materializes. The pattern of useful database features being unused because their benefit is non-obvious recurs across triggers, window functions, common table expressions, and many other features. Knowing the tool exists is the prerequisite for reaching for it when the situation justifies the cost.
Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) put these patterns into production.