How can you use serde's #[serde(flatten)] to compose nested structures without affecting the serialized format?

The #[serde(flatten)] attribute allows you to nest structs within your data types while keeping the serialized output flat. This enables better code organization through composition without changing how data appears in JSON or other formats. The inner struct's fields are "flattened" into the parent structure during serialization.

The Problem: Nested Types, Flat Format

use serde::{Serialize, Deserialize};
 
// You want to organize related fields into a struct
#[derive(Serialize, Deserialize)]
struct User {
    // Personal info
    name: String,
    email: String,
    age: u32,
    
    // Address info
    street: String,
    city: String,
    country: String,
    
    // Settings
    notifications_enabled: bool,
    theme: String,
}
 
// JSON output is flat:
// {"name":"Alice","email":"alice@example.com","age":30,
//  "street":"123 Main St","city":"Boston","country":"USA",
//  "notifications_enabled":true,"theme":"dark"}

The fields are flat in JSON but grouped logically in code.

Attempting Nested Structs Without Flatten

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Address {
    street: String,
    city: String,
    country: String,
}
 
#[derive(Serialize, Deserialize)]
struct Settings {
    notifications_enabled: bool,
    theme: String,
}
 
#[derive(Serialize, Deserialize)]
struct User {
    name: String,
    email: String,
    age: u32,
    address: Address,    // Nested struct
    settings: Settings,  // Nested struct
}
 
fn main() {
    let user = User {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
        age: 30,
        address: Address {
            street: "123 Main St".to_string(),
            city: "Boston".to_string(),
            country: "USA".to_string(),
        },
        settings: Settings {
            notifications_enabled: true,
            theme: "dark".to_string(),
        },
    };
    
    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("{}", json);
}
 
// JSON output has nested objects:
// {
//   "name": "Alice",
//   "email": "alice@example.com",
//   "age": 30,
//   "address": {
//     "street": "123 Main St",
//     "city": "Boston",
//     "country": "USA"
//   },
//   "settings": {
//     "notifications_enabled": true,
//     "theme": "dark"
//   }
// }

Without flatten, the JSON structure mirrors the Rust structure exactly.

Using Flatten for Composition

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Address {
    street: String,
    city: String,
    country: String,
}
 
#[derive(Serialize, Deserialize)]
struct Settings {
    notifications_enabled: bool,
    theme: String,
}
 
#[derive(Serialize, Deserialize)]
struct User {
    name: String,
    email: String,
    age: u32,
    
    #[serde(flatten)]
    address: Address,
    
    #[serde(flatten)]
    settings: Settings,
}
 
fn main() {
    let user = User {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
        age: 30,
        address: Address {
            street: "123 Main St".to_string(),
            city: "Boston".to_string(),
            country: "USA".to_string(),
        },
        settings: Settings {
            notifications_enabled: true,
            theme: "dark".to_string(),
        },
    };
    
    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("{}", json);
}
 
// JSON output is flat:
// {
//   "name": "Alice",
//   "email": "alice@example.com",
//   "age": 30,
//   "street": "123 Main St",
//   "city": "Boston",
//   "country": "USA",
//   "notifications_enabled": true,
//   "theme": "dark"
// }

The nested struct fields are flattened into the parent object.

Deserialization with Flatten

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize, Debug)]
struct Metadata {
    created_at: String,
    updated_at: String,
    version: u32,
}
 
#[derive(Serialize, Deserialize, Debug)]
struct Document {
    id: String,
    title: String,
    content: String,
    
    #[serde(flatten)]
    metadata: Metadata,
}
 
fn deserialize_example() {
    let json = r#"{
        "id": "doc-123",
        "title": "My Document",
        "content": "Hello, world!",
        "created_at": "2024-01-15",
        "updated_at": "2024-01-20",
        "version": 2
    }"#;
    
    let doc: Document = serde_json::from_str(json).unwrap();
    
    println!("ID: {}", doc.id);
    println!("Title: {}", doc.title);
    println!("Created: {}", doc.metadata.created_at);
    println!("Version: {}", doc.metadata.version);
}

Deserialization extracts fields from the flat JSON into the nested struct.

