👋
Welcome to my blog!

PostgreSQL Deep Dive Part 4 — Mastering Enums and Domains

Dive into PostgreSQL's enums and domains. Learn how to implement domain-driven design principles directly in your database architecture.

PostgreSQL Deep Dive Part 4 — Mastering Enums and Domains
Database
Postgres
Development

Published At

12/26/2024

Reading Time

~ 6 min read

📚 Raw Postgres Study Notes

Access complete, unfiltered study notes maintained on GitHub for easily maintaing Second Brain Dir

In our journey through PostgreSQL's type system, we've explored the built-in types that form the foundation of database design. Now, let's venture into territory that truly sets PostgreSQL apart: its ability to extend the type system through domains, and enums. This capability isn't just a feature—it's a gateway to implementing true domain-driven design at the database level.

The Power of ENUM Types: Beyond Simple Constants 🔄

Let's start with a real-world scenario. Imagine you're building an e-commerce platform where orders flow through various states. You could use strings or integers to represent these states, but ENUMs provide a more robust solution.

sql
-- First, let's define our order states
CREATE TYPE order_status AS ENUM (
    'pending',
    'processing',
    'shipped',
    'delivered',
    'cancelled',
    'returned'
);
 
-- Create our orders table using the enum
CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
 
INSERT INTO orders (status) VALUES
	('pending'),
	('processing');
-- Throws error if status values does not match with ENUM values
sql
-- First, let's define our order states
CREATE TYPE order_status AS ENUM (
    'pending',
    'processing',
    'shipped',
    'delivered',
    'cancelled',
    'returned'
);
 
-- Create our orders table using the enum
CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
 
INSERT INTO orders (status) VALUES
	('pending'),
	('processing');
-- Throws error if status values does not match with ENUM values

The Hidden Power of ENUM Ordering

One often-overlooked feature of ENUMs is their natural ordering based on the declaration sequence:

sql
-- Ordering of table will happen based on enum position
 
INSERT INTO orders (status) VALUES
('pending'),
('processing'),
('shipped'),
('delivered'),
('cancelled'),
('returned'),
('pending'),
('processing'),
('shipped'),
('cancelled');
 
SELECT
    order_id,
    status,
FROM orders
ORDER BY status;
 
--| order_id | status     |
--|----------|------------|
--|        9 | pending    |
--|        1 | pending    |
--|        3 | pending    |
--|       10 | processing |
--|        4 | processing |
--|        2 | processing |
--|       11 | shipped    |
--|        5 | shipped    |
--|        6 | delivered  |
--|       12 | cancelled  |
--|        7 | cancelled  |
--|        8 | returned   |
 
-- Here ordering is not alphabetical order but on the order of ENUM defined
sql
-- Ordering of table will happen based on enum position
 
INSERT INTO orders (status) VALUES
('pending'),
('processing'),
('shipped'),
('delivered'),
('cancelled'),
('returned'),
('pending'),
('processing'),
('shipped'),
('cancelled');
 
SELECT
    order_id,
    status,
FROM orders
ORDER BY status;
 
--| order_id | status     |
--|----------|------------|
--|        9 | pending    |
--|        1 | pending    |
--|        3 | pending    |
--|       10 | processing |
--|        4 | processing |
--|        2 | processing |
--|       11 | shipped    |
--|        5 | shipped    |
--|        6 | delivered  |
--|       12 | cancelled  |
--|        7 | cancelled  |
--|        8 | returned   |
 
-- Here ordering is not alphabetical order but on the order of ENUM defined

Modifying ENUM Types

Modifying ENUMs requires special consideration:

sql
-- Add a new value at the end of enum list
ALTER TYPE order_status ADD VALUE 'payment_pending';
 
-- Add values at specific positions in ENUM
ALTER TYPE order_status ADD VALUE 'payment_done' BEFORE 'processing';
ALTER TYPE order_status ADD VALUE 'processing_done' AFTER 'processing';
 
-- View all enum values
SELECT * FROM pg_catalog.pg_enum;
 
-- Display enum ranges
SELECT enum_range(null::mood);                    -- Show all enum values
SELECT enum_range(null::mood, 'sad'::mood);       -- Show values from start to 'sad'
SELECT enum_range('afraid'::mood, 'sad'::mood);   -- Show values from 'afraid' to 'sad'
sql
-- Add a new value at the end of enum list
ALTER TYPE order_status ADD VALUE 'payment_pending';
 
-- Add values at specific positions in ENUM
ALTER TYPE order_status ADD VALUE 'payment_done' BEFORE 'processing';
ALTER TYPE order_status ADD VALUE 'processing_done' AFTER 'processing';
 
-- View all enum values
SELECT * FROM pg_catalog.pg_enum;
 
