How to Serialize and Deserialize JSON in Rust with Serde

Serialize and deserialize JSON in Rust by deriving serde traits on your structs and using serde_json functions.

When manual parsing breaks down

You just finished building a CLI tool that reads a configuration file. The file is JSON. You write a parser that splits strings by commas, trims whitespace, and matches keys. It works until the user adds a trailing comma, switches to a different key order, or wraps a number in quotes. Suddenly your tool crashes on valid input. Writing manual parsers for structured data is a trap. Rust avoids the trap by handing the heavy lifting to a compile-time framework called Serde.

How Serde actually works

Serde stands for Serialization and Deserialization. Serialization turns your Rust structs into bytes or text. Deserialization does the reverse. Instead of shipping a giant runtime library that inspects your data and guesses how to convert it, Serde works at compile time. Think of it like a custom stamping press. You hand the compiler a blueprint of your struct. The compiler reads the blueprint, stamps out the exact conversion code for that specific shape, and welds it directly into your binary. The result is zero runtime reflection overhead and type safety that catches mismatches before your program ever runs.

The framework splits into two pieces. The serde crate defines the Serialize and Deserialize traits and provides the derive macros. Format-specific crates like serde_json, serde_yaml, or bincode implement the actual encoding and decoding logic. You can swap the format crate without changing your data structures. The traits act as a universal adapter.

Trust the trait system here. If your type implements the traits, any Serde-compatible format can read or write it.

The minimal setup

You need two crates to get started. serde provides the traits and macros. serde_json provides the JSON-specific implementation. Add them to your Cargo.toml:

[dependencies]
# The derive feature enables #[derive(Serialize, Deserialize)]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

The derive feature is mandatory. Without it, you have to write the conversion logic by hand. Once the dependencies are in place, you attach two attributes to your struct:

use serde::{Deserialize, Serialize};

/// Represents the application configuration loaded from a JSON file.
#[derive(Debug, Serialize, Deserialize)]
pub struct AppConfig {
    port: u16,
    debug_mode: bool,
}

fn main() {
    let config = AppConfig { port: 8080, debug_mode: true };

    // Serialize the struct into a JSON string.
    // We pass a reference because serialization only needs to read the data.
    let json_string = serde_json::to_string(&config).unwrap();
    println!("{}", json_string);

    // Deserialize the JSON string back into the struct.
    // The type annotation tells the compiler which Deserialize impl to use.
    let parsed: AppConfig = serde_json::from_str(&json_string).unwrap();
    println!("{:?}", parsed);
}

Run this and you get {"port":8080,"debug_mode":true} followed by the debug output. The compiler generated the Serialize and Deserialize trait implementations for AppConfig automatically.

Keep the derive macros on the type definition, not on the function. The compiler needs them at the struct level to generate the implementation.

What happens under the hood

When you compile that code, the #[derive(Serialize)] macro expands into a impl Serialize for AppConfig block. That implementation knows exactly how many fields your struct has and what their types are. It writes code that calls serializer.serialize_u16() for the port and serializer.serialize_bool() for the flag. Deserialization works in reverse. The from_str function reads the JSON character by character, matches the keys, and calls the generated Deserialize implementation to populate your struct.

The compiler enforces strict type matching. If the JSON contains a string where a number is expected, the deserializer returns an error instead of panicking. The type system guarantees that parsed is a fully initialized AppConfig or the function returns early with a Result::Err. No partial structs leak into your codebase.

Memory allocation follows a predictable pattern. to_string allocates a String on the heap to hold the serialized output. from_str allocates the target struct and any nested collections. If you are processing megabytes of JSON in a tight loop, you will want to reuse buffers or stream directly to a writer. The default functions prioritize convenience over raw throughput.

Profile before optimizing. The default string allocation is fast enough for 99 percent of configuration and API payloads.

Handling real-world data

Real configuration files rarely match Rust's snake_case defaults perfectly. APIs often use camelCase. You solve naming mismatches with the rename_all attribute. You also need to handle missing fields gracefully instead of crashing on incomplete input.

use serde::{Deserialize, Serialize};