Multiple Flatten Sources

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Timestamps {
    created_at: i64,
    updated_at: i64,
}
 
#[derive(Serialize, Deserialize)]
struct Ownership {
    created_by: String,
    updated_by: String,
}
 
#[derive(Serialize, Deserialize)]
struct Permissions {
    public_read: bool,
    public_write: bool,
}
 
#[derive(Serialize, Deserialize)]
struct File {
    name: String,
    size: u64,
    
    #[serde(flatten)]
    timestamps: Timestamps,
    
    #[serde(flatten)]
    ownership: Ownership,
    
    #[serde(flatten)]
    permissions: Permissions,
}
 
// All flattened fields appear at the same level in JSON

Multiple structs can be flattened into the same parent.

Partial Flattening

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
    total: u32,
}
 
#[derive(Serialize, Deserialize)]
struct ApiResponse {
    success: bool,
    message: String,
    
    #[serde(flatten)]
    pagination: Pagination,
    
    // This remains nested
    data: Vec<String>,
}
 
fn main() {
    let response = ApiResponse {
        success: true,
        message: "OK".to_string(),
        pagination: Pagination {
            page: 1,
            per_page: 10,
            total: 100,
        },
        data: vec!["item1".to_string(), "item2".to_string()],
    };
    
    let json = serde_json::to_string_pretty(&response).unwrap();
    println!("{}", json);
}
 
// JSON:
// {
//   "success": true,
//   "message": "OK",
//   "page": 1,
//   "per_page": 10,
//   "total": 100,
//   "data": ["item1", "item2"]
// }

You can mix flattened and non-flattened fields.

Enum Flattening

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum EventData {
    UserCreated { user_id: String, name: String },
    UserDeleted { user_id: String },
    FileUploaded { file_id: String, filename: String },
}
 
#[derive(Serialize, Deserialize)]
struct Event {
    timestamp: i64,
    
    #[serde(flatten)]
    data: EventData,
}
 
fn main() {
    let event = Event {
        timestamp: 1705312800,
        data: EventData::UserCreated {
            user_id: "u-123".to_string(),
            name: "Alice".to_string(),
        },
    };
    
    let json = serde_json::to_string_pretty(&event).unwrap();
    println!("{}", json);
}
 
// JSON:
// {
//   "timestamp": 1705312800,
//   "type": "UserCreated",
//   "user_id": "u-123",
//   "name": "Alice"
// }

Enums with #[serde(tag = "...")] flatten their variant fields.

Handling Optional Flattened Structs

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize, Default)]
struct OptionalFields {
    #[serde(skip_serializing_if = "Option::is_none")]
    nickname: Option<String>,
    
    #[serde(skip_serializing_if = "Option::is_none")]
    bio: Option<String>,
}
 
#[derive(Serialize, Deserialize)]
struct Profile {
    username: String,
    email: String,
    
    #[serde(flatten)]
    #[serde(skip_serializing_if = "Option::is_none")]
    optional: Option<OptionalFields>,
}
 
fn optional_flatten() {
    // Without optional fields
    let profile1 = Profile {
        username: "alice".to_string(),
        email: "alice@example.com".to_string(),
        optional: None,
    };
    let json1 = serde_json::to_string(&profile1).unwrap();
    println!("{}", json1);
    // {"username":"alice","email":"alice@example.com"}
    
    // With optional fields
    let profile2 = Profile {
        username: "bob".to_string(),
        email: "bob@example.com".to_string(),
        optional: Some(OptionalFields {
            nickname: Some("Bobby".to_string()),
            bio: None,
        }),
    };
    let json2 = serde_json::to_string(&profile2).unwrap();
    println!("{}", json2);
    // {"username":"bob","email":"bob@example.com","nickname":"Bobby"}
}

Optional flattened structs can be skipped entirely.

Default Values with Flatten

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize, Default)]
struct ConfigDefaults {
    #[serde(default)]
    timeout: u32,
    
    #[serde(default)]
    retries: u32,
    
    #[serde(default)]
    debug: bool,
}
 
#[derive(Serialize, Deserialize)]
struct Config {
    name: String,
    
    #[serde(flatten)]
    #[serde(default)]
    defaults: ConfigDefaults,
}
 
