Why We Stopped Putting Enums in Postgres

Native Postgres enums look free until ALTER TYPE hits a high-churn multi-tenant schema. Why we moved value-set validation into the app layer.

Why We Stopped Putting Enums in Postgres
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 weekday type 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 TYPE friction. 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 INSERT into 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.