How to Serialize and Deserialize Enums with Serde

Add serde derive macros and attributes to your enum to enable automatic serialization and deserialization.

The shape mismatch

You write a Rust enum to model a configuration mode or an API status. The compiler loves it. The type system guarantees you handle every variant. Then you try to send it over the wire or save it to a file, and the format expects a flat key-value structure. Rust enums are not JSON. They are not YAML. They are memory layouts with discriminants and optional payloads. Bridging that gap requires a translation layer, and Serde provides it through derive macros and attribute annotations.

The default translation strategy trips up beginners because it assumes you want the variant name wrapped in a JSON object. You usually want a plain string, or a string nested inside a larger payload, or a completely flat structure. Serde gives you four distinct tagging strategies. Picking the right one prevents runtime deserialization panics and keeps your wire format predictable.

How tagging actually works

Think of an enum like a physical switch with labeled positions. Rust stores the switch state as an integer discriminant, optionally paired with data. JSON has no concept of discriminants. It only has strings, numbers, objects, and arrays. Serde solves this by attaching a "tag" to the serialized output. The tag tells the deserializer which variant to construct.

The default strategy is externally tagged. The variant name becomes a key in a JSON object, and the variant data becomes the value. If the variant carries no data, the value is null. This matches how many Rust developers expect enums to serialize, but it rarely matches how web APIs or config files are structured. You can change the tag location, remove it entirely, or move it alongside the data. Each strategy changes the JSON shape and the deserialization guarantees.

Minimal example

use serde::{Deserialize, Serialize};

/// Represents application run modes for configuration files.
#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] // Convert Rust CamelCase to JSON snake_case
pub enum Mode {
    #[default] // Fallback when deserializing missing or unknown values
    Default,
    Simple,
    Advanced,
}

/// Demonstrates basic enum serialization and deserialization.
fn main() {
    let config = Mode::Simple;
    
    // Serialize to a JSON string using serde_json
    let json = serde_json::to_string(&config).expect("Serialization failed");
    println!("Serialized: {}", json);
    
    // Deserialize back from the JSON string
    let parsed: Mode = serde_json::from_str(&json).expect("Deserialization failed");
    assert_eq!(config, parsed);
}

The #[serde(rename_all = "snake_case")] attribute is a community convention. JSON APIs overwhelmingly prefer lowercase with underscores. Rust prefers PascalCase for enum variants. This single attribute bridges the naming gap without manual string mapping. The #[default] attribute is a Rust 1.62+ feature that works alongside Serde to provide a fallback during deserialization.

Trust the rename attribute. It handles every variant automatically and keeps your wire format consistent.

Under the hood: the conversion path

When you add #[derive(Serialize, Deserialize)], the macro expands into two trait implementations. The Serialize impl matches on each variant and calls the serializer's serialize_unit_variant, serialize_newtype_variant, or serialize_tuple_variant methods. The Deserialize impl builds a visitor that expects a specific JSON shape. The visitor checks the tag first, validates it against known variants, then extracts the payload if one exists.

During serialization, Serde does not allocate an intermediate serde_json::Value unless you explicitly request it. It streams directly to bytes or a string buffer. During deserialization, Serde parses the JSON token stream once. It matches the first token against the expected tag. If the tag matches, it consumes the remaining tokens to build the variant. If the tag is missing or unrecognized, the visitor returns an error immediately. This single-pass design keeps memory overhead minimal and deserialization fast.

The derive macros generate match statements that mirror your enum definition. If you add a variant later, the generated code automatically includes it. You do not need to update serialization logic manually. The compiler guarantees the generated impls stay in sync with your type definition.

Keep your enum variants stable. Adding or removing variants changes the wire format expectations for every consumer.

Realistic example: API payloads

External tagging works for simple configs. It breaks down when enums live inside larger structs. Consider a REST API that returns different resource types in the same endpoint. You want the type identifier inside the JSON object, not wrapping it. That requires internal tagging.

use serde::{Deserialize, Serialize};

/// Represents different notification types from a messaging API.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")] // Place the variant name as a field inside the object
pub enum Notification {
    /// A simple text alert with no extra payload
    Alert {
        message: String,
        priority: u8,
    },
    /// A scheduled event with metadata
    Scheduled {
        title: String,
        timestamp: i64,
        #[serde(default)] // Provide empty string if field is missing in JSON
        description: String,
    },
}

/// Demonstrates internally tagged enum serialization.
fn main() {
    let payload = Notification::Scheduled {
        title: "System Maintenance".to_string(),
        timestamp: 1700000000,
        description: "Routine database backup".to_string(),
    };
    
    let json = serde_json::to_string_pretty(&payload).expect("Serialization failed");
    println!("{}", json);
    
    // Simulate a partial JSON payload from an older API version
    let partial_json = r#"{"kind": "Scheduled", "title": "Update", "timestamp": 1700000001}"#;
    let parsed: Notification = serde_json::from_str(partial_json).expect("Deserialization failed");
    println!("{:?}", parsed);
}

The #[serde(tag = "kind")] attribute tells Serde to embed the variant name as a regular field. The remaining fields in the struct variant sit alongside it. This matches how most JSON APIs structure polymorphic responses. The #[serde(default)] attribute on description handles backward compatibility. Older API versions might omit the field, and Serde will fill it with String::default() instead of failing.

Internal tagging is the standard for API contracts. It keeps payloads flat and predictable.

Pitfalls and error patterns

Enum serialization fails in three predictable ways. Case mismatches are the most common. If your JSON uses "Simple" but your enum expects "simple", deserialization returns a serde_json::Error with a message like unknown variant 'Simple', expected one of 'simple', 'advanced'. The fix is always #[serde(rename_all = "...")] or explicit #[serde(rename = "...")] on the variant.

Missing variants cause hard failures. If your server adds a Critical variant but your client still expects only Alert and Scheduled, the client deserializer rejects the payload. You can mitigate this with #[serde(other)] on a catch-all variant, but that sacrifices type safety. Use it only when you control the server and need forward compatibility.

Untagged enums introduce ambiguity. When you remove the tag entirely, Serde guesses the variant based on the JSON structure. If two variants share the same shape, deserialization picks the first match. This works for simple string enums but breaks for complex payloads. The compiler will not warn you about ambiguous untagged enums. You will only discover the collision at runtime.

If you forget to derive Serialize or Deserialize, the compiler rejects your code with E0277 (trait bound not satisfied). The error points to the exact function call where the trait is required. Add the derive macro and the trait bound resolves immediately.

Treat deserialization errors as contract violations. Fix the wire format or update the client. Do not patch them with silent fallbacks.

Choosing your tagging strategy

Use externally tagged enums when you serialize standalone configuration values or command-line arguments where the variant name wraps the payload. Use internally tagged enums when your enum lives inside a larger JSON object or represents a polymorphic API response. Use adjacently tagged enums when you need the variant name and the payload data in separate, explicitly named fields for strict schema validation. Use untagged enums when you are parsing legacy data that lacks type identifiers and you can guarantee structural uniqueness across variants. Reach for #[serde(other)] when you need forward compatibility with server updates and accept losing compile-time exhaustiveness checks.

Counter-intuitive but true: the more you flatten your enum representation, the harder it becomes to validate incoming data. Pick the strategy that matches your API contract, not the one that produces the shortest JSON.

Where to go next