How to Use serde for Any Data Format in Rust

Use the serde crate with the derive feature to automatically serialize and deserialize Rust structs into formats like JSON or TOML.

When one parser isn't enough

You are building a CLI tool that reads a configuration file. You start with JSON because it is easy to test. Your code parses the JSON, extracts the fields, and runs. Then a user asks for TOML support because it is cleaner for config files. You copy your parser, rewrite the logic for TOML syntax, and deploy. A week later, someone wants YAML. You are about to write a third parser that does the exact same work: validate types, extract values, and construct your Rust structs.

Stop. You are repeating yourself. The logic for your data structure does not change. Only the text format changes. Rust solves this with serde, a library that separates your data model from the serialization format. You define your struct once. You derive traits that describe how to pack and unpack the data. Then you swap in serde_json, toml, or serde_yaml without touching your struct. Serde turns format support from a chore into a dependency.

The universal adapter pattern

Think of your Rust struct as a suitcase. Serialization is packing the suitcase for travel. Deserialization is unpacking it when you arrive. The suitcase has zippers and compartments. It does not care if you are shipping it by air, ground, or sea. The shipping method handles the transport.

serde defines the standard interface for the suitcase. The Serialize trait says how to open the suitcase and list the contents. The Deserialize trait says how to take a list of contents and fill the suitcase back up. Your struct implements these traits. The format crates implement the shipping methods. serde_json knows how to turn the list of contents into JSON text. toml knows how to turn it into TOML text. They both speak the same language defined by serde.

This decoupling is the core insight. Your struct never mentions JSON. The JSON crate never mentions your struct. They communicate through traits. This means you can add support for a new format by adding a crate, not by rewriting your data types.

Minimal example

Here is the smallest working setup. You need serde with the derive feature and a format crate like serde_json.

use serde::{Deserialize, Serialize};

/// A simple configuration for a web server.
#[derive(Serialize, Deserialize, Debug)]
struct Config {
    name: String,
    port: u16,
}

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

    // Serialize: Convert the struct to a JSON string.
    // The derive macro generates the code to walk the fields.
    let json = serde_json::to_string(&config).unwrap();
    println!("Serialized: {}", json);

    // Deserialize: Parse the JSON string back into the struct.
    // The derive macro generates the code to fill the fields.
    let parsed: Config = serde_json::from_str(&json).unwrap();
    println!("Deserialized: {:?}", parsed);
}

Add these dependencies to your Cargo.toml:

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

Convention aside: always enable the derive feature in serde. Without it, the #[derive(Serialize)] macro will not exist, and you will get a compiler error about missing macros. The derive feature pulls in the code generation tools that make serde practical.

Copy this pattern. It works for JSON, TOML, YAML, and more without changing your struct.

How the traits decouple data from format

The magic happens at compile time. The #[derive(Serialize, Deserialize)] attribute is a macro. It expands into code that implements the Serialize and Deserialize traits for your struct. The generated code walks through every field, calls methods on the serializer, and yields the values.

When you call serde_json::to_string, you are passing your struct to a function that expects anything implementing Serialize. The function creates a JSON serializer. Your struct's generated code calls methods like serializer.serialize_struct("Config", 2), then serializer.serialize_field("name", &self.name), and so on. The JSON serializer receives these calls and writes the corresponding JSON text.

Deserialization works in reverse. serde_json::from_str parses the text and creates a deserializer. It calls Deserialize::deserialize on your struct. The generated code asks for fields by name, checks types, and constructs the struct. If the JSON has a string where you expect a number, the deserializer reports a type mismatch.

This design gives you zero-cost abstraction. The generated code is as fast as hand-written serialization for your specific type. There is no runtime reflection or dynamic dispatch. The compiler inlines the field access and type checks. You get flexibility without performance penalties.

The magic happens at compile time. You pay nothing at runtime for the abstraction.

Realistic configuration loader

Real code needs error handling and often deals with nested structures. Here is a loader that reads a config file, handles errors, and uses a common convention for naming.

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;

/// Configuration for the application, including database settings.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct AppConfig {
    /// The name of the application instance.
    app_name: String,
    /// Database connection details.
    database: DatabaseConfig,
    /// List of enabled features.
    features: Vec<String>,
}

/// Database connection parameters.
#[derive(Serialize, Deserialize, Debug)]
struct DatabaseConfig {
    host: String,
    port: u16,
    max_connections: u32,
}

/// Load configuration from a JSON file.
/// Returns the config or an error describing what went wrong.
fn load_config(path: &Path) -> Result<AppConfig, Box<dyn std::error::Error>> {
    // Read the file content as a string.
    let content = fs::read_to_string(path)?;

    // Deserialize the string into the AppConfig struct.
    // The ? operator propagates IO errors and serde errors.
    let config: AppConfig = serde_json::from_str(&content)?;

    Ok(config)
}

fn main() {
    match load_config(Path::new("config.json")) {
        Ok(config) => println!("Loaded config: {:?}", config),
        Err(e) => eprintln!("Failed to load config: {}", e),
    }
}

The #[serde(rename_all = "camelCase")] attribute tells serde to map Rust's snake_case fields to camelCase in the output. app_name becomes appName. This is a common convention when working with JSON APIs that follow JavaScript naming styles. You avoid manual renaming for every field while keeping Rust idioms in your code.

Convention aside: add #[serde(deny_unknown_fields)] to your config structs in production. By default, serde ignores extra fields in the input. This can hide typos in your config files. Denying unknown fields forces the parser to fail if the input contains unexpected keys, catching mistakes early.

Handle errors explicitly. Your config loader should fail fast with a clear message.

Pitfalls and compiler errors

Serde enforces Rust's type system strictly. JSON is loose; Rust is not. Serde bridges the gap but does not coerce types silently.

If you forget the derive feature in Cargo.toml, the compiler rejects your code with a "cannot find derive macro" error. The macro does not exist without the feature. Enable features = ["derive"] to fix this.

If your JSON contains a string where the struct expects a number, deserialization fails with a runtime error like invalid type: string "8080", expected u16. Serde does not parse numbers from strings automatically. You must provide a custom deserializer or fix the data. This strictness prevents subtle bugs where type mismatches go unnoticed.

If a required field is missing from the input, you get a missing field error. You can make fields optional by using Option<T> or by adding #[serde(default)] to provide a fallback value. Without these, serde treats every field as mandatory.

If you try to serialize a type that does not implement Serialize, the compiler complains with a trait bound error. For example, E0277 indicates that a type does not implement the required trait. This usually means you forgot to derive the trait on a nested struct or are using a type from another crate that lacks serde support.

Serde enforces Rust's type system. Trust the errors; they point to data issues, not code bugs.

Choosing your serialization strategy

Use serde_json when you are exchanging data with web APIs or storing configuration that needs to be human-readable and widely supported. JSON is the lingua franca of the web.

Use toml when you are writing configuration files for Rust projects or tools where a clean, opinionated syntax reduces boilerplate. TOML is designed for config, not general data exchange.

Use bincode or postcard when performance and size matter more than readability, such as in embedded systems or high-throughput internal communication. These formats produce compact binary output and serialize faster than text formats.

Use serde attributes like #[serde(rename)] or #[serde(skip)] when the external format uses different naming conventions or omits fields that Rust requires. Attributes let you adapt your struct to the format without changing the struct definition.

Use manual parsing when the data format is non-standard, binary, or too simple to justify the overhead of a full serialization library. Serde adds compile time and dependency weight. For a single integer in a file, str::parse is enough.

Pick the format that fits the audience, not the one that feels familiar.

Where to go next