How to deserialize nested JSON

Use serde with derive macros and rename attributes to map nested JSON keys to Rust structs automatically.

When JSON has depth

You are building a dashboard that pulls data from a weather API. The response isn't a flat list of values. It is a tree. There is a location object containing a coordinates object, which holds lat and lon. You need that latitude to center the map. You could write a regex to scrape the string, or you could use serde to turn that JSON tree into a Rust struct that mirrors the shape exactly.

Deserializing nested JSON is about matching the hierarchy of the data to the hierarchy of your types. serde handles the recursion for you. You define the shape, and the library walks the JSON structure, descending into objects and arrays, filling your fields as it goes.

The nesting model

Think of deserialization like unpacking a set of nested boxes. You have the outer box labeled "Response". Inside, there is a smaller box labeled "Location". Inside that, a tiny box labeled "Coordinates". serde opens the outer box, sees the label, finds the matching field in your struct, and hands the inner box to the deserializer for that field. That inner deserializer repeats the process.

Your Rust code defines the boxes. A struct field that is itself a struct tells serde to expect a nested object in the JSON. A field that is a Vec<T> tells serde to expect an array. The nesting in your types must match the nesting in the JSON, or the deserialization fails.

use serde::Deserialize;

/// Represents the geographic coordinates from the API.
#[derive(Deserialize, Debug)]
struct Coordinates {
    /// Latitude in degrees.
    lat: f64,
    /// Longitude in degrees.
    lon: f64,
}

/// The top-level response containing location data.
#[derive(Deserialize, Debug)]
struct WeatherResponse {
    /// The API nests coordinates inside this object.
    location: Coordinates,
}

fn main() {
    let json = r#"
    {
        "location": {
            "lat": 40.7128,
            "lon": -74.0060
        }
    }
    "#;

    // Deserialize the string into the nested struct hierarchy.
    let data: WeatherResponse = serde_json::from_str(json).expect("Valid JSON");
    
    println!("Lat: {}, Lon: {}", data.location.lat, data.location.lon);
}

The #[derive(Deserialize)] macro generates the code that performs this matching. It creates an implementation of the Deserialize trait for each struct. When serde_json::from_str runs, it invokes the Deserialize impl for WeatherResponse. That impl asks for the next key, finds "location", and then invokes the Deserialize impl for Coordinates. The recursion stops when the closing brace is reached.

Trust the derive macro. It generates the boilerplate so you don't have to write the visitor logic by hand.

Realistic shapes and conventions

Real-world APIs rarely use perfect Rust naming. They use kebab-case, camelCase, or snake_case. They also include optional fields that might be missing. serde provides attributes to handle these mismatches without changing your Rust code.

The community convention is to use #[serde(rename_all = "...")] at the struct level rather than renaming every field individually. This keeps the code clean and ensures consistency. If one field breaks the pattern, use #[serde(rename = "...")] on that specific field.

use serde::Deserialize;

/// Configuration for a preprocessor tool.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct PreprocessorConfig {
    /// Maps "output-mode" in JSON to "output_mode" in Rust.
    output_mode: Mode,
    
    /// If "retry-count" is missing, this becomes None.
    /// Use Option for fields that the API might omit.
    retry_count: Option<u32>,
    
    /// If "timeout" is missing, serde uses the default value.
    /// The default attribute requires the Default trait.
    #[serde(default)]
    timeout_ms: u64,
}

#[derive(Deserialize, Debug)]
enum Mode {
    Fast,
    Thorough,
}

fn main() {
    let json = r#"
    {
        "output-mode": "fast",
        "retry-count": 3
    }
    "#;

    let config: PreprocessorConfig = serde_json::from_str(json).expect("Valid config");
    
    // retry_count is Some(3).
    // timeout_ms is 0 because Default::default() for u64 is 0.
    println!("{:?}", config);
}

Notice the Option<u32> for retry_count. If the JSON key is absent, serde sets the field to None. If the key is present with a value, it becomes Some(value). This is the standard way to handle optional data. If you use a non-optional type and the key is missing, deserialization fails with an error.

Make fields Option<T> if the API documentation says "sometimes present". It prevents runtime crashes on missing data.

What happens under the hood

When you call serde_json::from_str, the process splits into two phases. First, the JSON string is tokenized. The parser scans the text and produces a stream of tokens: StartObject, Key("location"), Colon, StartObject, Key("lat"), Value(f64), etc.

Second, the deserializer consumes these tokens. The Deserialize implementation for your struct acts as a visitor. It requests the next token, checks if it matches the expected field, and stores the value. If the tokens don't match the struct definition, the deserializer returns an error.

This separation allows serde to work with any data format. The same Deserialize impl works for JSON, YAML, TOML, or binary formats. The format-specific crate handles tokenization, while serde handles the mapping to Rust types.

