When using serde, how does #[serde(tag = "type")] differ from #[serde(tag = "type", content = "data")] for enum serialization?

#[serde(tag = "type")] uses internally tagged representation where the variant name appears as a field alongside the variant's data in a single JSON object. #[serde(tag = "type", content = "data")] uses adjacently tagged representation where the variant name and data are separate sibling fields. Internally tagged enums serialize to {"type": "Variant", ...other_fields} while adjacently tagged enums serialize to {"type": "Variant", "data": {...fields}}. The choice affects both serialization format and deserialization behavior: internally tagged enums must have variants with named fields (struct variants), while adjacently tagged enums can handle any variant type including unit and tuple variants. Internally tagged is cleaner for struct variants but cannot represent tuple or unit variants; adjacently tagged is more flexible but produces nested JSON.

Default Enum Representation

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
enum Message {
    Request { id: u32, method: String },
    Response { id: u32, result: String },
}
 
fn main() {
    let msg = Message::Request { 
        id: 1, 
        method: "get".to_string() 
    };
    
    let json = serde_json::to_string(&msg).unwrap();
    println!("{}", json);
    // {"Request":{"id":1,"method":"get"}}
}

By default, enums use externally tagged representation with variant name as the outer key.

Internally Tagged with tag = "type"

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
    Request { id: u32, method: String },
    Response { id: u32, result: String },
}
 
fn main() {
    let msg = Message::Request { 
        id: 1, 
        method: "get".to_string() 
    };
    
    let json = serde_json::to_string(&msg).unwrap();
    println!("{}", json);
    // {"type":"Request","id":1,"method":"get"}
}

tag = "type" puts the variant name as a field alongside other fields.

Adjacently Tagged with tag and content

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Message {
    Request { id: u32, method: String },
    Response { id: u32, result: String },
}
 
fn main() {
    let msg = Message::Request { 
        id: 1, 
        method: "get".to_string() 
    };
    
    let json = serde_json::to_string(&msg).unwrap();
    println!("{}", json);
    // {"type":"Request","data":{"id":1,"method":"get"}}
}

tag and content separate variant name and data into sibling fields.

Internally Tagged Requires Named Fields

use serde::{Serialize, Deserialize};
 
// This compiles but won't work for all variants
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Status {
    Active,                          // Unit variant - ERROR at runtime
    Pending(String),                 // Tuple variant - ERROR at runtime
    Completed { success: bool },     // Struct variant - OK
}
 
// Internally tagged only works with struct variants
// Unit and tuple variants cause serialization errors

Internally tagged enums cannot have unit or tuple variants.

Adjacently Tagged Works with All Variants

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Status {
    Active,                      // Unit variant - OK
    Pending(String),             // Tuple variant - OK
    Completed { success: bool }, // Struct variant - OK
}
 
fn main() {
    let active = Status::Active;
    println!("{}", serde_json::to_string(&active).unwrap());
    // {"type":"Active"}
    
    let pending = Status::Pending("waiting".to_string());
    println!("{}", serde_json::to_string(&pending).unwrap());
    // {"type":"Pending","data":"waiting"}
    
    let completed = Status::Completed { success: true };
    println!("{}", serde_json::to_string(&completed).unwrap());
    // {"type":"Completed","data":{"success":true}}
}

Adjacently tagged handles unit, tuple, and struct variants.

Deserialization Differences

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum Internal {
    Start { name: String },
    Stop { reason: String },
}
 
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type", content = "data")]
enum Adjacent {
    Start { name: String },
    Stop { reason: String },
}
 
fn main() -> Result<(), serde_json::Error> {
    // Internally tagged: fields at same level as tag
    let internal_json = r#"{"type":"Start","name":"process"}"#;
    let internal: Internal = serde_json::from_str(internal_json)?;
    println!("{:?}", internal);
    
    // Adjacently tagged: fields nested under content key
    let adjacent_json = r#"{"type":"Start","data":{"name":"process"}}"#;
    let adjacent: Adjacent = serde_json::from_str(adjacent_json)?;
    println!("{:?}", adjacent);
    
    // Each format requires matching JSON structure
    // Can't deserialize internal format into adjacent enum
    
    Ok(())
}

