Postgres enums are fundamentally just string types with a predefined list of values, but their performance characteristics can be surprisingly different from what you might expect.

Let’s see this in action. Imagine we have a table orders to track order statuses.

CREATE TABLE orders_enum (
    id SERIAL PRIMARY KEY,
    status VARCHAR(10) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');

ALTER TABLE orders_enum ALTER COLUMN status TYPE order_status USING status::order_status;

Now, if we try to insert an invalid status:

INSERT INTO orders_enum (status) VALUES ('returned');

Postgres will immediately reject this with ERROR: invalid input value for enum order_status: "returned". This is great for data integrity.

Compare this to a check constraint:

CREATE TABLE orders_check (
    id SERIAL PRIMARY KEY,
    status VARCHAR(10) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    CONSTRAINT valid_order_status CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled'))
);

Inserting an invalid status here also fails:

INSERT INTO orders_check (status) VALUES ('returned');

This results in ERROR: new row for relation "orders_check" violates check constraint "valid_order_status". Both achieve the goal of limiting allowed values.

So, what’s the difference? The mental model to build around this is how Postgres stores and validates these values. With enums, Postgres internally maps each allowed string value to an integer. When you insert 'pending', it’s stored as 0. 'processing' becomes 1, and so on. This integer representation is what’s actually written to disk. When you query status, Postgres converts the integer back to its string representation. This internal integer mapping means that lookups and comparisons on enum columns can be faster than on string columns, especially for large datasets, because comparing integers is generally more efficient than comparing strings.

The check constraint, on the other hand, stores the status as a plain VARCHAR. Every time a row is inserted or updated, Postgres has to evaluate the CHECK constraint expression. It takes the VARCHAR value and checks if it exists within the IN list of allowed strings. This string comparison happens on every write operation. For a small, static list of values, this overhead is negligible. But as the list grows, or if the constraint involves more complex logic, the performance cost can become noticeable.

Crucially, enums enforce type safety at a deeper level. When you declare a column as order_status, you’re telling Postgres that this column can only ever hold values from that specific enum type. If you try to cast a string that isn’t in the enum to that type, it will fail at that point. A CHECK constraint is a rule applied to a VARCHAR (or other scalar type) column. The column itself is still fundamentally a VARCHAR, and the constraint is just a guardrail. This difference matters for things like application-level ORMs or tools that inspect your schema; they see an order_status type versus a VARCHAR with a check.

The one thing that often surprises people is how Postgres handles changes to enum types. If you add a new value to an enum that’s already in use by a table, it’s a relatively fast operation. However, if you need to remove a value from an enum, or reorder the values, Postgres treats this as a more significant metadata change. It might invalidate query plans that relied on the old enum definition, requiring them to be re-optimized. This is because the underlying integer mapping changes, and Postgres needs to ensure all existing data is still correctly interpreted and that new operations adhere to the modified definition.

When you’re dealing with a fixed set of distinct states for a column where you want strict type enforcement and potentially better performance on comparisons, enums are often the way to go. If you need more flexibility, or if the set of allowed values might change frequently and unpredictably, a CHECK constraint on a VARCHAR column offers a more adaptable solution, albeit with a slight performance trade-off on writes.

The next step is to consider how these types interact with foreign keys and joins.

Want structured learning?

Take the full Postgres course →