How to use serde crate in Rust serialization

Use the serde crate with the derive feature to automatically serialize and deserialize Rust structs to JSON and other formats.

When you need to save state

You're writing a CLI tool that needs to remember your preferences between runs. You define a Config struct with a name and a port. Now you need to save that struct to a JSON file so the user doesn't have to re-type everything next time. Writing a JSON parser by hand is tedious and full of edge cases. Serde solves this by generating the serialization and deserialization code for you at compile time.

Serde generates the translation code at compile time, so you get automatic serialization with zero runtime overhead.

Serialization without reflection

Serde stands for "serialization-deserialization." Think of it as a universal translator for your data structures. You have a Rust struct in memory. You want to send it over a network, save it to a file, or pass it to a database. The network speaks JSON, the file speaks TOML, the database speaks binary. Serde generates the code that translates your Rust struct into whatever format you need, and back again.

In Python, serialization often relies on reflection: the library inspects the object at runtime to find its attributes. Rust avoids reflection. The derive macros generate static code during compilation. This means no runtime overhead for introspection, no dynamic dispatch, and the compiler can inline everything. The generated code is essentially a specialized function for your specific struct. You get the convenience of automatic serialization with the performance of hand-optimized code.

Rust trades runtime flexibility for compile-time guarantees. The generated code is as fast as hand-written serialization.

Minimal setup

Add serde with the derive feature to your Cargo.toml. The derive feature enables the macros that generate the implementation. Add serde_json to handle JSON specifically. Serde splits concerns: the serde crate defines the traits, and serde_json implements those traits for JSON. You can swap serde_json for serde_yaml or serde_toml without changing your struct code.

Convention aside: Always add features = ["derive"] to serde. Without it, the #[derive] macros won't exist, and you'll have to write manual implementations. Also, derive Debug alongside Serialize and Deserialize. You will need Debug for logging and error messages. It's a small habit that saves time during debugging.

[dependencies]
# The derive feature enables #[derive(Serialize, Deserialize)].
serde = { version = "1.0", features = ["derive"] }
# serde_json implements the serde traits for JSON format.
serde_json = "1.0"
use serde::{Serialize, Deserialize};

// Derive macros generate the implementation code at compile time.
// This avoids runtime reflection and keeps performance high.
#[derive(Serialize, Deserialize, Debug)]
struct Config {
    name: String,
    port: u16,
}

fn main() {
    let config = Config { name: "server".to_string(), port: 8080 };

    // to_string borrows the struct and produces a JSON String.
    // unwrap panics if serialization fails, which is rare for simple types.
    let json = serde_json::to_string(&config).unwrap();
    println!("{}", json);

    // from_str takes the JSON string and reconstructs the struct.
    // The type annotation : Config tells serde what to build.
    let parsed: Config = serde_json::from_str(&json).unwrap();
    println!("{:?}", parsed);
}

Add the derive feature. Without it, the macros won't exist.

How the macro expands

When you compile, the #[derive(Serialize)] macro expands into an impl Serialize for Config block. It generates code that visits each field, calls the serializer's methods, and builds the output. At runtime, to_string creates a Serializer for JSON, calls your generated serialize method, and returns the result.

Deserialization works in reverse. from_str creates a Deserializer, which calls your generated deserialize method. The deserializer reads tokens from the JSON and fills the struct fields. If the JSON is missing a field or has the wrong type, the deserializer returns an error instead of panicking. The compiler writes the loop. You just provide the data.

Real-world customization

Real APIs often use camelCase while Rust prefers snake_case. Serde provides attributes to bridge that gap. Attributes also let you skip optional fields, rename fields, and handle defaults.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: u64,
    // Rename field to match API convention (snake_case to camelCase).
    #[serde(rename = "firstName")]
    first_name: String,
    // Skip this field if it's None to keep JSON clean.
    #[serde(skip_serializing_if = "Option::is_none")]
    bio: Option<String>,
}

fn main() {
    let user = User {
        id: 42,
        first_name: "Alice".to_string(),
        bio: None,
    };

    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("{}", json);
}

Serde provides a rich set of attributes for customization. Use #[serde(rename_all = "camelCase")] to transform all field names at once. Use #[serde(flatten)] to merge nested structs into the parent JSON object. Use #[serde(with = "module")] to delegate serialization to a custom module when you need complex logic. These attributes compose well. You can stack them on a single field to handle renaming, skipping, and defaults simultaneously.

Attributes handle the quirks. You rarely need to implement the traits manually.

Lifetimes and borrowing

Deserialization introduces a subtle lifetime constraint. When you deserialize a string, the result might borrow from the input data. serde_json::from_str returns a value that owns its data by default. However, if you use borrowed deserializers or zero-copy modes, you might encounter lifetime errors.

If you try to return a deserialized value that borrows from a local string, the compiler rejects you with a lifetime error. The input string is dropped at the end of the function, leaving the struct with a dangling reference. The fix is to deserialize into an owned type or extend the lifetime of the input data. Stick to owned deserialization unless profiling proves zero-copy is necessary.

Prefer owned deserialization. Borrowing adds complexity that rarely pays off.

Common errors

If you forget the derive macro, the compiler rejects you with E0277 (the trait Serialize is not implemented for Config). If you forget the derive feature in Cargo.toml, you get a "cannot find derive macro" error. The fix is always the same: ensure the feature is enabled and the macro is applied.

Deserialization can fail. The JSON might be malformed, or a field might have the wrong type. serde_json::from_str returns a Result<T, serde_json::Error>. In production code, handle this error gracefully. Log the error and return a meaningful response to the user. Don't unwrap in library code. The error type implements std::error::Error, so you can chain it with ? in fallible functions.

When serialization fails, check your Cargo.toml features before blaming the code.

Choosing a format

Serde is format-agnostic. The traits are defined in serde, and the format crates implement them. Pick the format crate that matches your audience.

Use serde_json when you are building web APIs or exchanging data with JavaScript clients. JSON is the standard for HTTP payloads. Use serde_toml when you are writing configuration files for Rust tools. TOML is readable and unambiguous. Use serde_yaml when you need human-editable configuration that supports complex nesting, like Kubernetes manifests. Use bincode when performance and size matter more than readability, such as in embedded systems or high-frequency inter-process communication. Use manual impl Serialize when you need custom logic that attributes cannot express, like conditional serialization based on runtime state. Reach for attributes first; manual implementations are rare.

The format is a plugin. Swap it out without touching your structs.

Where to go next