Mehdi Akiki
Published on

Add a PostgreSQL Enum Value Without Surprising Old Rust Readers

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Derived state

A new order state is ready: processing. The database migration accepts it, the new service writes it, and an older worker immediately fails while reading the order. The schema change succeeded. The rollout did not.

Adding an enum label changes what the database can store. It does not change the set of values understood by every Rust binary already running. A worker deployed yesterday may still have a match that knows only pending and paid.

When adding a label to the existing database enum used here, the migration must commit before another statement uses that label. Separately, every reader that can receive it needs a way to handle it. Finishing the migration answers only the first question.

Keep the old reader old

The example in pg_enum.rs retains a small decoder for the old application. Its OldStatus enum has only the two original variants:

fn decode_old(value: &str) -> std::result::Result<OldStatus, String> {
    match value {
        "pending" => Ok(OldStatus::Pending),
        "paid" => Ok(OldStatus::Paid),
        other => Err(format!("unsupported order status: {other}")),
    }
}

The program reads the database enum as text, then calls this unchanged function. It tests the values the old application accepts. It does not exercise SQLx’s derived enum decoder; if the shipped application uses one, keep that actual decoding path in its compatibility check too.

Do not add the new variant to this retained decoder to make the migration test pass. That would test two new components together while the installed old component remains untested. Keep the release artifact or its actual decoding code available for compatibility checks.

PostgreSQL's enum documentation explains the database type's labels and ordering. An application enum is a separate definition that must evolve with the consumers using it.

Commit the addition before writing the value

The test first creates the database enum with pending and paid, then stores an ordinary pending order. It begins a migration transaction, adds processing, and immediately attempts to insert an order using that new label.

PostgreSQL must reject that early use. The fixture checks SQLSTATE 55P04 and rolls the failed transaction back. Since the transaction aborted, that attempt has not left a committed enum change behind.

The real demonstration migration then adds the value again in a new transaction and commits. Only afterward does another statement insert the processing order. PostgreSQL's ALTER TYPE reference documents the commit requirement for added enum values.

This matters for migration frameworks that wrap several changes in one transaction. A file that adds an enum label and backfills rows to that label may need a deliberate transaction split. Do not disable transaction handling across all migrations merely to solve one specific ordering requirement.

Check what the framework actually commits, and keep the schema addition separate from the first data write in the integration test. A successful command log without that boundary can hide the failing path.

The committed row still breaks the old decoder

Once the second migration commits, the fixture inserts a processing order and reads both stored status labels. The old decoder must still accept the pending order and return its explicit unsupported-state error for the processing order.

The test also counts the processing row in PostgreSQL. That confirms the compatibility error did not come from a failed insert or an empty query. The row exists; the old reader cannot represent its state.

Consider a worker that decodes a whole page of orders before processing any. One processing row could stop that page, including older pending orders the worker normally understands. Exercise the real batch or endpoint around the decoder, so the rollout plan accounts for how far a rejected value spreads.

A decoder that maps every unknown status to paid would hide the failure while changing the product's meaning. A processing order has not become paid because that happens to be a convenient old variant. Choose an honest fallback or an explicit refusal that the surrounding application can handle.

Prepare readers before enabling writers

One workable order is to deploy readers that understand the new state while writers continue producing the old states. Commit the database addition, check the relevant read paths, and then enable the new writer behavior for a controlled population.

The exact deployment order can vary with dependencies. The important point is that a schema accepting the value and a writer choosing the value are independently controlled steps. If the writer starts as soon as the migration completes, the team loses the opportunity to verify the readers first.

Inventory readers outside the main HTTP service too. Reports, exports, scheduled jobs, change-data consumers, and older administrative tools may all decode the same column. A rarely run report can be the first component to meet a label that the interactive application has handled for weeks.

If an old consumer must remain supported, decide how it receives a meaningful representation. That might be an explicit unsupported-state response or a separate compatibility view, depending on the product. Test the consumer's behavior around that response, including retries. Replacing a decode failure with an endless retry loop is not a successful transition.

Rollback includes the rows already written

Turning off the new writer does not remove processing orders that were committed earlier. Rolling back to a binary with the old decoder can therefore reintroduce the failure even after the feature flag is off.

Keep a reader that can handle the stored values deployed while those rows exist, or perform a separately reviewed data transition whose meaning the product accepts. Do not assume a down migration can simply erase an enum label and leave existing data untouched.