PostgreSQL ENUM Types
Learn how to use PostgreSQL ENUM types: define them, use them in columns, compare with CHECK constraints, and avoid common pitfalls.
Focus: use enum types in postgresql
Ever had a database column that silently accepts 'Pending', 'pending', or 'PENDING' as three different statuses, and then a report quietly breaks? That's the pain PostgreSQL ENUM types solve: they force a column to hold exactly one value from a predefined set, catching invalid data at the database level instead of letting it fester in your app. In this hands-on lesson, you'll learn how to define ENUM types, use them in tables, and when to reach for a CHECK constraint instead — so you can build schemas that are both strict and flexible.
The problem this lesson solves
Stringly-typed columns like status TEXT are a silent productivity killer. Every developer who writes a WHERE status = 'active' clause must know the exact spelling, casing, and whitespace that the app uses. One typo — 'actve' — and your query returns nothing, or worse, inserts a bad status that your BI tool later chokes on.
Beyond spelling, plain text allows impossible states: an order with status = 'shipped' but delivered_at IS NULL, or a user with role = 'admin' and is_banned = TRUE. The database can't help you because the column accepts anything.
PostgreSQL ENUM (short for enumeration) types treat this problem at the schema level. You declare a finite set of allowed values, and the database rejects anything else — before your application logic even sees the data. No more string-literal typos, no more undocumented magic values, and a much happier API consumer.
Core concept / mental model
Think of an ENUM type like a drop-down list in a form, but enforced by the database itself. When you create an ENUM, PostgreSQL stores each value as a 4-byte numeric label internally, mapping it back to the text you define. The database knows the order of values you listed, which matters for sorting and for operations like MIN() and MAX().
Here's the mental model in words:
An ENUM type is a custom data type that defines a fixed, ordered set of string constants. A column of that type can only store one of those constants, and PostgreSQL compares them using the order you specified — not alphabetical order.
Key properties of an ENUM:
- Predefined values: You list them at creation time, e.g.,
'draft', 'published', 'archived'. - Strict validation: Any other string is rejected with an error.
- Type safety: You can't mix two different ENUM types in a comparison unless they're the same type.
- Order matters: Sorting uses the order you define, not alphabetical. So
'high', 'medium', 'low'will sort as listed, not alphabetically. - Storage efficient: Internally stored as 4-byte integers, which is often smaller than a
VARCHARand faster to index.
To create an ENUM, you use the CREATE TYPE command:
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered');
After this, order_status is a first-class data type in your database, just like INTEGER or TEXT. You can use it in table definitions, function parameters, and even in arrays.
How it works step by step
Let's walk through the lifecycle of an ENUM type in a typical application.
Step 1 — Create the ENUM type
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered');
This name (order_status) should be unique across the database schema, just like a table name. You can't have a table and an ENUM with the same name in the same schema.
Step 2 — Use it in a table
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_name TEXT NOT NULL,
status order_status NOT NULL DEFAULT 'pending'
);
Notice the DEFAULT 'pending' — this is a clever way to ensure new rows always start in a sensible state.
Step 3 — Insert and query data
INSERT INTO orders (customer_name, status) VALUES
('Alice', 'processing'),
('Bob', 'delivered');
SELECT * FROM orders WHERE status = 'shipped';
Try inserting an invalid value:
INSERT INTO orders (customer_name, status) VALUES ('Carol', 'cancelled');
You'll get an error like:
ERROR: invalid input value for enum order_status: "cancelled"
LINE 1: ... INTO orders (customer_name, status) VALUES ('Carol', 'cancelled');
This error is your friend — it stops bad data before it becomes a bug.
Step 4 — Sorting and comparison
Because ENUM has a defined order, you can sort directly:
SELECT status, count(*) FROM orders GROUP BY status ORDER BY status;
The output will follow your definition order (pending, processing, shipped, delivered), not alphabetical. To sort alphabetically, cast to text: ORDER BY status::TEXT.
Also, you can use comparison operators like < and >:
SELECT * FROM orders WHERE status > 'pending';
This returns all rows with a status that comes after 'pending' in your defined order — useful for "is this past the first stage?" logic.
Pro tip: Use
pg_enumto see the internal ordering:sql SELECT enumlabel, enumsortorder FROM pg_enum WHERE enumtypid = 'order_status'::regtype;
Hands-on walkthrough
Let's build a realistic example — a ticket system that tracks support requests. We'll create an ENUM for priority and another for status, then write some queries.
Step 1 — Create ENUMs
CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'critical');
CREATE TYPE ticket_status AS ENUM ('open', 'in_progress', 'resolved', 'closed');
Step 2 — Create the table
CREATE TABLE support_tickets (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
priority priority NOT NULL DEFAULT 'medium',
status ticket_status NOT NULL DEFAULT 'open',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Step 3 — Insert sample data
INSERT INTO support_tickets (title, priority, status) VALUES
('Cannot login', 'high', 'open'),
('Bug in report', 'low', 'in_progress'),
('Payment failed', 'critical', 'resolved'),
('UI glitch', 'medium', 'closed');
Step 4 — Query with ENUM filtering
-- All open or in_progress tickets, sorted by priority (not alphabetically!)
SELECT id, title, priority, status
FROM support_tickets
WHERE status IN ('open', 'in_progress')
ORDER BY priority;
Expected output:
id | title | priority | status
----+---------------------+----------+------------
2 | Bug in report | low | in_progress
1 | Cannot login | high | open
Note how low comes before high? That's because our ENUM order is low < medium < high < critical — not alphabetical.
Step 5 — Count by priority
SELECT priority, count(*) FROM support_tickets GROUP BY priority ORDER BY priority;
Output:
priority | count
-----------+-------
low | 1
medium | 1
high | 1
critical | 1
Step 6 — Update a status
UPDATE support_tickets SET status = 'resolved' WHERE id = 1;
Everything works because 'resolved' is in the ticket_status ENUM.
Pro tip: You can use ENUM values in array constructs too:
sql SELECT * FROM support_tickets WHERE status = ANY(ARRAY['open', 'resolved']::ticket_status[]);
Compare options / when to choose what
PostgreSQL offers several ways to enforce allowed values: ENUM types, CHECK constraints, and lookup tables (foreign keys to a reference table). Each has trade-offs.
| Criteria | ENUM | CHECK constraint | Lookup table (FK) |
|---|---|---|---|
| Setup | One-time CREATE TYPE, then use everywhere |
Add constraint to each column | Create table, insert rows, set FK |
| Adding a value | Requires ALTER TYPE ... ADD VALUE (no lock in PG 12+, but can be tricky in transactions) |
ALTER TABLE ... DROP CONSTRAINT + re-add |
Just INSERT into lookup table |
| Removing a value | ALTER TYPE ... DROP VALUE (PG 13+), but must handle existing data |
Same as adding, but need to clean data first | DELETE from lookup table (FK may block if rows reference it) |
| Reordering | Not possible after creation | Not applicable | Order via sort_order column |
| Cross-table consistency | Same ENUM type can be used in many tables | Must repeat constraint on every table | Natural via FK |
| Query readability | Clean: WHERE status = 'shipped' |
Clean | Requires join to get the label |
| Performance | Internal 4-byte storage, fast | No overhead for text, uses whatever type | Join overhead for label retrieval |
| Flexibility | Low — adding/removing values is a schema change | Moderate — can add/remove easily | High — dynamic values |
When to choose ENUM: - The set of values is stable (e.g., days of the week, order statuses that rarely change). - You want type safety across many tables and functions. - You need specific ordering that isn't alphabetical.
When to choose CHECK:
- The value set is likely to change often during development.
- You want to avoid the extra ALTER TYPE dance.
- You're using an ORM that might not handle ENUMs well.
When to choose a lookup table: - The list is dynamic and managed by users (e.g., project categories). - You need extra metadata per value (e.g., color, description, sort order). - You want to associate attributes with the value itself.
Troubleshooting & edge cases
Many developers trip over ENUMs because they seem simple but have subtle quirks. Here are the most common issues and how to fix them.
1. "Invalid input value for enum" on insert
Symptom: You get the error we saw earlier.
Cause: The value isn't in your ENUM, or there's a whitespace/case mismatch. ENUM matching is case-sensitive and ignores trailing spaces? Actually, it doesn't ignore — the string must exactly match.
Fix: Verify the exact values with:
SELECT enumlabel FROM pg_enum WHERE enumtypid = 'order_status'::regtype;
Also ensure your app sends the exact string, e.g., status='processing' not 'Processing'.
2. Adding a new value to an ENUM fails in transactions
Symptom: ALTER TYPE ... ADD VALUE 'urgent' hangs or errors.
Cause: In PostgreSQL, adding a value to an ENUM inside a transaction block is not allowed until PG 12? Actually, PG 12+ allows it but with restrictions — the new value cannot be used until the transaction commits. If you try to use it before commit, you get an error.
Fix: Avoid BEGIN, or commit immediately after adding. If you're using a migration tool, ensure it doesn't wrap the ALTER TYPE in a transaction with other statements that use the new value.
3. Can't drop a value that's in use
Symptom: ALTER TYPE ... DROP VALUE 'pending' fails if any table has that value.
Cause: PostgreSQL won't allow you to remove a value if it's stored in any column of that type.
Fix: First update or delete rows that use that value, then drop it. For example:
UPDATE orders SET status = 'cancelled' WHERE status = 'pending';
ALTER TYPE order_status DROP VALUE 'pending';
4. ORDER BY returns 'high' before 'medium'
Symptom: You expect alphabetical sorting but get the ENUM order.
Cause: ENUMs sort by their defined order, not alphabetically.
Fix: If you want alphabetical, cast to text: ORDER BY status::TEXT.
5. Comparing two different ENUM types
Symptom: WHERE priority > status (where priority and status are different ENUMs) fails with "operator does not exist."
Cause: PostgreSQL treats each ENUM type as distinct; you can't compare them directly.
Fix: Cast to text or define an ordering with a CASE statement.
6. Adding a value with ALTER TYPE ... ADD VALUE blocks writes briefly
Symptom: Application timeouts during migration.
Cause: The ADD VALUE command takes a brief lock, but in some versions it can block reads/writes.
Fix: In PG 12+, the default behavior avoids a full rewrite, but you should schedule changes during low traffic. Also, use ALTER TYPE ... ADD VALUE IF NOT EXISTS to avoid errors.
What you learned & what's next
You've just mastered how to use enum types in PostgreSQL — a powerful tool to keep your data clean and your queries bug-free. Specifically, you learned:
- What an ENUM type is — a custom data type that restricts a column to a fixed set of string values.
- How to create and use ENUMs — via
CREATE TYPE, in table definitions, with defaults, and in queries. - How ENUM ordering works — following your definition order, not alphabetical.
- How to compare ENUMs with CHECK constraints and lookup tables — each has pros and cons.
- How to troubleshoot common ENUM issues — invalid inputs, adding/dropping values, and transaction pitfalls.
Now that you're comfortable with ENUM types, the next lesson in this PostgreSQL Tutorial track will teach you how to create and use composite primary keys — another schema design tool that ensures data uniqueness across multiple columns. You'll build on your ENUM knowledge to design even more robust database models.
Remember: ENUMs are a great fit when your value set is stable and small; for anything that changes frequently, a lookup table might serve you better. Choose wisely, and your future self will thank you.
Practice recap
Try this quick exercise: create an ENUM for payment_status with values 'pending', 'completed', 'failed' and a payments table that uses it. Insert a few rows, then attempt to insert 'done' to see the error. Next, add a new value 'refunded' using ALTER TYPE, and verify that you can now insert that value. Finally, write a query that counts payments by status ordered by your ENUM definition.
Common mistakes
- Using ENUM for values that change frequently (e.g., user roles), leading to constant
ALTER TYPEmigrations instead of a simple lookup table. - Forgetting that ENUM matching is case-sensitive —
'Active'is not'active', and the database will reject it. - Assuming ENUM sorts alphabetically — it sorts by the order you defined values, so
ORDER BYmay surprise you. Usestatus::TEXTif you need alphabetical. - Trying to add a new ENUM value inside a transaction block and using it before commit — in PostgreSQL 12+, the new value isn't available until the transaction commits.
- Comparing two different ENUM types directly (e.g.,
priority > status), which fails because each ENUM is a distinct data type.
Variations
- Use a
CHECKconstraint withIN ('a','b','c')when you need a quick, single-column restriction without creating a custom type. - Use a lookup table (e.g.,
status_codeswith anidandlabel) when the value set is dynamic or needs extra attributes like sort order or color. - Use
CREATE DOMAINover a TEXT type with a CHECK constraint if you want a 'type' but prefer to keep ALTER operations simpler than with ENUMs.
Real-world use cases
- Order management: enforce order statuses (pending, paid, shipped, delivered) so dashboards and reports never encounter invalid states.
- Support ticketing systems: store ticket status and priority as ENUMs to guarantee consistent filtering and sorting by criticality.
- User management: define fixed roles (admin, editor, viewer) to prevent accidental role misspellings and simplify authorization logic.
Key takeaways
- ENUM types enforce a fixed set of values at the database level, preventing invalid data from entering your tables.
- ENUM values are stored efficiently as 4-byte integers, improving performance and storage over plain text columns.
- ENUM ordering follows the order you define at creation time, not alphabetical — use
::TEXTto sort alphabetically. - Adding a new ENUM value requires
ALTER TYPE ... ADD VALUE, which can be tricky in transactions; plan migrations carefully. - Consider CHECK constraints or lookup tables when the value set is volatile or needs additional metadata.
- Always validate the exact ENUM labels using
pg_enumto avoid case-sensitivity and whitespace errors.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.