DEV Community

Cover image for πŸ—„οΈ The JPA Enum Default Quietly Corrupts Your Data
Kyryl
Kyryl

Posted on • Originally published at codewithkyryl.dev

πŸ—„οΈ The JPA Enum Default Quietly Corrupts Your Data

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
@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);
    }
}
Enter fullscreen mode Exit fullscreen mode

The column stores 'PND'. The Java identifier is now free to change:

PENDING -> AWAITING_PAYMENT
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
stitas profile image
stitas

Great article. I agree that ORDINAL is dangerous.

For the AttributeConverter approach, I think the main downside is confusion. If Java says AWAITING_PAYMENT, but the database still stores PND, 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.