PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 3: Data types

Enums, domains and composite types

Level 1: Foundations25 min

PostgreSQL lets you define your own types. Enums give a fixed ordered list of labels, domains put a reusable constraint on a base type, and composite types group fields into one value. Each has trade-offs you need to know before putting it in a schema.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Create enums, add values and understand their ordering and limits
  • Use domains to define a constraint once and reuse it across tables
  • Use composite types for function results and grouped fields

conceptThe concept

Enum: CREATE TYPE order_state AS ENUM ('pending', 'paid', 'shipped'). Values are stored as 4-byte OIDs, sort in declaration order (not alphabetically) and reject anything not in the list. You can add values (ALTER TYPE ... ADD VALUE ... BEFORE/AFTER) and rename them, but you cannot remove or reorder values without recreating the type.

Domain: CREATE DOMAIN email AS text CHECK (...) is a base type plus NOT NULL, DEFAULT and CHECK constraints. Every column, function argument and cast that uses the domain gets the rule, and ALTER DOMAIN ... ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT changes it everywhere.

Composite type: CREATE TYPE address AS (street text, city text, postcode text). Every table already has one with the same name as the table. Use them for functions that return several columns and occasionally for grouped columns; access fields with (col).field.

NeedBest fit
Small, stable, ordered set of statesEnum
Set that changes often or needs extra attributes (label, sort, active flag)Lookup table with a foreign key
Same validation rule in many tablesDomain
Rule specific to one columnCHECK constraint on that column

whyWhy it matters

Types push rules into the database where no application path can skip them. A domain for currency amounts or email addresses means every new table gets the rule for free, and an enum makes invalid states impossible to store while using a quarter of the space of the text label.

Use caseReal-world scenario

An order system uses an enum for order_state because the lifecycle is ordered and rarely changes, and WHERE state >= 'shipped' reads naturally. Amounts use a money_amount domain (numeric(12,2), non-negative) shared by orders, refunds and invoices. Product categories, which marketing changes weekly, live in a lookup table instead.

Try itExamples

Run and edit these in the interactive lesson.

An enum, its order and its catalog
BEGIN;
CREATE TYPE order_state AS ENUM ('pending', 'paid', 'shipped');
ALTER TYPE order_state ADD VALUE 'delivered' AFTER 'shipped';
ALTER TYPE order_state ADD VALUE 'on_hold' BEFORE 'paid';
SELECT enum_range(NULL::order_state);
SELECT 'paid'::order_state > 'on_hold'::order_state AS paid_sorts_after_on_hold;
SELECT enumlabel, enumsortorder FROM pg_enum
WHERE enumtypid = 'order_state'::regtype ORDER BY enumsortorder;
ROLLBACK;
on_hold got sort order 1.5: halfway between pending and paid, without renumbering anything.
Enums reject unknown labels
BEGIN;
CREATE TYPE order_state AS ENUM ('pending', 'paid', 'shipped');
SELECT 'refunded'::order_state;
ROLLBACK;
A domain shared by two tables
BEGIN;
CREATE DOMAIN money_amount AS numeric(12,2) NOT NULL CHECK (VALUE >= 0);
CREATE TABLE refund (id int PRIMARY KEY, amount money_amount);
CREATE TABLE invoice (id int PRIMARY KEY, amount money_amount);
INSERT INTO refund VALUES (1, 19.99);
INSERT INTO invoice VALUES (1, 120);
SELECT table_name, column_name, domain_name
FROM information_schema.columns
WHERE domain_name = 'money_amount' ORDER BY table_name;
ROLLBACK;
The domain rule fires everywhere
BEGIN;
CREATE DOMAIN email AS text CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[a-z]{2,}$');
CREATE TABLE signup (id int PRIMARY KEY, contact email);
INSERT INTO signup VALUES (1, 'asha@example.com');
INSERT INTO signup VALUES (2, 'not-an-email');
ROLLBACK;
The error names the domain constraint (email_check), so the message is the same in every table that uses it.
Composite type fields
BEGIN;
CREATE TYPE address AS (street text, city text, postcode text);
CREATE TABLE depot (id int PRIMARY KEY, addr address);
INSERT INTO depot VALUES (1, ROW('1 Dock Rd', 'Pune', '411001'));
SELECT (addr).city, addr FROM depot;
UPDATE depot SET addr.postcode = '411002' WHERE id = 1;
SELECT (d.addr).* FROM depot d;
ROLLBACK;
The parentheses in (addr).city are required, otherwise PostgreSQL reads addr as a table name. In SET, no parentheses are used.
Every table is a composite type
SELECT c AS whole_row, (c).city
FROM customers c
ORDER BY id
LIMIT 2;

