How to use serde with enums

Add the serde derive feature to Cargo.toml and derive Serialize and Deserialize on your enum to enable serialization.

When enums hit the wire

You're building a config parser. The user wants to set mode to either "fast" or "slow". You write an enum in Rust. You try to save it to JSON. The output is {"Active": null} and you stare at the screen wondering why your API client is exploding. Or you try to deserialize a response and the program crashes because the JSON has "active" in lowercase and Rust expects Active.

Enums are the backbone of Rust logic. They model state, commands, and results with precision. Serialization formats like JSON, YAML, TOML, and Bincode don't speak Rust enums natively. They speak strings, numbers, objects, and arrays. Bridging that gap is where serde earns its keep. You need to tell serde how to translate your Rust variants into the wire format, and how to reconstruct them on the other side.

The packing analogy

Think of serialization like packing a suitcase for international travel. Your Rust enum is a set of oddly shaped tools. The destination (JSON, TOML, Bincode) has strict rules about what fits in the box. serde is the packing expert. It knows how to wrap your tools so they slide into the box without breaking, and how to unpack them when they arrive.

The default behavior is usually straightforward: put the name of the tool on a label and stick it in the box. If the tool has attachments (data), wrap those in a smaller box and label that too. Sometimes the destination has weird rules. Maybe they want the label inside the box, or maybe they want no label at all and expect you to guess based on the shape. serde lets you customize the packing strategy with attributes.

Minimal example: unit variants

Start with the setup. You need serde with the derive feature. This feature enables the #[derive(Serialize, Deserialize)] macros. Without it, you can't use the derive syntax.

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Here is a basic enum with unit variants. Unit variants carry no data. They are just names.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
enum Status {
    Active,
    Inactive,
}

fn main() {
    let status = Status::Active;
    
    // Serialize to a JSON string.
    let json = serde_json::to_string(&status).unwrap();
    println!("{}", json); // "Active"

    // Deserialize back from JSON.
    let parsed: Status = serde_json::from_str(&json).unwrap();
    println!("{:?}", parsed); // Active
}

Convention aside: Always derive Debug alongside Serialize and Deserialize. When serialization fails, you'll be printing values to figure out what went wrong. Having Debug ready saves you from rewriting the derive line in the middle of a panic.

What happens under the hood

When you derive Serialize on Status, serde generates code that checks which variant is active. For unit variants, the default representation is a plain string. Active becomes "Active". This works because JSON supports strings, and the string uniquely identifies the variant.

When you derive Deserialize, serde generates code that reads the input. If it sees a string, it compares it against the known variant names. If it matches, it constructs the enum. If it doesn't match, it returns an error. The comparison is case-sensitive. "active" does not match Active.

The default string representation is the path of least resistance. Stick with it until you hit a wall.

Realistic example: enums with data

Real enums usually carry data. A message protocol might have a Ping, a Text with content, and an Error with a code.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
enum Message {
    Ping,
    Text { content: String },
    Error { code: u32, message: String },
}

/// Serializes a message to JSON and prints the result.
fn print_json(msg: &Message) {
    // Convert the enum to a JSON string.
    let json = serde_json::to_string(&msg).unwrap();
    println!("{}", json);
}

fn main() {
    let msg = Message::Text { content: "Hello".to_string() };
    print_json(&msg);
}

The output is {"Text":{"content":"Hello"}}.

This is the externally tagged representation. serde creates a JSON object with a single key. The key is the variant name. The value is the serialized data for that variant. For Ping, the output is {"Ping": null} because there is no data to serialize.

Externally tagged is the safe default. It's verbose, but it's unambiguous. The receiver always knows exactly which variant arrived.

Convention aside: Rust uses PascalCase for enum variants. JSON APIs often prefer snake_case or kebab-case. The community standard is to add #[serde(rename_all = "snake_case")] to the enum definition. This transforms Text to "text" and Error to "error" automatically. Apply this attribute early to avoid case-mismatch bugs later.

Externally tagged is the safe default. It's verbose, but it's unambiguous. The receiver always knows exactly which variant arrived.

Taming case sensitivity

Case sensitivity is the silent killer of serialization. Annotate your enums early, or debug your JSON logs late.

If your wire format uses different casing, serde will not guess. It will fail.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum Status {
    Active,
    VeryImportant, // Serializes to "very_important"
    #[serde(rename = "on")]
    Enabled,       // Serializes to "on"
}

The rename_all attribute applies to all variants. The rename attribute overrides for a specific variant. Use rename when the wire format has a legacy name that doesn't fit the general pattern.

If you forget rename_all and the JSON contains "active", deserialization fails with a runtime error like unknown variant 'active', expected 'Active'. This is a data error, not a compile error. The compiler trusts you to match the formats.

Case sensitivity is the silent killer of serialization. Annotate your enums early, or debug your JSON logs late.

Pitfalls and compiler errors

If you try to deserialize without deriving Deserialize, the compiler rejects you with E0277 (the trait bound Deserialize is not satisfied). You need both traits for round-tripping. Deriving Serialize alone lets you write data but not read it back.

Another common trap is the "unknown variant" error during deserialization. This happens when the input contains a variant name that doesn't exist in your Rust enum. This often occurs when a server adds a new variant and the client hasn't updated yet. The client crashes on the unknown data.

Serde is literal. It does not infer intent. If the wire format says snake_case, tell Serde to expect snake_case.

Forward compatibility with other

Design your enums for the future. The other variant buys you time when the world changes.

To handle unknown variants gracefully, use #[serde(other)]. This marks a variant as a catch-all. Any input that doesn't match the other variants maps to this one.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
enum Status {
    Active,
    Inactive,
    #[serde(other)]
    Unknown,
}

fn main() {
    // The JSON has a variant we don't know about.
    let json = r#""Pending""#;
    
    // Deserialization succeeds, mapping "Pending" to Unknown.
    let status: Status = serde_json::from_str(json).unwrap();
    println!("{:?}", status); // Unknown
}

This pattern is essential for robust systems. It turns a crash into a handled case. You can log the unknown value, fall back to a default behavior, or prompt the user to update.

Design your enums for the future. The other variant buys you time when the world changes.

Choosing the representation

Serde supports several tagging strategies. The right choice depends on the wire format you're integrating with.

Use the default externally tagged representation for most APIs and config files. It produces {"Variant": data} which is explicit and easy to debug.

Use #[serde(tag = "type")] for internally tagged enums when you're working with a JSON schema that expects a discriminator field inside the object, like {"type": "User", "id": 1}. This is common in JSON:API and some database drivers.

Use #[serde(tag = "t", content = "c")] for adjacently tagged enums when the wire format separates the tag and the payload into two distinct keys, often required by strict schema validators.

Use #[serde(untagged)] only when you're parsing a heterogeneous list where the structure itself determines the variant, such as a JSON array that contains both strings and numbers. Untagged enums are greedy and can lead to ambiguous deserialization; reserve them for data you control completely.

Pick the representation that matches the wire format, not the one that looks prettiest in Rust. The wire format is the boss.

Where to go next