Arrays and enums: when each beats a string
Native arrays (ANY, unnest, @> with GIN) and enums (ordered, compact) are powerful at the right boundary — but the enum ALTER pain and array's join-hostility decide when a lookup table wins.
A team defined status as an enum with three values, shipped it, and six months later product asked for a fourth value — between two existing ones. Adding to the end was a one-liner; inserting in the middle, and later removing a value entirely, turned into a multi-hour migration that rewrote a dependent view and a function. Meanwhile a different team used a text[] array for “roles” and discovered they couldn’t put a foreign key on the role ids. Both types are excellent — at the boundary where their tradeoffs are features, not surprises. This lesson is about finding that boundary.
Native arrays: a real first-class type
Postgres arrays aren’t a hack — any type can be an array (int[], text[], even jsonb[]), with literal syntax and a rich operator set. They shine for a small, ordered, single-table list that you never join to: a row’s tags, a set of flags, a path.
CREATE TABLE products (
id bigint PRIMARY KEY,
tags text[] NOT NULL DEFAULT '{}' -- array literal: empty array
);
INSERT INTO products (id, tags) VALUES (1, '{sale,featured,new}');
SELECT * FROM products WHERE 'sale' = ANY(tags); -- membership test
SELECT * FROM products WHERE tags @> '{sale,new}'; -- contains ALL of these
SELECT id, unnest(tags) AS tag FROM products; -- explode to rows
SELECT array_agg(id) FROM products WHERE 'sale' = ANY(tags); -- collapse to arrayThe core operators: ANY(arr) / ALL(arr) for “matches some / every element”, @> containment (“contains all of”), unnest() to explode an array into rows, and array_agg() to collapse rows back into an array. And arrays are indexable — a GIN index on a text[] makes @> and = ANY containment queries fast, exactly like JSONB.
CREATE INDEX ON products USING gin (tags); -- now @> and = ANY use the indexWhen you find yourself storing a list of related ids in an array, pause here — the catch that decides everything: you can’t put a foreign key on an array element. A tag_id int[] can hold ids that don’t exist in any tags table, and Postgres won’t stop it. Arrays trade referential integrity and join performance for locality and simplicity. Great for a self-contained list; wrong for a relationship.
Enums: ordered and compact
An enum is a custom type with a fixed, ordered set of label values. It’s compact (stored as a 4-byte oid, not the full text) and the order is meaningful — comparisons follow declaration order, which is perfect for things like severity or workflow stages.
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'delivered');
CREATE TABLE orders (
id bigint PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending'
);
-- ordering follows declaration order, not alphabetical:
SELECT * FROM orders WHERE status >= 'shipped'; -- shipped + deliveredThat ordering is the enum’s superpower over a CHECK (status IN (...)) on a text column — you can’t naturally order arbitrary strings by workflow stage, but enum comparisons just work.
The enum ALTER pain
Here’s the tradeoff that bites in production. ADD VALUE to an enum is easy and online in modern Postgres:
ALTER TYPE order_status ADD VALUE 'returned'; -- appended, fine
ALTER TYPE order_status ADD VALUE 'cancelled' BEFORE 'paid'; -- positioned, also fineBut you cannot remove or rename-away a value without effectively rebuilding the type: you’d create a new enum, alter every column that uses it, drop the old type, and recreate any dependent views and functions — a real migration, not a one-liner. Enums are a one-way ratchet: easy to add to, painful to shrink. If your value set is genuinely fixed for the life of the system (severity levels, blood types), the enum’s compactness and ordering are pure win. If it churns — categories the business keeps reorganizing — the rigidity becomes a recurring tax.
Enum vs CHECK vs lookup table
Three ways to model “a column from a small set”, on a spectrum from rigid-and-fast to flexible-and-relational:
- Enum — value set is fixed and ordering matters. Compact, fast, self-documenting; ALTER pain on removal.
- CHECK (
status IN ('a','b')) on atextcolumn — small set, no ordering need, easy to edit (just alter the constraint). No order, slightly larger storage, no metadata. - Lookup table (
statuses(code text PK, label text, sort_order int, is_active bool)) with a foreign key — the relational answer when the set evolves, needs metadata, or must be foreign-keyed. You pay a join, but you can add/remove/relabel values as ordinaryINSERT/UPDATE/DELETEand attach as many attributes as you like.
The senior heuristic: fixed + ordered → enum; small + flexible → CHECK; evolving or needs metadata → lookup table. Defaulting to a lookup table is rarely wrong; defaulting to an enum on a churning value set is a future migration you scheduled for yourself.
▸Common mistake
The classic over-use of arrays: storing tag_ids int[] to model product-to-tag. It feels tidy, but you can’t foreign-key the ids (orphans creep in), you can’t efficiently answer “which products have this tag” without a GIN index and even then it’s clumsier than a join, and adding one tag rewrites the whole array (and the row). The relational answer — a product_tags(product_id, tag_id) join table — gives you integrity, two-way joins, and cheap single-row inserts. Reserve arrays for lists you never relate to other tables.
A small value set is evolving and now needs a display label and a sort order. Order the steps to move from an enum to a lookup table:
- 1 Create a lookup table: statuses(code text PRIMARY KEY, label text, sort_order int)
- 2 Seed it with the current enum values plus the new metadata columns
- 3 Add a status_code text column to orders with a FK to statuses(code)
- 4 Backfill status_code from the existing enum column
- 5 Switch reads/writes to status_code, then drop the old enum column and type
Why is a tag_id int[] array a poor way to model a product-to-tag many-to-many relationship?
A status set keeps changing — values get added, reordered, and occasionally removed, and product wants a display label per value. Best model?
- 01What are the core array operators, and what's the one thing arrays can't do that decides against them for relationships?
- 02What's easy and what's painful about altering an enum, and what does that imply for modelling?
- 03Give the heuristic for enum vs CHECK vs lookup table.
Postgres arrays are a real first-class type — any element type, with = ANY/ALL membership, @> containment, unnest, array_agg, and GIN indexing — ideal for a small, self-contained, per-row list you never join to. Their hard limit is that an array element can’t carry a foreign key, so an id array silently permits orphans and is the wrong tool for a relationship; a many-to-many is always a join table. Enums are compact (a 4-byte oid) and ordered (comparisons follow declaration order, beating a text CHECK when stage ordering matters), but they’re a one-way ratchet: ADD VALUE is easy and online while removing a value is a painful type rebuild that drags dependent views and functions along. So model a small value set deliberately — fixed and ordered favours an enum, small and flexible favours a CHECK, and an evolving set or one needing metadata favours a lookup table with a foreign key, where add/remove/relabel are ordinary DML. Now when you see a tag_ids int[] column or an enum for a value set that product keeps renegotiating — you’ll know which tool is carrying hidden cost, and what to reach for instead.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.