Alright, let’s talk about making your database actually useful. You’ve got your standard-issue data types: INTEGER, TEXT, VARCHAR. They’re the raw lumber. Powerful, but if you build a house with just planks and nails, you’ll end up with doors that are seven feet tall and windows that let in the rain. This is where domain types come in. Think of them as custom molds or jigs. They let you take a base type and slap on constraints and a more meaningful name, ensuring the data that goes in actually makes sense for your specific problem.

Why bother? Because constraints are the ultimate “trust, but verify” policy for your data. Pushing validation rules down into the database schema means you can’t have a rogue application script or a sleepy developer insert nonsense like a negative price or an email address without an ‘@’. The database itself becomes the final, un-bypassable bouncer checking IDs at the door.

Creating and Using Your Own Domains

Creating a domain is like defining a new type alias with a built-in attitude problem. The syntax is straightforward, but the power is immense.

-- Let's define a domain for email addresses. It's basically text, but with standards.
CREATE DOMAIN email AS TEXT
    CHECK (
        VALUE IS NOT NULL
        AND VALUE ~* '^[A-Za-z0-9._+%-]+@[A-Za-z0-9.-]+[.][A-Za-z]+$'
    );

-- And one for a price. Because money can be a positive number, or free, but never negative.
CREATE DOMAIN positive_numeric AS NUMERIC
    CHECK (VALUE >= 0);

Now, you can use these just like any built-in type. This is so much cleaner than repeating that gnarly CHECK constraint on every table that needs an email column.

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    -- Look how clean this is. The intent is immediately obvious.
    email_address EMAIL NOT NULL,
    -- This column inherits the 'must be positive or zero' rule.
    account_credit POSITIVE_NUMERIC DEFAULT 0
);

-- This will work beautifully:
INSERT INTO users (username, email_address) VALUES ('cool_bean', 'bean@legit.org');

-- This will fail spectacularly, and rightly so:
INSERT INTO users (username, email_address) VALUES ('joker', 'not-an-email');
-- ERROR:  value for domain email violates check constraint "email_check"

See? The database just saved you from yourself. Again.

The Devil’s in the Defaults (and Nulls)

Here’s a fun bit of trivia that’s bitten me more than once: when you create a domain, it inherits the default nullability from its underlying base type. Since TEXT and NUMERIC are nullable by default, so are your new domains. If you want them to reject NULL values, you have to be explicit about it in the domain definition itself.

-- This domain allows NULLs because TEXT allows NULLs.
CREATE DOMAIN optional_phone AS TEXT
    CHECK (VALUE ~ '^[0-9]{3}-[0-9]{3}-[0-9]{4}$'); -- simple pattern

-- This domain is NOT NULL first, checked second.
CREATE DOMAIN required_phone AS TEXT
    NOT NULL
    CHECK (VALUE ~ '^[0-9]{3}-[0-9]{3}-[0-9]{4}$');

Always decide on the nullability you need upfront. It’s a pain to ALTER DOMAIN later to change this, precisely because it might break existing data. Speaking of…

Altering and Dropping: Tread Carefully

You can change a domain after the fact with ALTER DOMAIN. It’s useful, but it’s like doing engine repairs while the car is moving. You can add new constraints, which will be validated against all existing data in any table using the domain. If you have a single row that violates the new rule, the entire ALTER operation fails. This is a good thing! It prevents you from accidentally legalizing corrupt data.

-- Let's say we want to allow extensions in our phone number.
-- First, we'll add a constraint that allows NULL (our existing data) OR the new pattern.
ALTER DOMAIN required_phone
    DROP CONSTRAINT required_phone_check;

ALTER DOMAIN required_phone
    ADD CHECK (
        VALUE ~ '^[0-9]{3}-[0-9]{3}-[0-9]{4}$' -- old pattern
        OR VALUE ~ '^[0-9]{3}-[0-9]{3}-[0-9]{4}x[0-9]+$' -- new pattern with extension
    );

Dropping a domain (DROP DOMAIN) will fail if any column is still using it. You have to either change those columns first or use the nuclear option: DROP DOMAIN ... CASCADE, which will forcibly change all those columns back to the underlying base type. This is the database equivalent of “hold my beer,” so use it with extreme prejudice and a recent backup.

When to Use Them (And When to Just Use a Check Constraint)

Domains are fantastic for enforcing consistency across multiple tables. Email, phone, postal codes, currency—if more than one table needs it, a domain is your best friend. It’s the “Don’t Repeat Yourself” principle applied to your schema.

However, if a constraint is truly unique to a single column in a single table (e.g., CHECK (employee_salary < CEO_salary)), just use a standard CHECK constraint on that column. No need to pollute the global namespace with a one-off domain.

The biggest pitfall is getting too clever. Your domains should represent genuine, reusable concepts in your system, not just be a fancy way to write a CHECK constraint. If you find yourself creating a domain used in exactly one place, you’ve probably over-engineered it. The goal is clarity and consistency, not just cleverness. Now go build a schema that doesn’t suck.