Convention aside: always add features = ["derive"] to your serde dependency in Cargo.toml. Without this feature, the #[derive(Deserialize)] macro is not available. If you forget it, the compiler rejects you with an error that #[derive(Deserialize)] is not found. This is a common stumbling block for beginners.

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Pitfalls and compiler errors

Nested deserialization introduces a few specific failure modes. Understanding these saves debugging time.

If you forget the derive macro on a nested struct, the compiler stops you immediately. The outer struct tries to call Deserialize on the inner type, but the trait is not implemented. The compiler rejects you with E0277 (the trait bound Deserialize is not satisfied). Add #[derive(Deserialize)] to every struct in the hierarchy.

If the JSON contains a string where you expect a number, deserialization fails at runtime. serde_json::from_str returns a Result. The error message points to the exact field and byte offset. For example, "invalid type: string "fast", expected u32 at line 2 column 15". This precision makes debugging fast.

If you use rename_all but the JSON key casing doesn't match the variant, deserialization fails. The rename_all attribute applies to field names, not enum variants. For enums, use #[serde(rename_all = "...")] on the enum definition itself.

use serde::Deserialize;

#[derive(Deserialize, Debug)]
#[serde(rename_all = "SCREAMING-KEBAB-CASE")]
enum Status {
    Active,
    Inactive,
}

fn main() {
    // This works: "ACTIVE" maps to Status::Active.
    let json = r#"{"status": "ACTIVE"}"#;
    let status: Status = serde_json::from_str(json).expect("Valid");
    println!("{:?}", status);
}

If you try to deserialize into a struct with a field that doesn't exist in the JSON, and that field is not Option or #[serde(default)], the deserialization fails. The error message says "missing field name at line 1 column 1". This is a safety feature. It forces you to decide how to handle missing data explicitly.

Don't ignore missing fields. Use Option or default to declare your intent. The compiler won't catch missing JSON keys, but serde will catch them at runtime with a clear error.

Advanced nesting patterns

Sometimes the JSON structure doesn't map cleanly to a single struct. You might need to flatten nested objects or handle dynamic keys.

The #[serde(flatten)] attribute merges the fields of a nested struct into the parent object. This is useful when the JSON has a flat structure but you want to group related fields in Rust.

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct User {
    id: u64,
    // Flatten merges address fields directly into User.
    #[serde(flatten)]
    address: Address,
}

#[derive(Deserialize, Debug)]
struct Address {
    city: String,
    zip: String,
}

fn main() {
    // JSON is flat, but Rust groups address fields.
    let json = r#"{"id": 1, "city": "Seattle", "zip": "98101"}"#;
    let user: User = serde_json::from_str(json).expect("Valid");
    println!("{:?}", user.address.city);
}

For deeply nested paths where you only need one value, deserializing the entire tree can be wasteful. In those cases, use serde_json::Value to probe the structure dynamically. Value is an enum that represents any JSON value. You can access nested fields using indexing or helper methods.

use serde_json::Value;

fn main() {
    let json = r#"
    {
        "users": [
            {"name": "Alice", "score": 95},
            {"name": "Bob", "score": 82}
        ]
    }
    "#;

    let value: Value = serde_json::from_str(json).expect("Valid");
    
    // Access nested array and object fields dynamically.
    if let Some(scores) = value["users"].as_array() {
        for user in scores {
            if let Some(name) = user["name"].as_str() {
                println!("User: {}", name);
            }
        }
    }
}

Use Value when the structure varies wildly or you only need to extract a few fields. It avoids defining structs for data you don't care about. However, you lose compile-time safety. Typos in keys result in None or runtime errors instead of compiler errors.

Decision: choosing the right approach

Picking the right deserialization strategy depends on the stability of the JSON and how you use the data.

Use typed structs with #[derive(Deserialize)] when the JSON shape is stable and you access most fields. This gives you compile-time safety, IDE autocomplete, and clear error messages. It is the default choice for most APIs.

Use serde_json::Value when the JSON structure changes frequently or you only need to probe a few keys. This avoids maintaining structs for data you don't use. It is useful for debugging, logging, or handling heterogeneous responses.

Use HashMap<String, Value> when the JSON contains dynamic keys that are not known at compile time. For example, a configuration object where keys are user-defined plugin names. Structs require known field names, so a map is necessary for dynamic keys.

Use #[serde(flatten)] when you want to group related fields in Rust but the JSON is flat. This keeps your types organized without requiring nested objects in the data.

Use #[serde(alias)] when the API might send the same data under different key names. This makes your code robust against API changes without breaking deserialization.

Trust the borrow checker and the type system. If you can define a struct, do it. The safety and clarity are worth the upfront effort.

Where to go next