Contents
Reach for a Postgres ENUM and it feels like the responsible choice. You’ve got a
column that can only be pending, sent, or failed, so you encode that at the
database level, get a compact type, readable \d output, and a hard guarantee that no
garbage value ever lands in the column. Textbook. I’ve shipped plenty of them.
Then I spent about fifteen months embedded in a multi-tenant AI voice and texting
platform for home-services contractors — an AI receptionist that answers calls, books
jobs into the CRMs those contractors already run on, and drives outbound campaigns. The
codebase moved fast: on the order of six thousand PRs in eleven months, a migration
runner with per-environment concurrency locks, and enough accumulated schema churn that
the team eventually re-baselined the whole migration history from a production schema
dump. In that environment, native enums quietly became a tax. So on new columns we
stopped using them — plain text, with the value set validated in the application
layer. Here’s the reasoning, because the tradeoff is real and it cuts both ways.
The problem isn’t enums. It’s ALTER TYPE.
An enum is lovely right up until the set of values changes. And in a live product, the
set of values always changes. A campaign status gains a paused. A sync state gains a
rate_limited. A message channel picks up a new provider. Every one of those is an
ALTER TYPE, and ALTER TYPE is where the sharp edges live.
Adding a value looks harmless:
ALTER TYPE campaign_status ADD VALUE 'paused';
The gotcha: the newly added value cannot be used in the same transaction that adds it. So the very natural migration — add the value, then backfill or insert rows that use it — blows up:
BEGIN;
ALTER TYPE campaign_status ADD VALUE 'paused';
-- ERROR: unsafe use of new value "paused" of enum type campaign_status
UPDATE campaigns SET status = 'paused' WHERE ...;
COMMIT;
You now have to split that into two migrations that must run in order, un-batched. If your migration tooling wraps each migration in a transaction by default — most do — this fights the tool directly. At low velocity you shrug and split the file. At high velocity, with a runner serializing migrations behind concurrency locks, every one of these becomes a scheduling problem and a chance to leave the schema half-applied if step one lands and step two fails.
Adding is the easy case. Now try to remove a value:
ALTER TYPE campaign_status DROP VALUE 'legacy_mode';
-- ERROR: syntax error
There is no DROP VALUE. It does not exist. To retire an enum value you recreate the
whole type: make a new type, cast the column across with a USING clause, drop the old
type, rename the new one back. That’s an ACCESS EXCLUSIVE lock on the table while the
column is rewritten and re-validated — a genuine locking migration on a table that, in a
multi-tenant system, every tenant is reading and writing right now. Renaming a value
(ALTER TYPE ... RENAME VALUE, Postgres 10+) is less brutal but still a type-wide change
you have to sequence carefully.
So the honest scorecard for a native enum whose values churn: adding is awkward, renaming is careful, removing is a rewrite, and none of it composes cleanly with a transactional migration runner. You’re paying migration friction to buy a database-level integrity check. When schema velocity is your bottleneck, that’s a bad trade.
What we did instead
Plain text column. The value set lives in TypeScript as the single source of truth, and
we validate at the write boundary.
-- migration: no enum, no CHECK
alter table campaigns
add column status text not null default 'pending';
// The set of values lives in code, not the schema.
export const CAMPAIGN_STATUSES = [
'pending',
'running',
'paused',
'completed',
'failed',
] as const;
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
const CAMPAIGN_STATUS_SET = new Set<string>(CAMPAIGN_STATUSES);
export function assertCampaignStatus(value: string): CampaignStatus {
if (!CAMPAIGN_STATUS_SET.has(value)) {
throw new Error(`Invalid campaign status: ${value}`);
}
return value as CampaignStatus;
}
Every write funnels through the boundary, so nothing reaches the column unchecked:
export async function setCampaignStatus(
campaignId: string,
next: string,
): Promise<void> {
const status = assertCampaignStatus(next); // throws before it hits Postgres
await db
.from('campaigns')
.update({ status })
.eq('id', campaignId);
}
Because our database types are generated from the live schema and committed to the repo,
the column reads back as string — and the CampaignStatus union is what everything
downstream actually consumes. The union is the contract. Postgres just stores bytes.
I’ll be blunt about what you give up: the database will now happily accept
'runnning' with three n’s if a raw SQL path skips the boundary. That guarantee is gone.
You are trading a hard DB-level constraint for application-layer discipline, and that only
works if the discipline is real — one canonical value list, one validator, every write
routed through it, and the convention written down where reviewers (and, in our case, the
AI coding agents the team leaned on) will actually enforce it. We codified exactly this in
the database package’s conventions doc: no enums or CHECK constraints for enumerated
values on new columns, plain text validated in app code, because ALTER TYPE can’t
reliably run in a transaction at our migration velocity. A rule with its reason attached
survives contact with 120 contributors; a rule without one gets “fixed” by the next person
who thinks they know better.
The payoff is velocity
Here’s the part that made it worth it. Adding a new value stopped being a migration at
all. When we shipped category-scoped SMS opt-out — splitting sends into Customer Care
versus Marketing so a customer could STOP marketing texts without losing appointment
reminders — the “category” was exactly the kind of enumerated set that grows. With a
native enum, each new category is an ALTER TYPE, a migration, a slot in the runner
queue. As plain text, a new category is a one-line edit to a union and a code review. It
ships on the normal deploy train, no schema lock, no migration coordination, no waiting
behind another tenant’s long-running migration. That is a real difference in how fast you
can move, and at that PR volume it compounds every single week.
When I’d still reach for an enum
This isn’t a crusade. Native enums are the right call when:
- The set is genuinely closed and stable. A
weekdaytype is never gaining an eighth value. Encode it and enjoy the free guarantee. - Migration velocity is low. A single-tenant app deploying weekly doesn’t feel
ALTER TYPEfriction. The DB-level integrity is pure upside. - You need ordering. Enums sort in definition order, which is occasionally exactly the property you want and annoying to reproduce with text.
- Untrusted write paths exist. If code you don’t control can
INSERTinto the table, the database is the only boundary that actually holds, and app-layer validation is a lie you tell yourself.
The decision rule I walked away with: how often will this set of values change, and how expensive is a locking migration on this table right now? High churn plus a hot multi-tenant table pushes hard toward app-layer validation. Low churn on a quiet table pushes toward the native enum. It was never “enums are bad” — it’s that at real migration velocity, a schema-design choice is a shipping-speed choice, and I’d rather spend my constraint budget where it buys me something other than migration pain.
