How to serialize an enum with serde
Short answer: With no attribute, serde writes an enum the externally tagged way: a unit variant becomes a bare string, every other variant an object with the variant name as its only key. Add #[serde(tag = "type")] for internally tagged, #[serde(tag = "t", content = "c")] for adjacently tagged, or #[serde(untagged)] when the JSON has no tag at all.
This continues the json-demo project from the earlier pages in this chapter — cargo new json-demo, with the same two crates:
[dependencies]serde = { version = "1.0.229", features = ["derive"] }serde_json = "1.0.154"The default: externally tagged
Section titled “The default: externally tagged”use serde::Serialize;
#[derive(Debug, Serialize)]enum Message { Quit, Move { x: i32, y: i32 }, Write(String), Echo(String, u8),}
fn main() { for m in [ Message::Quit, Message::Move { x: 3, y: -4 }, Message::Write(String::from("hello")), Message::Echo(String::from("ping"), 2), ] { println!("{}", serde_json::to_string(&m).unwrap()); }}"Quit"{"Move":{"x":3,"y":-4}}{"Write":"hello"}{"Echo":["ping",2]}Each variant kind gets its own shape: a unit variant is the variant name as a JSON string, a newtype variant an object with one key, a tuple variant an object whose value is an array, a struct variant an object whose value holds the fields. The tag is the variant name spelled exactly as in Rust, and serde_json reads all four shapes back with no extra attributes.
Internally tagged
Section titled “Internally tagged”use serde::Serialize;
#[derive(Debug, Serialize)]#[serde(tag = "type")]enum Event { Start, Move { x: i32, y: i32 }, Write(String),}
fn main() { for e in [ Event::Start, Event::Move { x: 3, y: -4 }, Event::Write(String::from("hello")), ] { match serde_json::to_string(&e) { Ok(json) => println!("{json}"), Err(e) => println!("error: {e}"), } }}{"type":"Start"}{"type":"Move","x":3,"y":-4}error: cannot serialize tagged newtype variant Event::Write containing a stringThe tag is an ordinary field beside the variant’s own fields — the shape event streams and message buses use, where a consumer dispatches on type without unwrapping anything first. The content has to be a JSON object, which is where the newtype variant falls over: the error arrives at run time, not at compile time, so a payload that is never exercised ships anyway.
Adjacently tagged
Section titled “Adjacently tagged”use serde::Serialize;
#[derive(Debug, Serialize)]#[serde(tag = "t", content = "c")]enum Message { Quit, Move { x: i32, y: i32 }, Write(String), Echo(String, u8),}
fn main() { for m in [ Message::Quit, Message::Move { x: 3, y: -4 }, Message::Write(String::from("hello")), Message::Echo(String::from("ping"), 2), ] { println!("{}", serde_json::to_string(&m).unwrap()); }}{"t":"Quit"}{"t":"Move","c":{"x":3,"y":-4}}{"t":"Write","c":"hello"}{"t":"Echo","c":["ping",2]}Two keys instead of one: t names the variant, c carries the content, and a unit variant gets the tag key alone. Every variant kind survives, including the newtype that internally tagged rejected, and the payload never changes shape depending on which variant it is. The cost is a few extra bytes per message.
Untagged
Section titled “Untagged”use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]#[serde(untagged)]enum Shape { Point { x: i32, y: i32 }, Circle { x: i32, y: i32, r: i32 },}
fn main() { let circle = Shape::Circle { x: 1, y: 2, r: 5 }; let json = serde_json::to_string(&circle).unwrap(); println!("serialised: {json}"); match serde_json::from_str::<Shape>(&json) { Ok(shape) => println!("parsed back: {shape:?}"), Err(e) => println!("error: {e}"), } match serde_json::from_str::<Shape>(r#""nope""#) { Ok(shape) => println!("parsed: {shape:?}"), Err(e) => println!("error: {e}"), }}serialised: {"x":1,"y":2,"r":5}parsed back: Point { x: 1, y: 2 }error: data did not match any variant of untagged enum ShapeUntagged writes the variant’s content with nothing recording which variant it was, so reading it back means trying the variants in declaration order and keeping the first that parses. Circle serialises its three fields, but Point comes first and accepts the extra r — serde ignores unknown fields unless told otherwise — so the circle comes back as a point. When nothing fits, the error doesn’t say which variants were tried.
Which one to pick
Section titled “Which one to pick”- Externally tagged (the default) when both ends are yours: no attribute, and every variant kind round-trips.
- Internally tagged when something downstream reads the tag as a field. Unit and struct variants work; a newtype variant doesn’t.
- Adjacently tagged when the content can be a scalar or an array, or when one flat object per message matters more than the extra bytes.
- Untagged only when the JSON genuinely carries no tag — and get the variant order right, because that’s the entire disambiguation rule.
- How to handle a missing field in JSON — the previous page:
Option,default, and the difference between the two - How to deserialize JSON into a struct — where the
json-demoproject starts - Back to Working with JSON, TOML and CSV