fn default_flatten() {
    // Minimal JSON - missing fields get defaults
    let json = r#"{"name":"my-service"}"#;
    
    let config: Config = serde_json::from_str(json).unwrap();
    
    println!("Name: {}", config.name);
    println!("Timeout: {}", config.defaults.timeout);  // 0 (default)
    println!("Retries: {}", config.defaults.retries);  // 0 (default)
    println!("Debug: {}", config.defaults.debug);      // false (default)
}

Defaults work naturally with flattened structs.

Capturing Unknown Fields

use serde::{Serialize, Deserialize};
use std::collections::HashMap;
 
#[derive(Serialize, Deserialize)]
struct KnownFields {
    id: String,
    name: String,
}
 
#[derive(Serialize, Deserialize)]
struct FlexibleRecord {
    #[serde(flatten)]
    known: KnownFields,
    
    #[serde(flatten)]
    extra: HashMap<String, serde_json::Value>,
}
 
fn capture_unknown() {
    let json = r#"{
        "id": "123",
        "name": "Alice",
        "department": "Engineering",
        "location": "Boston",
        "active": true
    }"#;
    
    let record: FlexibleRecord = serde_json::from_str(json).unwrap();
    
    println!("ID: {}", record.known.id);
    println!("Name: {}", record.known.name);
    println!("Extra: {:?}", record.extra);
    // Extra: {"department": String("Engineering"), "location": String("Boston"), "active": Bool(true)}
}

Flatten with HashMap captures unknown fields.

Field Collision Behavior

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Base {
    id: String,
    name: String,
}
 
#[derive(Serialize, Deserialize)]
struct Extended {
    #[serde(flatten)]
    base: Base,
    
    // Same field name as in Base!
    name: String,  // This shadows the flattened 'name'
}
 
// During serialization, the non-flattened field wins
// During deserialization, both get the same value

Be careful with field name collisions between flattened and direct fields.

Renaming Flattened Fields

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct InternalNames {
    internal_id: String,
    internal_name: String,
}
 
#[derive(Serialize, Deserialize)]
struct ExternalFormat {
    #[serde(flatten)]
    #[serde(rename = "prefix_")]  // Note: this doesn't work on flatten
    
    // Instead, rename in the inner struct
    internal: InternalRenamed,
}
 
#[derive(Serialize, Deserialize)]
struct InternalRenamed {
    #[serde(rename = "id")]
    internal_id: String,
    
    #[serde(rename = "name")]
    internal_name: String,
}
 
#[derive(Serialize, Deserialize)]
struct ApiRecord {
    #[serde(flatten)]
    internal: InternalRenamed,
    
    extra_field: String,
}
 
fn rename_example() {
    let record = ApiRecord {
        internal: InternalRenamed {
            internal_id: "123".to_string(),
            internal_name: "Alice".to_string(),
        },
        extra_field: "value".to_string(),
    };
    
    let json = serde_json::to_string_pretty(&record).unwrap();
    println!("{}", json);
    // {"id":"123","name":"Alice","extra_field":"value"}
}

Rename fields in the inner struct, not on the flatten attribute.

Composition Pattern for API Clients

use serde::{Serialize, Deserialize};
 
// Common pagination structure
#[derive(Serialize, Deserialize, Clone)]
struct Pagination {
    page: u32,
    per_page: u32,
    total_pages: u32,
    total_items: u32,
}
 
// Common metadata structure
#[derive(Serialize, Deserialize, Clone)]
struct Metadata {
    #[serde(skip_serializing_if = "Option::is_none")]
    etag: Option<String>,
    
    #[serde(skip_serializing_if = "Option::is_none")]
    last_modified: Option<String>,
}
 
// Reusable across multiple response types
#[derive(Serialize, Deserialize)]
struct PaginatedResponse<T> {
    items: Vec<T>,
    
    #[serde(flatten)]
    pagination: Pagination,
    
    #[serde(flatten)]
    metadata: Metadata,
}
 
#[derive(Serialize, Deserialize)]
struct User {
    id: String,
    name: String,
}
 
#[derive(Serialize, Deserialize)]
struct Product {
    id: String,
    title: String,
    price: f64,
}
 