/// Configuration for a web service, matching external API conventions.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceConfig {
    /// The network port to bind to.
    port: u16,
    /// Enables verbose logging. Defaults to false if omitted.
    #[serde(default)]
    enable_debug: bool,
    /// Optional list of allowed hostnames.
    #[serde(default)]
    allowed_hosts: Vec<String>,
}

fn load_config(json_input: &str) -> Result<ServiceConfig, serde_json::Error> {
    // Return the Result directly to let the caller decide how to handle failures.
    serde_json::from_str(json_input)
}

The #[serde(default)] attribute tells the deserializer to call Default::default() for that field if the key is missing from the JSON. bool defaults to false, Vec defaults to an empty collection. This turns a hard failure into a graceful fallback. The community convention is to use #[serde(default)] for optional configuration values rather than wrapping every field in Option<T>. Wrapping in Option changes the type signature and forces callers to unwrap everywhere. Using default keeps the API clean while still handling missing data.

Sometimes you do not know the shape of the JSON ahead of time. Maybe you are building a proxy that forwards arbitrary payloads, or a plugin system that reads user-defined schemas. In those cases, you deserialize into serde_json::Value. This type represents a generic JSON value at runtime. It supports Object, Array, Number, String, Bool, and Null. You access fields using indexing or the get method, which returns Option<&Value>.

use serde_json::Value;

fn extract_version(payload: &Value) -> Option<&str> {
    // Navigate the dynamic structure safely without panicking on missing keys.
    payload.get("metadata")?.get("version").as_str()
}

Dynamic parsing trades compile-time guarantees for flexibility. You lose the safety of the borrow checker and type system at the cost of runtime checks. Use it only when the schema is truly unknown.

Stick to strongly typed structs whenever the schema is fixed. The compiler will catch typos in field names before you ship.

Common pitfalls and compiler signals

The most common stumbling block is forgetting the derive feature in Cargo.toml. If you add serde = "1" without features = ["derive"], the compiler cannot find the macro and rejects your code. Always include the feature flag.

Another frequent error involves borrowing. serde_json::to_string expects a reference to the data you want to serialize. If you pass the value directly, the compiler complains about moving a value into a function that only needs to read it. The signature requires &T. If you accidentally write to_string(config) instead of to_string(&config), you get a type mismatch error. The borrow checker is protecting you from unnecessary allocations.

Deserialization errors are runtime events, not compile-time ones. serde_json::from_str returns a Result<T, serde_json::Error>. Using .unwrap() on untrusted input will panic your program. In production code, you propagate the error using the ? operator or match on the Result. The error type implements std::error::Error, so it plays nicely with the standard ecosystem. If you try to deserialize into a type that does not implement Deserialize, the compiler stops you with E0277 (trait bound not satisfied). The fix is always the same: add #[derive(Deserialize)] to the target struct or enum.

Enums require special attention. Rust enums map to JSON in multiple ways. The default representation uses the variant name as a string. If you need to serialize a tuple enum or a struct enum, you must add #[serde(tag = "type", content = "data")] or similar attributes to control the layout. Without explicit tagging, complex enums will fail to deserialize or produce unexpected JSON shapes.

Read the error message carefully. The compiler usually points to the exact field or variant causing the mismatch.

Choosing the right tool

Use serde_json::from_str when you are parsing JSON from a string slice or a String. Use serde_json::from_slice when you are reading raw bytes from a file or network socket to avoid an intermediate string allocation. Use serde_json::to_string when you need a JSON string in memory for logging or transmission. Use serde_json::to_writer when you are streaming JSON directly to a file, socket, or HTTP response body. Use #[serde(rename_all = "camelCase")] when your external data source uses different naming conventions than Rust. Use #[serde(default)] when missing keys should fall back to sensible defaults instead of failing. Use Option<T> when the absence of a key is semantically different from a default value and you need to track whether the user explicitly provided it. Reach for serde_json::Value only when the JSON schema is unknown at compile time and you must navigate it dynamically. Reach for manual parsing only when you are dealing with a non-standard format that Serde cannot handle, or when you are building a custom serialization framework yourself.

Pick the path that matches your data shape. The rest of the ecosystem expects Serde traits.

Where to go next