How to Use #[serde(flatten)] for Nested Structures

Use #[serde(flatten)] to merge nested struct fields directly into the parent struct's JSON output.

When the wire format disagrees with your types

You are building a user profile endpoint. The database stores addresses in a separate table, so your Rust struct naturally nests an Address inside User. The frontend team sends a complaint: their form expects city and zip at the top level, not buried inside an address object. You could write a manual serialization function to unpack the fields, but that duplicates logic and breaks if you add a new field. Serde gives you a single attribute to flatten nested structures into the parent scope.

#[serde(flatten)] tells Serde to merge the fields of a nested struct directly into the parent's serialized output. The nested structure disappears in the JSON, but your Rust types stay clean and modular.

How flattening works

Think of a nested struct like a box inside a box. Standard serialization writes the outer box, then writes the inner box as a value. Flattening is like taking the contents of the inner box and spreading them out on the table next to the outer box's contents. The structure vanishes; only the data remains.

At the Rust level, the types remain distinct. You still have a User containing an Address. The flattening happens only during serialization and deserialization. Serde generates code that treats the fields of the nested type as if they were declared directly on the parent. This keeps your domain model organized while satisfying external format requirements.

Minimal example

The attribute goes on the field you want to flatten. The field can be any type that implements Serialize and Deserialize.

use serde::{Deserialize, Serialize};

/// Represents a physical location with city and zip code.
#[derive(Serialize, Deserialize)]
struct Address {
    city: String,
    zip: String,
}

/// User profile that merges address fields into the root JSON.
#[derive(Serialize, Deserialize)]
struct User {
    name: String,
    // Flatten merges Address fields directly into User's JSON map.
    #[serde(flatten)]
    address: Address,
}

fn main() {
    let user = User {
        name: "Alice".to_string(),
        address: Address {
            city: "Seattle".to_string(),
            zip: "98101".to_string(),
        },
    };

    // Serialize to pretty JSON to inspect the flat structure.
    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("{}", json);
}

This code produces:

{
  "name": "Alice",
  "city": "Seattle",
  "zip": "98101"
}

The address key is gone. The city and zip keys sit alongside name. The JSON is flat, but the Rust types stay clean.

Serialization and deserialization flow

Understanding the flow helps when debugging. Serde generates different code depending on whether you are writing or reading.

During serialization, the generated serialize method for User creates a map. It inserts "name" with the user's name. Then it serializes the Address field. Normally, this would insert "address" with a nested map. With flatten, the generated code iterates over the fields of Address and inserts each one directly into the parent map. The result is a single flat map.

Deserialization works in reverse. Serde parses the JSON map and looks for keys. It extracts "name" for the User struct. For the flattened Address, Serde consumes all remaining fields that match Address's schema and uses them to reconstruct the Address struct. If the JSON contains extra fields that don't match User or Address, Serde ignores them by default. This behavior makes flattening safe against unknown fields.

You can also flatten an Option<T>. If the option is None, no fields are added to the output. This lets you make entire groups of fields optional without changing the JSON shape. If the option is Some, the fields appear as if they were always there.

Realistic example: Config composition

A common pattern is configuration inheritance. You have a base config with shared settings and an app-specific config with extra fields. Flattening lets you compose these structs without duplicating fields.

Another powerful pattern is capturing unknown fields. By flattening a HashMap, you can collect any keys that don't match known fields. This is essential for forward compatibility.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Shared configuration options for all services.
#[derive(Serialize, Deserialize)]
struct BaseConfig {
    log_level: String,
    timeout_ms: u32,
}

/// Application-specific config that inherits base settings.
#[derive(Serialize, Deserialize)]
struct AppConfig {
    app_name: String,
    // Flatten allows BaseConfig fields to sit alongside app_name.
    #[serde(flatten)]
    base: BaseConfig,
    // Capture any extra fields that don't match known keys.
    #[serde(flatten)]
    extra: HashMap<String, serde_json::Value>,
}

fn main() {
    let json = r#"{
        "app_name": "MyService",
        "log_level": "debug",
        "timeout_ms": 5000,
        "custom_feature": true
    }"#;

    // Deserialize handles both the flattened struct and the map.
    let config: AppConfig = serde_json::from_str(json).unwrap();
    println!("App: {}", config.app_name);
    println!("Log: {}", config.base.log_level);
    println!("Extra: {:?}", config.extra);
}

The extra map captures "custom_feature". When you serialize AppConfig back to JSON, the map's entries are merged back into the output. The round-trip preserves data that your struct doesn't explicitly know about.

The community often pairs #[serde(flatten)] with a HashMap<String, Value> to capture unknown fields. This makes your API resilient to new fields added by the server without breaking the client. Name the map field something descriptive like extra or metadata so readers know its purpose.

Pitfalls: Collisions and missing fields

Flattening introduces specific failure modes. The most common is field name collision.

If the parent struct and the flattened child share a field name, Serde refuses to compile. The derive macro checks for overlapping keys at compile time. You will see an error like duplicate field "city" in struct "User". The compiler cannot decide which struct owns the key. Rename one of the fields or remove the overlap. Serde won't guess your intent.

Missing fields interact with flattening in a subtle way. If the JSON is missing a field required by the flattened struct, deserialization fails. You can fix this by adding #[serde(default)] to the flattened field. This tells Serde to use the default value for the entire struct if keys are missing. Alternatively, add #[serde(default)] to specific fields inside the flattened struct. The attribute applies to the field it decorates, so placing it on the flattened field affects the whole nested type.

Flattening does not work with sequences. You cannot flatten a Vec<T>. The attribute requires a type that serializes to a map-like structure. Attempting to flatten a sequence results in a compile-time error.

When to flatten

Choose the right tool based on the mismatch between your types and the external format.

Use #[serde(flatten)] when the external format requires fields to be at the root level, but your internal model groups them logically for maintainability.

Use #[serde(flatten)] with a HashMap when you need to capture unknown fields for forward compatibility or pass-through data that your application doesn't process.

Use #[serde(flatten)] on an Option<T> when you need to make an entire group of fields optional without introducing nested objects in the JSON.

Use standard nesting without flatten when the external format matches your internal structure, or when the nesting provides semantic clarity that the consumer expects.

Use #[serde(rename)] when you only need to change a field name, not merge structures.

Use manual Serialize and Deserialize implementations when you need complex logic that attributes can't express, such as conditional flattening based on runtime values or transforming data during the merge.

Flatten for the wire, nest for the code.

Where to go next