How to Serialize Enums with Serde (Tagged, Untagged, etc.)

Use Serde attributes like `#[serde(tag)]` or `#[serde(untagged)]` to define how Rust enums serialize to JSON.

When JSON doesn't know what an enum is

You have a config file. You define a Mode enum in Rust. You derive Serialize and Deserialize. You dump the config to JSON. You expect {"mode": "simple"}. You get {"Simple": {}}. Your frontend parser chokes because it's looking for a mode key, not a Simple key. Or worse, you try to deserialize a list of mixed objects and Serde panics with a deserialization error because it can't figure out which variant to construct.

Rust enums are sum types. They carry a tag (the variant name) and optional payload. JSON is a flat structure of objects, arrays, strings, and numbers. JSON has no concept of an enum. When you serialize, Serde has to translate the enum into JSON. When you deserialize, Serde has to reverse the process. The default translation often doesn't match what APIs, config files, or databases expect. Serde attributes give you the controls to shape that translation.

The tagging problem

Think of serialization like shipping packages. You have different types of items. When you ship them, the receiver needs to know what's inside. You have a few options for labeling the box.

You can write the item type on a sticker on the outside of the box. This is internal tagging. The box contains the item and the label together. This is usually what APIs want.

You can put a card inside the box that says what the item is. This is less common in JSON but exists in some formats.

You can use the box type itself as the label. A red box means apples. A blue box means oranges. This is external tagging. The variant name becomes the JSON key.

You can throw the item in a generic bag with no label and hope the receiver knows what it is based on the shape of the item. This is untagged. It works if the items are distinct enough, but it's risky.

Serde lets you pick the labeling strategy. The attributes tag, content, and untagged control this.

Minimal example: Internal tagging

The most common fix for the config problem is internal tagging. You tell Serde to put the variant name into a specific field inside the JSON object.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum Mode {
    Default,
    Simple,
    Advanced { level: u32 },
}

fn main() {
    let config = Mode::Advanced { level: 5 };
    let json = serde_json::to_string(&config).unwrap();
    // Output: {"mode":"advanced","level":5}
    println!("{}", json);
}

The #[serde(tag = "mode")] attribute tells Serde to use the mode field as the discriminator. For unit variants like Default, the output is {"mode":"default"}. For tuple or struct variants like Advanced, the payload fields merge into the same object alongside the tag.

The rename_all = "snake_case" attribute is almost always paired with tagging. Rust enums use PascalCase. JSON conventions usually prefer snake_case or camelCase. Without renaming, you'd get {"mode":"Advanced"}, which breaks parsers expecting lowercase keys.

Stick to internal tagging for polymorphic data. It keeps your JSON flat and your parsers predictable.

How deserialization works

Serialization is easy. Serde looks at the variant, writes the tag, writes the payload. Deserialization is where things get interesting.

When Serde deserializes a tagged enum, it looks for the tag field first. It reads the value, maps it to a variant, and then deserializes the remaining fields into that variant's payload. If the tag is missing, deserialization fails immediately. If the tag exists but the payload fields are wrong, deserialization fails with a field error. This is fast and deterministic.

Untagged deserialization is different. Serde doesn't look for a tag. It tries to deserialize the data into the first variant. If that succeeds, it returns that variant. If it fails, it tries the second variant. It continues until one variant matches or all variants are exhausted.

This trial-and-error approach has consequences. If multiple variants can match the same data, Serde picks the first one silently. There is no warning. The bug hides in runtime behavior.

Realistic example: Mixed notifications

Consider a notification system. Some notifications are just text. Some have images. Some are system alerts with codes. You receive a JSON array from an API.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Notification {
    Text { message: String },
    Image { url: String, caption: Option<String> },
    Alert { code: u32, severity: String },
}