-- Display enum ranges
SELECT enum_range(null::mood);                    -- Show all enum values
SELECT enum_range(null::mood, 'sad'::mood);       -- Show values from start to 'sad'
SELECT enum_range('afraid'::mood, 'sad'::mood);   -- Show values from 'afraid' to 'sad'

Domain Types: Adding Business Rules to Data Types 📋

Domains allow us to create custom types with built-in constraints, perfect for enforcing business rules at the data level.

Building a Robust Email Domain

sql
-- Create a comprehensive email domain
CREATE DOMAIN email_address AS TEXT
CHECK (
    VALUE ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
    AND length(VALUE) <= 254
);
 
-- Create a US phone number domain
CREATE DOMAIN us_phone AS TEXT
CHECK (
    VALUE ~ '^\+1[0-9]{10}$'
    OR VALUE ~ '^[0-9]{10}$'
);
 
CREATE DOMAIN us_postal_code AS TEXT
CONSTRAINT format CHECK (
    VALUE ~ '^\d{5}$' OR VALUE ~ '^\d{5}-\d{4}$'
);
 
-- Practical application
CREATE TABLE contacts (
    id SERIAL PRIMARY KEY,
    email email_address NOT NULL,
    phone us_phone,
    postal us_postal_code NOT NULL,
    verified BOOLEAN DEFAULT FALSE
);
sql
-- Create a comprehensive email domain
CREATE DOMAIN email_address AS TEXT
CHECK (
    VALUE ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
    AND length(VALUE) <= 254
);
 
-- Create a US phone number domain
CREATE DOMAIN us_phone AS TEXT
CHECK (
    VALUE ~ '^\+1[0-9]{10}$'
    OR VALUE ~ '^[0-9]{10}$'
);
 
CREATE DOMAIN us_postal_code AS TEXT
CONSTRAINT format CHECK (
    VALUE ~ '^\d{5}$' OR VALUE ~ '^\d{5}-\d{4}$'
);
 
-- Practical application
CREATE TABLE contacts (
    id SERIAL PRIMARY KEY,
    email email_address NOT NULL,
    phone us_phone,
    postal us_postal_code NOT NULL,
    verified BOOLEAN DEFAULT FALSE
);

Domain Constraints in Action

sql
-- Testing domain constraints with examples
INSERT INTO contacts (email, phone, postal) VALUES
    ('valid@email.com', '+11234567890', '12345'),    -- Works
    ('invalid@', '+11234567890', '12345'),           -- Fails email check
    ('valid@email.com', '123-456-7890', '12345'),    -- Fails phone check
    ('valid@email.com', '+11234567890', '123456');   -- Fails postal check
 
-- Creating a domain for monetary amounts with business rules
CREATE DOMAIN positive_amount AS NUMERIC
CHECK (
    VALUE > 0                    -- Must be positive
    AND VALUE < 1000000         -- Less than 1 million
    AND scale(VALUE) <= 2       -- Max 2 decimal places
);
 
-- Using the domain in a financial transactions table
CREATE TABLE transactions (
    id SERIAL PRIMARY KEY,
    amount positive_amount,      -- Uses our custom domain
    description TEXT
);
sql
-- Testing domain constraints with examples
INSERT INTO contacts (email, phone, postal) VALUES
    ('valid@email.com', '+11234567890', '12345'),    -- Works
    ('invalid@', '+11234567890', '12345'),           -- Fails email check
    ('valid@email.com', '123-456-7890', '12345'),    -- Fails phone check
    ('valid@email.com', '+11234567890', '123456');   -- Fails postal check
 
-- Creating a domain for monetary amounts with business rules
CREATE DOMAIN positive_amount AS NUMERIC
CHECK (
    VALUE > 0                    -- Must be positive
    AND VALUE < 1000000         -- Less than 1 million
    AND scale(VALUE) <= 2       -- Max 2 decimal places
);
 
-- Using the domain in a financial transactions table
CREATE TABLE transactions (
    id SERIAL PRIMARY KEY,
    amount positive_amount,      -- Uses our custom domain
    description TEXT
);

This deep dive into PostgreSQL's ENUM and Domain illustrates how the database can become an active participant in enforcing your domain model, not just a passive data store.

Do you have any questions, or simply wish to contact me privately? Don't hesitate to shoot me a DM on Twitter.

Have a wonderful day.
Abhishek 🙏

Join My Exclusive Newsletter Community

Step into a world where creativity intersects with technology. By subscribing, you'll get a front-row seat to my latest musings, full-stack development resources, and exclusive previews of future posts. Each email is a crafted experience that includes:

  • In-depth looks at my covert projects and musings to ignite your imagination.
  • Handpicked frontend development resources and current explorations, aimed at expanding your developer toolkit.
  • A monthly infusion of inspiration with my personal selection of quotes, books, and music.

Embrace the confluence of words and wonder, curated thoughtfully and sent straight to your inbox.

No fluff. Just the highest caliber of ideas.