You add an enum to an entity, slap @Enumerated on it, and move on. Five seconds. It is the kind of decision nobody writes a design doc for.
Then six months later a row comes back as SHIPPED when it was PAID, no exception was thrown, no query failed, and you spend an afternoon learning that the default you never thought about has been silently rewriting history.
Here is the order lifecycle we will use the whole way through:
public enum OrderStatus {
PENDING,
PAID,
SHIPPED,
DELIVERED
}
Five ways to store it. They are not equivalent, and the gap between them only shows up under change.
@Enumerated(ORDINAL): store the position
This is the default. Leave the annotation bare and JPA stores the enum's ordinal, its index in the declaration order.
@Enumerated(EnumType.ORDINAL)
private OrderStatus status;
PENDING is 0, PAID is 1, SHIPPED is 2, DELIVERED is 3. The column is a tidy little smallint. Everything works.
Until someone needs a new status and adds it where it reads well:
public enum OrderStatus {
PENDING,
PAID,
CANCELLED, // inserted here
SHIPPED,
DELIVERED
}
CANCELLED is now 2. SHIPPED is 3. DELIVERED is 4. Every row written before this change still holds the old integer, so every order that was SHIPPED (2) now reads back as CANCELLED. The database is correct. Your data is wrong. And nothing told you.
If you are stuck with ORDINAL on a legacy schema, pin it with a test that fails the build the moment someone reorders:
@Test
void ordinalsAreFrozen() {
assertEquals(0, OrderStatus.PENDING.ordinal());
assertEquals(1, OrderStatus.PAID.ordinal());
assertEquals(2, OrderStatus.SHIPPED.ordinal());
assertEquals(3, OrderStatus.DELIVERED.ordinal());
}
New constants may only be appended. The test turns an invisible runtime corruption into a loud compile-time-ish failure. It is a guardrail, not a fix.
@Enumerated(STRING): store the name
Store the constant name instead of its position.
@Enumerated(EnumType.STRING)
private OrderStatus status;
Now the column holds 'PAID'. Reordering the enum is free, because the name does not move when the position does. The column is readable in a raw SELECT, exports document themselves, and a human debugging production can actually tell what a row means.
The cost moves to renames. Rename the constant:
PAID -> SETTLED
and every row still says 'PAID', which no longer matches any constant. Reads blow up or silently drop, depending on your mapping. A rename now requires a data migration:
UPDATE orders SET status = 'SETTLED' WHERE status = 'PAID';
For most schemas this is the right default. Renames are rarer than reorders, and when they happen they are at least visible.
A real Postgres enum type
The two options above are enforced entirely in the JPA layer. The database sees a varchar and will happily accept 'BANANA'. If you want the database itself to guarantee validity, give it a real type:
CREATE TYPE order_status AS ENUM ('PENDING', 'PAID', 'SHIPPED', 'DELIVERED');
CREATE TABLE orders (
id bigserial PRIMARY KEY,
status order_status NOT NULL
);
Now an invalid status is rejected at insert time, by the database, no matter which application or migration script tries to write it. Pair it with @Enumerated(STRING) on the Java side so the names line up.
One JDBC detail bites everyone the first time: the Postgres driver sends strings as varchar, and Postgres will not implicitly cast varchar to the enum type. Add stringtype=unspecified to the connection URL and the cast resolves:
jdbc:postgresql://localhost:5432/app?stringtype=unspecified
The limit: Postgres only lets you append values to an enum type. ALTER TYPE ... ADD VALUE works, but reordering or dropping a value means recreating the type and rewriting every column that uses it. You traded application-side flexibility for database-side guarantees.
AttributeConverter: decouple the name from the stored value
Both STRING and native enums tie the stored value to the Java identifier. Rename the constant, migrate the data. If you want renames to be free, stop storing the name at all. Store a stable code instead.
public enum OrderStatus {
PENDING("PND"),
PAID("PAID"),
SHIPPED("SHP"),
DELIVERED("DLV");
private final String code;
OrderStatus(String code) { this.code = code; }
public String getCode() { return code; }
public static OrderStatus fromCode(String code) {
for (OrderStatus s : values()) {
if (s.code.equals(code)) return s;
}
throw new IllegalArgumentException("Unknown code: " + code);
}
}
@Converter(autoApply = true)
public class OrderStatusConverter
implements AttributeConverter<OrderStatus, String> {
@Override
public String convertToDatabaseColumn(OrderStatus status) {
return status == null ? null : status.getCode();
}
@Override
public OrderStatus convertToEntityAttribute(String code) {
return code == null ? null : OrderStatus.fromCode(code);
}
}
The column stores 'PND'. The Java identifier is now free to change:
PENDING -> AWAITING_PAYMENT
The code stays "PND", the database does not move, the rename is a pure refactor your IDE does in one shortcut. The price is one extra class per enum and a layer of indirection: the code in the column no longer reads like the constant, so a raw SELECT shows 'PND' instead of 'PENDING'. You bought rename-freedom with a little readability.
The lookup table
The last option stops treating status as an enum at all. It becomes a foreign key to a reference table.
CREATE TABLE order_status (
id int PRIMARY KEY,
code varchar(32) UNIQUE NOT NULL,
label varchar(64) NOT NULL,
sort_order int NOT NULL,
is_active boolean NOT NULL DEFAULT true
);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
status_id int NOT NULL REFERENCES order_status(id)
);
This is the one people reach for too early. It looks like the "grown-up" option, so it gets used as a general way to dodge renames and schema changes. That is the wrong reason. You pay a join on every read, you lose compile-time exhaustiveness (the compiler can no longer warn you about an unhandled switch branch), and you take on the overhead of keeping a relationship in sync.
A lookup table earns all of that only when the value carries editable business data. A display label the product team changes without a deploy. A sort order. A colour for the UI. A feature flag or an SLA attached to the status. Data that lives and changes at runtime.
The honest trade-off
There is no free option here, and the lookup table is where the cost is steepest. Every status read becomes a join. Every switch over status loses the safety net that makes enums worth using in Java. And you now own a tiny CRUD surface for reference data that, in most systems, never actually changes.
Weigh that against what STRING plus a Postgres enum type costs: a one-time migration on the rare day you rename a constant. For the overwhelming majority of status columns, that is the cheaper bill.
My take
Default to @Enumerated(STRING) backed by a native Postgres enum type. You get readable columns, reorder-safety, and the database rejecting garbage at the door.
Upgrade to an AttributeConverter the moment renames need to be free, for example a domain vocabulary that is still settling and gets renamed often.
Reach for a lookup table only when the value is genuinely a record: it has a label, a flag, an SLA, something a human edits at runtime.
That is the whole decision, compressed: if the value is identity only, keep it an enum. If the value is a record, it was never really an enum.
Get that right once and your statuses stay boring, which is the highest compliment you can pay a column.
What do you store status enums as in production, and has a reorder or a rename ever burned you?
Top comments (1)
Great article. I agree that
ORDINALis dangerous.For the
AttributeConverterapproach, I think the main downside is confusion. If Java saysAWAITING_PAYMENT, but the database still storesPND, debugging raw SQL or logs becomes harder.I would usually keep the database value the same as the enum name. If I rename the enum, I would also update the database with a migration.