fn api_example() {
    // User list response
    let user_response = PaginatedResponse {
        items: vec![
            User { id: "u1".to_string(), name: "Alice".to_string() },
            User { id: "u2".to_string(), name: "Bob".to_string() },
        ],
        pagination: Pagination {
            page: 1,
            per_page: 10,
            total_pages: 5,
            total_items: 48,
        },
        metadata: Metadata {
            etag: Some("abc123".to_string()),
            last_modified: None,
        },
    };
    
    let json = serde_json::to_string_pretty(&user_response).unwrap();
    println!("{}", json);
}
 
// JSON is flat:
// {
//   "items": [{"id": "u1", "name": "Alice"}, {"id": "u2", "name": "Bob"}],
//   "page": 1,
//   "per_page": 10,
//   "total_pages": 5,
//   "total_items": 48,
//   "etag": "abc123"
// }

Flatten enables reusable components for API responses.

Nested Flattening

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Base {
    id: String,
}
 
#[derive(Serialize, Deserialize)]
struct Middle {
    #[serde(flatten)]
    base: Base,
    
    value: i32,
}
 
#[derive(Serialize, Deserialize)]
struct Top {
    #[serde(flatten)]
    middle: Middle,
    
    extra: String,
}
 
fn nested_flatten() {
    let top = Top {
        middle: Middle {
            base: Base { id: "123".to_string() },
            value: 42,
        },
        extra: "hello".to_string(),
    };
    
    let json = serde_json::to_string(&top).unwrap();
    println!("{}", json);
    // {"id":"123","value":42,"extra":"hello"}
}

Flattening works recursively through nested structures.

Deserialization Error Handling

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize, Debug)]
struct Required {
    field1: String,
    field2: i32,
}
 
#[derive(Serialize, Deserialize, Debug)]
struct Container {
    name: String,
    
    #[serde(flatten)]
    required: Required,
}
 
fn error_handling() {
    // Missing flattened fields
    let json = r#"{"name":"test"}"#;
    
    match serde_json::from_str::<Container>(json) {
        Ok(container) => println!("Parsed: {:?}", container),
        Err(e) => println!("Error: {}", e),
        // Error: missing field `field1` at line 1 column 15
    }
    
    // All fields present
    let json = r#"{"name":"test","field1":"value","field2":42}"#;
    
    match serde_json::from_str::<Container>(json) {
        Ok(container) => println!("Parsed: {:?}", container),
        Err(e) => println!("Error: {}", e),
        // Parsed: Container { name: "test", required: Required { field1: "value", field2: 42 } }
    }
}

Error messages reference the flattened field names directly.

Synthesis

#[serde(flatten)] enables composition without changing serialization format:

Key benefits:

Benefit Description
Code organization Group related fields in structs
Reuse Share common structures across types
API compatibility Keep JSON flat for external APIs
Backward compatibility Add structure without breaking format

Serialization behavior:

// Rust structure (nested):
Container {
    name: "test",
    metadata: Metadata { created: 123, updated: 456 }
}
 
// JSON (flat):
{"name":"test","created":123,"updated":456}

Common patterns:

// Pattern 1: Shared metadata
struct Record {
    id: String,
    #[serde(flatten)]
    metadata: Metadata,  // created_at, updated_at, etc.
}
 
// Pattern 2: Extensible types
struct Dynamic {
    #[serde(flatten)]
    known: KnownFields,
    #[serde(flatten)]
    extra: HashMap<String, Value>,
}
 
// Pattern 3: Optional groups
struct Profile {
    username: String,
    #[serde(flatten)]
    #[serde(skip_serializing_if = "Option::is_none")]
    details: Option<ProfileDetails>,
}
 
// Pattern 4: Generic wrappers
struct ApiResponse<T> {
    data: T,
    #[serde(flatten)]
    pagination: Pagination,
}

Considerations:

  1. Field name collisions produce unexpected behavior
  2. Error messages use the flattened field names
  3. Works with most serde features (rename, skip, default)
  4. Can be combined with Option for optional groups
  5. Enum variants with tag flatten nicely

#[serde(flatten)] is essential for maintaining clean Rust code structure while working with flat JSON APIs.