fn main() {
    let json = r#"[
        {"type": "text", "message": "Hello"},
        {"type": "image", "url": "https://example.com/img.png", "caption": "A photo"},
        {"type": "alert", "code": 404, "severity": "warning"}
    ]"#;

    let notifications: Vec<Notification> = serde_json::from_str(json).unwrap();
    println!("{:#?}", notifications);
}

The tag attribute handles the polymorphism. Each object in the array has a type field. Serde uses that field to decide which Notification variant to construct. The rename_all ensures the JSON keys match the API's snake_case convention.

If the API sends {"type": "unknown"}, deserialization fails with a clear error: unknown variant 'unknown', expected one of 'text', 'image', 'alert'. This is helpful. You know exactly what went wrong.

Convention aside: Pair tag with rename_all on the enum. It's the standard pattern. If you need to rename a specific variant differently, use #[serde(rename = "...")] on that variant.

Pitfalls and errors

Untagged ambiguity

Untagged enums are dangerous when variants overlap.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
    Integer(i64),
    Float(f64),
}

fn main() {
    let json = "42";
    let value: Value = serde_json::from_str(json).unwrap();
    // Result: Integer(42)
    println!("{:?}", value);
}

The JSON 42 matches both Integer and Float. Serde tries Integer first. It succeeds. The result is Integer(42). If you swap the order of variants, the result becomes Float(42.0). The behavior changes based on source code order. This is a maintenance trap.

If you need to distinguish between integers and floats, use tagged enums or a custom deserializer. Don't rely on untagged ordering.

Missing tag errors

If you use tag but the JSON lacks the tag field, deserialization fails.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum Shape {
    Circle { radius: f64 },
}

fn main() {
    let json = r#"{"radius": 5.0}"#;
    let result: Result<Shape, _> = serde_json::from_str(json);
    // Error: missing field `kind`
    println!("{:?}", result);
}

The error message is clear: missing field 'kind'. This happens when the JSON producer doesn't follow the contract. If you control both sides, this error is rare. If you're deserializing from an external API, check the docs. The API might use a different tag name or a different tagging strategy.

E0277 on fields

If a field type doesn't implement Serialize or Deserialize, you get a compiler error.

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Config {
    File { path: PathBuf },
}

This fails with E0277: the trait bound PathBuf: Serialize is not satisfied. PathBuf doesn't implement Serde traits by default. You need to use a string or a wrapper type that implements the traits.

Fix field errors before worrying about enum structure. The compiler catches these early.

Adjacent tagging

Some APIs require the variant data to be nested inside a separate key, not merged with the tag. This is adjacent tagging. You use both tag and content attributes.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum Event {
    Click { x: i32, y: i32 },
    Scroll { offset: i32 },
}

fn main() {
    let event = Event::Click { x: 10, y: 20 };
    let json = serde_json::to_string(&event).unwrap();
    // Output: {"type":"click","payload":{"x":10,"y":20}}
    println!("{}", json);
}

The content = "payload" attribute tells Serde to put the variant data inside a payload key. The tag stays at the top level. This produces {"type":"click","payload":{"x":10,"y":20}}.

Use adjacent tagging when the API spec demands it. It's common in older REST APIs or systems that treat the payload as a black box.

The content attribute saves you from rewriting legacy APIs just to match Rust's enum layout.

Decision matrix

Use #[serde(tag = "...")] when you need a flat JSON object with a discriminator field. This is the standard for polymorphic APIs and config files. It produces clean JSON and fast deserialization.

Use #[serde(untagged)] when the input format lacks type information and you must infer the variant from the data shape. This is useful for generic JSON values or parsing heterogeneous lists where the structure varies wildly. Avoid untagged enums for large variants or overlapping types.

Use #[serde(tag = "...", content = "...")] when the API requires the variant data to be nested inside a separate key. This matches adjacent tagging formats where the payload is isolated from the tag.

Use the default external tagging when you want the variant name to be the JSON key itself. This produces {"VariantName": payload}. It's explicit and debuggable, but less common in modern APIs.

Match the serialization format to the consumer's expectations, not your enum's internal layout.

Where to go next