How to use serde_json crate in Rust JSON

Add serde_json to Cargo.toml and use serde_json::to_writer to serialize Rust data to JSON output.

The JSON bridge in Rust

You are building a command-line tool that needs to export user preferences. Or maybe you are writing a microservice that returns API responses. In both cases, you need to turn Rust structs into JSON strings and back again. The standard library does not include JSON support. You need a crate. The ecosystem standard is serde_json.

JSON is a loose, text-based format. Rust is strict and type-safe. serde_json sits between them as a translator. Think of it like a customs agent at a border crossing. The agent checks every item in your luggage against a manifest. If the manifest says you are carrying a laptop but the agent finds a toaster, the shipment gets rejected. serde_json does the same with data. It validates structure, converts types, and escapes characters. It refuses to guess. If the JSON does not match your Rust type, you get a compile-time or runtime error, not silently corrupted data.

The name breaks down into two parts. serde stands for serialization and deserialization. json specifies the format. The crate relies on the serde framework, which generates the translation code for you. You do not write manual parsers. You mark your types, and the compiler builds the bridge. Trust the borrow checker and the type system here. They catch structural mismatches before your program ever runs.

The minimal setup

Add the crate to your Cargo.toml. You need both serde and serde_json. The serde crate provides the derive macros. The serde_json crate provides the JSON-specific functions.

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

The derive feature is mandatory. Without it, #[derive(Serialize, Deserialize)] will not exist, and the compiler will complain about missing trait implementations. This is a community convention that catches beginners off guard. The serde crate is split into a core library and a separate derive crate to keep compile times low. You must opt into the derive feature explicitly.

Here is the smallest working example. It defines a struct, marks it for serialization, and prints it to standard output.

use serde::{Serialize, Deserialize};

/// Configuration struct for a simple CLI tool
#[derive(Serialize, Deserialize, Debug)]
struct Config {
    name: String,
    debug_mode: bool,
    max_retries: u32,
}

/// Demonstrates basic serialization and deserialization
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = Config {
        name: "my-app".to_string(),
        debug_mode: true,
        max_retries: 3,
    };

    // Serialize to a pretty-printed string for human reading
    // Allocates a new String with indentation and newlines
    let json_string = serde_json::to_string_pretty(&config)?;
    println!("{}", json_string);

    // Deserialize back from the string
    // Validates structure and reconstructs the Config instance
    let restored: Config = serde_json::from_str(&json_string)?;
    println!("{:?}", restored);

    Ok(())
}

When you run this, the compiler expands the #[derive] macros. It generates impl Serialize for Config and impl Deserialize for Config behind the scenes. The generated code walks through each field, calls the appropriate serialization method, and writes the result to a buffer. to_string_pretty allocates a String, formats it with indentation, and returns it. from_str reads the text, validates the structure, and constructs a new Config instance. If any field is missing or has the wrong type, from_str returns an error instead of panicking.

The ? operator propagates errors up to main. Since main returns a Result, the program exits cleanly with an error message if something goes wrong. This is the standard Rust pattern for I/O and parsing. You handle failures explicitly. You do not rely on exceptions or silent failures. Keep your error handling close to the boundary where data enters or leaves your system.

Working with real data

Real applications rarely deal with perfect JSON. APIs return nested objects. Configuration files contain optional fields. Sometimes you receive data that does not match your exact struct. serde_json handles these cases with attributes and dynamic types.

Consider a user profile coming from a third-party API. The API might omit the bio field. It might send age as a string instead of a number. You can tell serde how to handle these mismatches.

use serde::{Serialize, Deserialize};

/// User profile fetched from an external API
#[derive(Serialize, Deserialize, Debug)]
struct UserProfile {
    id: u64,
    username: String,
    // Default to an empty string if the field is missing in the JSON
    #[serde(default)]
    bio: String,
    // Rename the JSON key to match the API's snake_case convention
    #[serde(rename = "is_active")]
    active: bool,
}