internalsUnder the hood

Enum labels live in pg_enum with a float enumsortorder, which is how ADD VALUE BEFORE inserts between two existing values without renumbering. A value added in a transaction cannot be used in that same transaction unless the type itself was created in it ("unsafe use of new value"). Domains are checked on input and on cast to the domain; a domain over a composite or an array works too. Composite values are stored inline in the row as a nested tuple with their own header.

Avoid / preferDo this, not that

Avoid
CREATE TYPE category AS ENUM ('shoes', 'bags', 'hats');
-- marketing adds and retires categories every month
Prefer
CREATE TABLE category (code text PRIMARY KEY, label text NOT NULL, active boolean NOT NULL DEFAULT true);
ALTER TABLE products ADD FOREIGN KEY (category) REFERENCES category (code);

Enum values cannot be removed or reordered without recreating the type and rewriting every column that uses it. Volatile sets belong in a lookup table.

Avoid
CREATE TABLE a (email text CHECK (email ~ '@'));
CREATE TABLE b (email text CHECK (email LIKE '%@%.%'));
Prefer
CREATE DOMAIN email AS text CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[a-z]{2,}$');
CREATE TABLE a (email email);
CREATE TABLE b (email email);

Copy-pasted checks drift apart. A domain defines the rule once, and changing it changes every column.

PitfallsCommon mistakes

  • Expecting enum values to sort alphabetically; they sort in declaration order.
  • Adding an enum value in a migration transaction and using it in the same transaction.
  • Writing addr.city instead of (addr).city in a SELECT and getting a "missing FROM-clause entry" error.
  • Using a composite column where the fields need their own indexes, constraints or foreign keys; plain columns are easier to work with.

LimitsWhen not to use it

Skip enums when the set of values changes often, when values need extra attributes, or when other databases or tools must read the schema without PostgreSQL-specific types. Skip composite columns in tables for anything you query or index by field; they are best for function results and transient values.

SecuritySecurity notes

Domains are a cheap layer of input validation that no application path can bypass, which helps against bad data from new services, imports and manual fixes. Remember that CHECK only validates shape; it does not make an email address real or a value safe to display, so still encode output in the application.

ChallengeTry it yourself

Create a domain percent that only accepts numbers from 0 to 100 with one decimal place, use it in a table, insert 42.5 and read it back. Leave nothing behind.

Show one solution
One solution
BEGIN;
CREATE DOMAIN percent AS numeric(4,1) CHECK (VALUE BETWEEN 0 AND 100);
CREATE TABLE discount (code text PRIMARY KEY, pct percent NOT NULL);
INSERT INTO discount VALUES ('AUTUMN', 42.5);
SELECT code, pct FROM discount;
ROLLBACK;

InterviewQuestions you should be able to answer

Enum or lookup table for a status column?
An enum when the set is small, ordered and stable, like an order lifecycle: it is 4 bytes, sorts in lifecycle order and needs no join. A lookup table when values change often, need extra attributes such as display names or an active flag, or must be managed by non-engineers. Removing or reordering enum values requires recreating the type, which rewrites every table that uses it.
What is a domain and when would you use one?
A named base type with NOT NULL, DEFAULT and CHECK constraints. Use it for rules shared by many columns, such as non-negative money amounts, email shape or country codes, so the rule is defined once and enforced in every table, function argument and cast. Changing it with ALTER DOMAIN ADD CONSTRAINT NOT VALID and VALIDATE updates every user of the type.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation