open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 06 · 04

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.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

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 array

The 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 index

When 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 + delivered

That 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 fine

But 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 a text column — 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 ordinary INSERT/UPDATE/DELETE and 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.

Order the steps

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. 1 Create a lookup table: statuses(code text PRIMARY KEY, label text, sort_order int)
  2. 2 Seed it with the current enum values plus the new metadata columns
  3. 3 Add a status_code text column to orders with a FK to statuses(code)
  4. 4 Backfill status_code from the existing enum column
  5. 5 Switch reads/writes to status_code, then drop the old enum column and type
Quiz

Why is a tag_id int[] array a poor way to model a product-to-tag many-to-many relationship?

Quiz

A status set keeps changing — values get added, reordered, and occasionally removed, and product wants a display label per value. Best model?

Recall before you leave
  1. 01
    What are the core array operators, and what's the one thing arrays can't do that decides against them for relationships?
  2. 02
    What's easy and what's painful about altering an enum, and what does that imply for modelling?
  3. 03
    Give the heuristic for enum vs CHECK vs lookup table.
Recap

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.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.