/// Demonstrates handling missing fields and key renaming
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let json_input = r#"
    {
        "id": 42,
        "username": "rustacean",
        "is_active": true
    }
    "#;

    // Parse the JSON string into the typed struct
    // Fails fast if required fields are missing or malformed
    let user: UserProfile = serde_json::from_str(json_input)?;
    println!("{:?}", user);
    Ok(())
}

The #[serde(default)] attribute tells the deserializer to use String::default() when the key is absent. The #[serde(rename = "...")] attribute maps a JSON key to a different Rust field name. These attributes compile away. They only affect the generated serialization logic. Your runtime code stays clean. A common convention is to use #[serde(rename_all = "camelCase")] at the struct level when working with JavaScript APIs. It saves you from annotating every single field.

When you truly do not know the structure ahead of time, use serde_json::Value. It is an enum that represents any valid JSON value. It is useful for middleware, logging, or generic data pipelines.

use serde_json::Value;

/// Demonstrates dynamic JSON parsing with serde_json::Value
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let json = r#"{"status": "ok", "data": [1, 2, 3]}"#;
    
    // Parse into a generic Value tree instead of a typed struct
    let parsed: Value = serde_json::from_str(json)?;

    // Access nested data safely using helper methods
    // Returns Option<&Value>, so you can chain checks without panicking
    if let Some(status) = parsed.get("status").and_then(Value::as_str) {
        println!("Status: {}", status);
    }

    Ok(())
}

Value allocates for every node. It is slower than typed deserialization. Use it only when the schema is dynamic or when you are passing data through without inspecting it deeply. Treat Value as a temporary staging area, not a long-term data structure.

Common pitfalls and compiler signals

The most frequent mistake is forgetting the derive feature in Cargo.toml. The compiler will reject your code with E0277 (trait bound not satisfied) when you try to call to_string on your struct. The error message points to the missing Serialize implementation. Adding features = ["derive"] to the serde dependency fixes it immediately.

Another trap is type mismatch during deserialization. JSON numbers are floating point by default in many parsers, but serde_json tries to preserve integer precision. If your JSON contains 10.0 and your struct expects u32, deserialization fails. The error message explicitly states which field failed and why. Fix it by adjusting the Rust type to match the data, or by writing a custom deserializer function. You will also encounter E0308 (mismatched types) if you try to assign a serde_json::Result to a plain type without unwrapping or propagating it.

Memory allocation is another consideration. to_string and from_str allocate heap memory for the entire JSON payload. If you are processing megabytes of data, this creates pressure on the allocator and increases latency. The crate provides streaming APIs for large payloads. You parse or serialize chunk by chunk, keeping memory usage flat. The serde_json::Deserializer and serde_json::Serializer types accept any std::io::Read or std::io::Write implementation. Pipe them directly to files or network sockets.

You will also run into trouble when serializing types that do not implement Serialize. Standard library types like PathBuf or Duration do not implement it by default. You need to convert them to String or u64 first, or use a crate like serde_with that provides blanket implementations for common types. Do not fight the trait bounds. Convert the data to a serializable format before it crosses the boundary.

Choosing the right tool

Pick serde_json when you need to exchange data with web APIs, configuration files, or message queues that use JSON. Pick serde_yaml when your configuration files require comments, multi-line strings, or human-friendly formatting. Pick serde_cbor or bincode when you are communicating between trusted services and want smaller payloads with faster parsing. Pick manual string parsing only when you are dealing with a highly constrained, non-standard format that breaks on every edge case.

Use serde_json::to_string for quick debugging and small payloads. Use serde_json::to_writer when you are writing directly to a file, socket, or HTTP response body to avoid intermediate string allocation. Use serde_json::from_value when you already have a parsed Value tree and need to extract a specific typed struct from it. Use serde_json::Value when the schema changes frequently or when you are building a generic data pipeline.

Keep your serialization boundaries thin. Convert external data into internal types as soon as it enters your program. Never pass raw JSON strings through your business logic.

Where to go next