That VARCHAR status column is an enum in denial
Every schema has a status VARCHAR(30). It starts innocent: three
possible values, everyone knows what they are. A year later it holds
'PAID', 'paid', 'Pago' and a
'PAIDD' nobody can explain.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(30) NOT NULL
);
A short text column with a state-like name (status,
type, role...) is almost always an enum in disguise.
The database accepts any string, so integrity comes down to the team's
discipline. Discipline doesn't scale.
Option 1: CHECK constraint
ALTER TABLE orders
ADD CONSTRAINT orders_status_check
CHECK (status IN ('PENDING', 'PAID', 'CANCELLED'));
Cheap, readable, works on the column you already have. Evolving the set is a swap, and it doesn't rewrite the table:
ALTER TABLE orders DROP CONSTRAINT orders_status_check;
ALTER TABLE orders ADD CONSTRAINT orders_status_check
CHECK (status IN ('PENDING', 'PAID', 'CANCELLED', 'REFUNDED'));
Postgres does validate existing rows when the constraint is added (a full
scan, brief lock); on a huge table, add it NOT VALID and run
VALIDATE CONSTRAINT separately, which takes a weaker lock.
Option 2: native ENUM type
CREATE TYPE order_status AS ENUM ('PENDING', 'PAID', 'CANCELLED');
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
status order_status NOT NULL
);
More compact on disk (4 bytes vs the string) and self-documenting in
\dT. The pain is evolution: ALTER TYPE order_status ADD
VALUE 'REFUNDED' exists but appends only, you can't remove or rename a
value without creating a new type, casting every column over and dropping the
old one. Values also carry an internal sort order fixed at creation, so
ORDER BY status sorts by declaration order, not alphabetically,
which is occasionally what you want and usually a surprise.
Option 3: lookup table
CREATE TABLE order_status (
code VARCHAR(30) PRIMARY KEY,
label VARCHAR(80) NOT NULL,
sort_order INT NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(30) NOT NULL REFERENCES order_status (code)
);
The heavyweight option, for when the values are data: they change at runtime, carry attributes (display label, ordering, an "active" flag), or are edited by an admin screen rather than a migration. The cost is a join in every listing and one more table in every environment's seed. Don't pay it for three static states.
Choosing by churn
- Values change a few times a year, via migration: CHECK. The default for most CRUD apps.
- Values genuinely stable for the product's lifetime (card brands, weekdays): native ENUM, the evolution pain never gets invoked.
- Values managed at runtime or carrying attributes: lookup table.
On the Java side
public enum OrderStatus { PENDING, PAID, CANCELLED }
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private OrderStatus status;
EnumType.STRING always. The JPA default is
ORDINAL, which stores the enum's position: add
REFUNDED anywhere but the end of the Java enum and every
historical row silently means something else. There is no error, no warning,
just wrong data. STRING costs a few bytes per row and removes the failure mode
entirely; with the CHECK constraint on the same values, the database and the
class enforce the same contract from both sides.
In TypeScript the equivalent is a union
(type OrderStatus = 'PENDING' | 'PAID' | 'CANCELLED'), in Pydantic
a str, Enum subclass; either way the source of truth is the value
set in the DDL, which is why it's worth pinning down there first.