How to Serialize and Deserialize Structs with Serde

Add the serde dependency and derive Serialize and Deserialize traits on your struct to handle JSON conversion.

When your data needs to travel

You built a command line tool that saves user preferences. You wrote a web API that receives a JSON payload. You created a game that needs to save a player's inventory to disk. In every case, your data lives comfortably in memory as a Rust struct. Now it needs to cross a boundary. It needs to become a string of bytes that can travel over a network or sit quietly on a filesystem.

Rust will not do this automatically. The language refuses to guess your format. It refuses to add hidden runtime costs. If you want your struct to become text or bytes, you must provide the translation rules yourself. That is where Serde enters the picture.

The translation layer

Serde is not a single crate. It is a framework built on two halves. The serde crate defines the traits that describe how a type can be converted. The format crates like serde_json, serde_yaml, or bincode implement the actual encoding and decoding logic. Think of it like a universal translator at a spaceport. The translator knows the grammar of Rust types and the grammar of JSON. It does not store the data. It just converts it back and forth.

The derive feature is the mechanism that writes the translation rules for you. Instead of manually implementing Serialize and Deserialize for every field, you attach a macro to your struct. The macro reads your struct definition at compile time and generates the trait implementations. You get the safety of manual code with the convenience of automatic generation.

The minimal setup

Start by adding the dependency to your Cargo.toml. You must enable the derive feature. Without it, the macro does not exist and your code will not compile.

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

Create a struct and attach the derive macro. The macro expands into two trait implementations that tell Serde how to walk through your fields.

use serde::{Serialize, Deserialize};

/// Represents application settings that can be saved or loaded.
#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
    output_mode: String,
    max_retries: u32,
}

Serialization turns your struct into a JSON string. Deserialization reads a JSON string and rebuilds the struct. Notice the type annotation on the deserialization call. Rust needs to know what type to construct before it starts parsing.

fn main() {
    // Create an instance with concrete values.
    let config = Config {
        output_mode: "verbose".to_string(),
        max_retries: 3,
    };

    // Serialize to a JSON string. Returns a Result because I/O can fail.
    let json_string = serde_json::to_string(&config).expect("Serialization failed");
    println!("Serialized: {}", json_string);

    // Deserialize back into a Config struct.
    // The ::<Config> tells the compiler which type to build.
    let restored: Config = serde_json::from_str(&json_string).expect("Deserialization failed");
    println!("Restored: {:?}", restored);
}

Convention aside: the community prefers #[derive(Serialize, Deserialize)] on the same line. It keeps the struct header clean and signals immediately that the type is portable.

What actually happens under the hood

When you compile this code, the #[derive] macro runs before the rest of your program. It inspects the Config struct and generates two implementations. One implements serde::Serialize. The other implements serde::Deserialize. The generated code looks roughly like a manual implementation that visits each field in order, calls the serializer's string or integer methods, and handles the output buffer.

There is no reflection. There is no runtime type information. The compiler bakes the field names and types directly into the binary. This is why Serde is fast. It generates tight loops that match your exact struct layout. At runtime, you are just calling functions that write bytes or read bytes. No dictionary lookups. No dynamic dispatch unless you explicitly ask for it.

The serde_json crate handles the formatting. It knows how to quote strings, escape special characters, and format numbers. It also validates the input during deserialization. If the JSON contains a string where a number is expected, serde_json stops parsing and returns an error. It does not guess. It does not silently truncate. It fails fast and gives you a descriptive message.

A realistic configuration scenario

Real projects rarely use flat structs with primitive types. You will encounter nested objects, enums, optional fields, and default values. Serde handles all of them through attributes. Attributes are hints you attach to fields or variants to change how the derive macro generates code.

use serde::{Serialize, Deserialize};

/// Defines how the application should behave at startup.
#[derive(Serialize, Deserialize, Debug)]
pub struct AppSettings {
    /// The port to listen on. Falls back to 8080 if missing.
    #[serde(default = "default_port")]
    port: u16,
    
    /// Feature flags that can be toggled independently.
    features: Vec<String>,
    
    /// Database connection details. Omitted from output if None.
    #[serde(skip_serializing_if = "Option::is_none")]
    database_url: Option<String>,
}

/// Provides the fallback port value when the config lacks one.
fn default_port() -> u16 {
    8080
}

The #[serde(default = "default_port")] attribute tells the deserializer what to do when the JSON key is missing. Instead of failing, it calls your function and inserts the result. The skip_serializing_if attribute prevents None values from cluttering your output. This keeps your JSON clean and reduces payload size.

Convention aside: keep default functions small and pure. They run during deserialization, so avoid file I/O or network calls inside them. The community expects defaults to be fast, deterministic calculations.

Where things go wrong

Serialization seems straightforward until you hit the edge cases. The compiler will catch most mistakes, but the error messages can feel dense if you do not know what to look for.

If you forget the derive feature in Cargo.toml, you will get E0433 (failed to resolve) when you try to use the macro. The compiler cannot find the procedural macro because it was not compiled. Add the feature flag and rebuild.

If you try to serialize a type that does not implement Serialize, the compiler rejects you with E0277 (trait bound not satisfied). This happens often with custom types or third party structs you cannot modify. You must either implement the trait manually or wrap the type in a newtype struct that derives the traits.

Deserialization fails most often because of type inference gaps. If you write serde_json::from_str(json_str) without the ::<T> annotation, the compiler does not know what to build. It will complain about a missing type. Always provide the target type explicitly, or use type inference from a let binding like let config: Config = serde_json::from_str(json_str)?;.

JSON key mismatches are another common trap. Rust uses snake_case by convention. JSON often uses camelCase or kebab-case. If your JSON says outputMode but your struct says output_mode, deserialization fails with a missing field error. Fix it with #[serde(rename = "outputMode")] on the field. Do not fight the naming convention of the external system. Adapt your struct to match it.

Error handling deserves attention. serde_json::from_str returns a Result<T, serde_json::Error>. The error type implements std::error::Error and provides line and column information. Use the ? operator to propagate it, or match on it to provide user friendly messages. Never unwrap in production code. A malformed config file should not crash your application. It should log the error and fall back to defaults or exit gracefully.

Choosing your serialization path

Use serde_json when you are building web APIs or configuration files that humans might read. Use serde_yaml when your configuration needs comments or multi line strings that JSON cannot express. Use bincode or postcard when you are sending data between two Rust programs and care about byte size or parsing speed. Use manual trait implementations when you need custom formatting that the derive macro cannot express. Reach for serde_json::from_reader when you are parsing a file or network stream directly instead of loading it into a String first.

Pick the format that matches your boundary. Do not overengineer the serialization layer. The derive macro handles ninety percent of cases. Reach for attributes only when the default behavior conflicts with your external contract.

Where to go next