Deserialization requires JSON matching the tag format.

Nested Content with Adjacent Tagging

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
    Click { x: i32, y: i32 },
    KeyPress { key: String, modifiers: Vec<String> },
    Scroll { direction: String, amount: u32 },
}
 
fn main() {
    let click = Event::Click { x: 100, y: 200 };
    println!("{}", serde_json::to_string_pretty(&click).unwrap());
    // {
    //   "type": "Click",
    //   "data": {
    //     "x": 100,
    //     "y": 200
    //   }
    // }
    
    let keypress = Event::KeyPress { 
        key: "Enter".to_string(), 
        modifiers: vec!["Shift".to_string()] 
    };
    println!("{}", serde_json::to_string_pretty(&keypress).unwrap());
    // {
    //   "type": "KeyPress",
    //   "data": {
    //     "key": "Enter",
    //     "modifiers": ["Shift"]
    //   }
    // }
}

The content field wraps all variant data as a nested object.

Flatten Conflict with Internal Tagging

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
    // If a variant has a field named "type", it conflicts
    Request { 
        #[serde(rename = "msgType")]  // Rename to avoid conflict
        type: String,  // Would conflict with tag
        id: u32 
    },
}
 
// The tag field name cannot be used by any variant field
// Use rename on the conflicting field

Tag field name must not conflict with any variant field names.

Multiple Enum Nesting

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Outer {
    Inner(Inner),
}
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Inner {
    A { value: i32 },
    B { value: String },
}
 
fn main() {
    let outer = Outer::Inner(Inner::A { value: 42 });
    println!("{}", serde_json::to_string(&outer).unwrap());
    // {"type":"Inner","data":{"type":"A","value":42}}
}

Nested enums combine their tagging formats.

Custom Tag and Content Names

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", content = "payload")]
enum Message {
    Ping,
    Pong { timestamp: u64 },
}
 
fn main() {
    let ping = Message::Ping;
    println!("{}", serde_json::to_string(&ping).unwrap());
    // {"kind":"Ping"}
    
    let pong = Message::Pong { timestamp: 12345 };
    println!("{}", serde_json::to_string(&pong).unwrap());
    // {"kind":"Pong","payload":{"timestamp":12345}}
}

Choose field names that fit your JSON schema.

Comparison with Untagged

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Value {
    Int(i32),
    String(String),
}
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum TaggedValue {
    Int(i32),
    String(String),
}
 
fn main() {
    // Untagged: no wrapper, inferred from content
    let untagged_int = Value::Int(42);
    println!("{}", serde_json::to_string(&untagged_int).unwrap());
    // 42
    
    // Tagged: explicit type field
    let tagged_int = TaggedValue::Int(42);
    println!("{}", serde_json::to_string(&tagged_int).unwrap());
    // {"type":"Int","data":42}
}

Untagged enums have no type field; content is serialized directly.

Default for Unit Variants with Adjacent Tagging

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Status {
    #[serde(skip_serializing)]
    Unknown,  // Unit variant
    Ready,
    Error(String),
}
 
fn main() {
    // Unit variants with adjacent tagging: data field is null or absent
    let ready = Status::Ready;
    println!("{}", serde_json::to_string(&ready).unwrap());
    // {"type":"Ready"}
}

Unit variants with adjacent tagging don't include a content field.

Tuple Variants with Adjacent Tagging

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Tuple {
    Point(i32, i32),
    Named { x: i32, y: i32 },
}
 
fn main() {
    let point = Tuple::Point(10, 20);
    println!("{}", serde_json::to_string(&point).unwrap());
    // {"type":"Point","data":[10,20]}
    
    let named = Tuple::Named { x: 10, y: 20 };
    println!("{}", serde_json::to_string(&named).unwrap());
    // {"type":"Named","data":{"x":10,"y":20}}
}

Tuple variants serialize as arrays, struct variants as objects.

Deserializing Unknown Variants

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum Known {
    A { value: i32 },
    B { value: String },
}
 
