How to Use Database Enums in Rust

Define an `enum` to group related variants and use `match` to handle each case safely. This pattern ensures your code handles every possible state without runtime errors.

The mismatch that breaks deployments

You add a new variant to a Rust enum, run cargo build, and everything compiles. You push to staging, trigger a migration, and the next API request crashes with a database constraint violation. The column still expects the old set of values. Or worse, the ORM tries to insert a string like Shipped into a column that only accepts lowercase shipped. The compiler never warned you. The database does.

Rust enums and database enums share a name and a general idea, but they operate on completely different rules. Rust enums are compile-time guarantees about exhaustiveness and memory layout. Database enums are runtime constraints on what values a column will accept. Bridging them requires explicit mapping, deliberate trait implementations, and a clear choice about how the data travels between your code and your tables.

What a database enum actually is

A database enum is not a Rust enum. It is a constrained column type. PostgreSQL calls it ENUM. MySQL uses ENUM or SET. SQLite has no native enum type and relies on TEXT or INTEGER with CHECK constraints. The database stores a fixed list of allowed values and rejects anything outside that list.

Rust enums are flexible. You can add variants, attach data to variants, and match on them with compile-time guarantees. The compiler knows every possible shape your value can take. The database knows nothing about Rust. It only knows the schema you gave it.

Think of a database enum like a turnstile with specific keycards. The turnstile only opens for cards it recognizes. Rust enums are like a multi-tool. You can swap blades, add attachments, and reconfigure it on the fly. You need an adapter to make the multi-tool work with the turnstile. That adapter is your serialization and type-mapping layer.

The minimal mapping

You start with a plain Rust enum. It compiles fine. It does nothing for databases yet.

/// Represents the lifecycle state of an order in the system.
#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
    Created,
    Processing,
    Shipped,
    Delivered,
}

fn main() {
    let status = OrderStatus::Created;
    println!("{:?}", status);
}

The compiler accepts this. The database will not. You need to tell Rust how to convert OrderStatus into a database-compatible representation, and how to convert it back. Most modern Rust database libraries expect two things: a way to serialize to the wire format, and a trait that declares the database type name.

Here is the standard pattern using serde for text mapping and sqlx for type registration.

use serde::{Deserialize, Serialize};

/// Represents the lifecycle state of an order in the system.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum OrderStatus {
    Created,
    Processing,
    Shipped,
    Delivered,
}

fn main() {
    let status = OrderStatus::Created;
    let json = serde_json::to_string(&status).unwrap();
    println!("Serialized: {}", json);
}

The #[serde(rename_all = "lowercase")] attribute changes how the enum serializes. Without it, serde outputs Created. With it, you get created. Most databases expect lowercase enum values. The attribute saves you from manual string mapping.

Convention aside: the Rust community prefers explicit #[serde(rename_all = "...")] over manual #[serde(rename = "...")] on every variant. It keeps the derive macro doing the heavy lifting and reduces typo surface area.

How the data moves

When you insert a row, the data travels through three stages. First, your Rust enum becomes a byte sequence or string. Second, the database driver sends that sequence over the wire. Third, the database engine validates it against the column's allowed values.

Deserialization runs in reverse. The database returns a string or integer. The driver hands it to your deserializer. Your code reconstructs the Rust enum. If the database contains a value your Rust enum does not recognize, deserialization fails. The compiler cannot prevent this. The database schema can change independently of your code.

You handle unknown values with #[serde(other)] or a custom TryFrom implementation. This catches future variants added by migrations or manual DB edits.

use serde::{Deserialize, Serialize};

/// Represents the lifecycle state of an order in the system.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum OrderStatus {
    Created,
    Processing,
    Shipped,
    Delivered,
    #[serde(other)]
    Unknown,
}

fn main() {
    let raw = "\"pending\"";
    let status: OrderStatus = serde_json::from_str(raw).unwrap();
    println!("Parsed: {:?}", status);
}

The #[serde(other)] variant catches any string that does not match the known variants. It prevents runtime panics when the database holds a value your code has not yet updated. You log it, you handle it, you move on.

A realistic workflow

Real projects use an ORM or query builder. sqlx and diesel both require explicit type registration. Here is how sqlx expects you to declare the mapping.

use serde::{Deserialize, Serialize};
use sqlx::Type;

/// Represents the lifecycle state of an order in the system.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "order_status")]
#[sqlx(rename_all = "lowercase")]
enum OrderStatus {
    Created,
    Processing,
    Shipped,
    Delivered,
}

fn main() {
    let status = OrderStatus::Shipped;
    println!("DB type: {}", std::any::type_name::<OrderStatus>());
}

The #[derive(Type)] macro generates the trait implementation that tells sqlx how to encode and decode the enum. The #[sqlx(type_name = "order_status")] attribute matches the exact name you created in your migration. The #[sqlx(rename_all = "lowercase")] attribute ensures the driver sends lowercase strings to the database.

Convention aside: always match the type_name to your migration exactly. Case sensitivity varies by database. PostgreSQL treats order_status and OrderStatus as different types. A mismatch produces a compile-time error or a runtime panic depending on your setup. Keep the names identical.

Here is how the enum looks in a migration file.

CREATE TYPE order_status AS ENUM (
    'created',
    'processing',
    'shipped',
    'delivered'
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'created'
);

The database now enforces the constraint. Your Rust code now speaks the same language. The mapping is explicit. The compiler and the database agree.

Where things go wrong

The most common failure is a missing trait bound. You try to use the enum in a query, and the compiler rejects you with E0277 (trait bound not satisfied). The error points to sqlx::Type or diesel::Queryable. You forgot to derive the database trait. Add the derive. The error disappears.

The second failure is a constraint violation. You insert a value the database does not recognize. The query returns an error like invalid input value for enum order_status. This happens when your Rust enum has a variant the database schema lacks, or when the casing does not match. Align the schema and the rename_all attributes.

The third failure is deserialization panic. The database contains pending, but your Rust enum only knows created, processing, shipped, delivered. serde panics. Add #[serde(other)] or implement FromStr with a fallback. Never let unknown database values crash your service.

The fourth failure is integer mapping confusion. Some teams store enums as integers to save space. They use #[repr(i32)] on the Rust enum. They forget that #[repr(i32)] only changes memory layout. It does not change serialization. You still need serde or a custom FromStr/ToString implementation to convert between integers and the database wire format.

Trust the borrow checker. It usually has a point. Trust the type system. It catches mapping mistakes before they hit production.

Picking your mapping strategy

Use text-based mapping with #[serde(rename_all = "lowercase")] when you want self-documenting queries and easy debugging. Text values survive schema dumps, copy-paste into SQL clients, and log inspection. The storage overhead is negligible for typical enum sizes.

Use integer mapping with #[repr(i32)] and custom FromStr/ToString when you are storing millions of rows and every byte matters. Integers sort faster and index smaller. You pay for the extra conversion code and lose human readability in raw database dumps.

Use #[derive(sqlx::Type)] or #[derive(diesel::FromSqlRow)] when you are using a type-checked query library. These derives generate the encode/decode logic automatically. They tie your Rust enum to the exact database type name. They fail at compile time if the names drift apart.

Use a custom FromStr implementation with a fallback variant when your database is managed by multiple teams or legacy systems. Unknown values appear. The fallback variant catches them. You log the mismatch and handle it gracefully instead of crashing.

Use a plain TEXT column with CHECK constraints when you are on SQLite or a database that lacks native enum support. You get the same safety guarantees without fighting type system limitations. The constraint lives in the migration. The mapping lives in Rust.

Where to go next