paste PostgreSQL or JSON, get your schema reviewed like a senior would

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

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.

Schemint flags columns that look like enums when you paste your CREATE TABLE, and explains what to weigh before converting.

Related