fn main() {
    // Unknown variant causes error
    let json = r#"{"type":"C","value":123}"#;
    
    let result: Result<Known, _> = serde_json::from_str(json);
    match result {
        Ok(known) => println!("{:?}", known),
        Err(e) => println!("Error: {}", e),
        // Error: unknown variant `C`, expected `A` or `B`
    }
}

Unknown variants are rejected during deserialization.

Catch-All with Untagged

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
    Request { id: u32 },
    Response { id: u32 },
}
 
// Use untagged enum as fallback
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum AnyMessage {
    Known(Message),
    Unknown(serde_json::Value),
}
 
fn main() {
    let known = r#"{"type":"Request","id":1}"#;
    let msg: AnyMessage = serde_json::from_str(known).unwrap();
    println!("{:?}", msg);
    
    let unknown = r#"{"type":"Unknown","data":"something"}"#;
    let msg: AnyMessage = serde_json::from_str(unknown).unwrap();
    println!("{:?}", msg);
}

Combine tagged with untagged for flexible parsing.

Practical Example: API Response

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "status", content = "data")]
#[serde(rename_all = "snake_case")]
enum ApiResponse<T> {
    Success(T),
    Error { code: u32, message: String },
}
 
#[derive(Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
}
 
fn main() {
    let success = ApiResponse::Success(User { 
        id: 1, 
        name: "Alice".to_string() 
    });
    println!("{}", serde_json::to_string_pretty(&success).unwrap());
    // {
    //   "status": "success",
    //   "data": {
    //     "id": 1,
    //     "name": "Alice"
    //   }
    // }
    
    let error = ApiResponse::Error::<User> { 
        code: 404, 
        message: "Not found".to_string() 
    };
    println!("{}", serde_json::to_string_pretty(&error).unwrap());
    // {
    //   "status": "error",
    //   "data": {
    //     "code": 404,
    //     "message": "Not found"
    //   }
    // }
}

Adjacently tagged enums work well for generic result types.

Flatten with Internal Tagging

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Metadata {
    timestamp: u64,
    source: String,
}
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "event_type")]
enum Event {
    #[serde(rename = "click")]
    Click { 
        #[serde(flatten)]
        meta: Metadata,
        x: i32,
        y: i32,
    },
    #[serde(rename = "scroll")]
    Scroll {
        #[serde(flatten)]
        meta: Metadata,
        direction: String,
    },
}
 
fn main() {
    let event = Event::Click {
        meta: Metadata { 
            timestamp: 12345, 
            source: "web".to_string() 
        },
        x: 100,
        y: 200,
    };
    
    println!("{}", serde_json::to_string_pretty(&event).unwrap());
    // {
    //   "event_type": "click",
    //   "timestamp": 12345,
    //   "source": "web",
    //   "x": 100,
    //   "y": 200
    // }
}

Flatten embeds struct fields at the same level as the tag.

Comparison Table

Feature Externally Tagged (default) Internally Tagged Adjacently Tagged
JSON format {"Variant": {...}} {"type":"Variant",...} {"type":"Variant","data":{...}}
Tag attribute None tag = "field" tag = "field", content = "data"
Unit variants
Tuple variants
Struct variants
Flat structure No Yes No (nested)
Use case Simple enums Struct variants only Any variant type

Synthesis

The choice between tag and tag + content depends on your data format requirements:

Internally tagged (tag = "type") produces cleaner JSON for struct variants—fields appear at the same level as the type field. This matches common JSON API conventions where type discrimination happens via a type field. However, it only works with struct variants; unit and tuple variants will cause runtime errors. Use internally tagged when all variants have named fields and you want flat JSON.

Adjacently tagged (tag = "type", content = "data") separates the variant name from variant data into two sibling fields. This works with all variant types including unit and tuple variants. The data is nested under the content field. Use adjacently tagged when you need tuple or unit variants, or when your JSON schema requires the variant data to be nested separately.

Key insight: The tag attribute fundamentally changes enum representation from the default externally tagged format. Externally tagged wraps content in the variant name as key; internally tagged embeds the variant name as a field alongside content; adjacently tagged uses two fields for variant name and content. Choose based on your JSON schema requirements and variant types. For struct-only enums matching common JSON patterns, internally tagged is cleaner. For enums with mixed variant types, adjacently tagged is necessary.