RustSerialization (serde)

Serialization with serde

serde is Rust's de-facto standard for serializing and deserializing data structures. The name is a portmanteau of serialization and deserialization. It is a framework — not tied to any single format — so the same #[derive] annotations work with JSON, YAML, TOML, MessagePack, Bincode, and dozens of other formats simply by swapping the format crate.

serde is extraordinarily fast: it generates zero-copy, monomorphised code at compile time with no runtime reflection, which is why Rust services routinely out-perform languages that rely on built-in serialization.

Adding serde to Cargo.toml

You need two crates: serde itself (with the derive feature enabled) and a format crate. For JSON the format crate is serde_json.

TOML
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Note
The derive feature is what unlocks the #[derive(Serialize, Deserialize)] macros. Without it you would have to implement the traits by hand.
Deriving Serialize and Deserialize

The most common way to make a type serializable is to derive both traits. Any struct or enum whose fields are themselves serializable will work automatically.

RUST
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    name: String,
    age: u32,
    active: bool,
}
Tip
Derive both Serialize and Deserialize together unless you intentionally need only one direction — for example an append-only event log entry that is written but never read back.
Serializing to JSON

serde_json provides two serialization functions:

  • to_string — compact single-line JSON
  • to_string_pretty — indented, human-readable JSON

Both return Result<String, serde_json::Error>.

RUST
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    name: String,
    age: u32,
    active: bool,
}

fn main() {
    let user = User {
        name: String::from("Alice"),
        age: 30,
        active: true,
    };

    // Compact JSON
    let json = serde_json::to_string(&user).unwrap();
    println!("compact: {}", json);

    // Pretty-printed JSON
    let pretty = serde_json::to_string_pretty(&user).unwrap();
    println!("pretty:
{}", pretty);
}
compact: {"name":"Alice","age":30,"active":true}
pretty:
{
  "name": "Alice",
  "age": 30,
  "active": true
}
Deserializing from JSON

serde_json::from_str parses a JSON string back into a Rust value. The turbofish syntax ::<User> tells the compiler what type to deserialize into, or you can use a type annotation on the binding — both are equivalent.

RUST
fn main() {
    let json_str = r#"{"name":"Bob","age":25,"active":false}"#;

    // Type annotation on the binding
    let user: User = serde_json::from_str(json_str).unwrap();
    println!("{:?}", user);

    // Turbofish style — equivalent
    let user2 = serde_json::from_str::<User>(json_str).unwrap();
    println!("name: {}, age: {}", user2.name, user2.age);
}
User { name: "Bob", age: 25, active: false }
name: Bob, age: 25
Nested Structs and Vectors

serde handles nested structures automatically. Any field type that itself implements Serialize / Deserialize is supported, including Vec, HashMap, Option, and other structs.

RUST
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Address {
    street: String,
    city: String,
    country: String,
}

#[derive(Serialize, Deserialize, Debug)]
struct Person {
    name: String,
    age: u32,
    address: Address,
    hobbies: Vec<String>,
}

fn main() {
    let person = Person {
        name: String::from("Carol"),
        age: 28,
        address: Address {
            street: String::from("42 Elm St"),
            city: String::from("Springfield"),
            country: String::from("US"),
        },
        hobbies: vec![
            String::from("hiking"),
            String::from("painting"),
        ],
    };

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

    // Round-trip: serialize then deserialize
    let parsed: Person = serde_json::from_str(&json).unwrap();
    println!("city: {}", parsed.address.city);
    println!("hobbies: {:?}", parsed.hobbies);
}
{
  "name": "Carol",
  "age": 28,
  "address": {
    "street": "42 Elm St",
    "city": "Springfield",
    "country": "US"
  },
  "hobbies": [
    "hiking",
    "painting"
  ]
}
city: Springfield
hobbies: ["hiking", "painting"]
Dynamic JSON with serde_json::Value

When the shape of the JSON is not known at compile time, use serde_json::Value — an enum that can represent any JSON value. Index into it with ["key"] for objects and [n] for arrays. The json! macro constructs a Value from a literal.

RUST
use serde_json::Value;

fn main() {
    let raw = r#"
        {
            "product": "widget",
            "price": 9.99,
            "tags": ["sale", "new"],
            "meta": { "weight_kg": 0.5 }
        }
    "#;

    let v: Value = serde_json::from_str(raw).unwrap();

    // Access fields dynamically
    println!("product: {}", v["product"]);
    println!("price:   {}", v["price"]);
    println!("first tag: {}", v["tags"][0]);
    println!("weight: {}", v["meta"]["weight_kg"]);

    // Build JSON dynamically with the json! macro
    let built = serde_json::json!({
        "status": "ok",
        "count": 3
    });
    println!("built: {}", built);
}
product: "widget"
price:   9.99
first tag: "sale"
weight: 0.5
built: {"count":3,"status":"ok"}
Field Attributes

serde's attribute system lets you precisely control how each field is serialized or deserialized without changing the struct's Rust-facing API.

Attribute

Effect

#[serde(rename = "user_name")]

Use a different key name in the serialized format

#[serde(skip)]

Exclude this field from both serialization and deserialization

#[serde(skip_serializing_if = "Option::is_none")]

Omit the field when the value is None

#[serde(default)]

Use Default::default() if the field is absent during deserialization

#[serde(rename_all = "camelCase")]

Transform every field name (struct-level attribute)

#[serde(flatten)]

Inline the fields of a nested struct into the parent object

RUST
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
    // Serialized as "userId" because of rename_all
    user_id: u64,

    // Renamed explicitly — overrides rename_all
    #[serde(rename = "display_name")]
    full_name: String,

    // Omit from JSON when None
    #[serde(skip_serializing_if = "Option::is_none")]
    email: Option<String>,

    // Use 0 as default if field is missing on deserialization
    #[serde(default)]
    login_count: u32,

    // Never included in JSON
    #[serde(skip)]
    internal_token: String,
}

fn main() {
    let resp = ApiResponse {
        user_id: 42,
        full_name: String::from("Alice Smith"),
        email: None,          // will be omitted
        login_count: 7,
        internal_token: String::from("secret"),  // will be skipped
    };

    let json = serde_json::to_string_pretty(&resp).unwrap();
    println!("{}", json);
}
{
  "userId": 42,
  "display_name": "Alice Smith",
  "loginCount": 7
}
The flatten Attribute

#[serde(flatten)] merges the fields of a nested struct into the surrounding JSON object. This is useful for shared fields like pagination metadata, or when an external API returns a flat object that you want to model as separate Rust structs.

RUST
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Pagination {
    page: u32,
    per_page: u32,
    total: u64,
}

#[derive(Serialize, Deserialize, Debug)]
struct UsersResponse {
    users: Vec<String>,

    #[serde(flatten)]
    pagination: Pagination,
}

fn main() {
    let resp = UsersResponse {
        users: vec![String::from("alice"), String::from("bob")],
        pagination: Pagination { page: 1, per_page: 10, total: 2 },
    };

    println!("{}", serde_json::to_string_pretty(&resp).unwrap());

    // Deserialize from a flat JSON object
    let json = r#"{"users":["alice"],"page":2,"per_page":10,"total":1}"#;
    let parsed: UsersResponse = serde_json::from_str(json).unwrap();
    println!("page: {}", parsed.pagination.page);
}
{
  "users": [
    "alice",
    "bob"
  ],
  "page": 1,
  "per_page": 10,
  "total": 2
}
page: 2
Serializing Enums with tag

serde offers several representations for enums. The tag representation adds a discriminant field to the JSON object, making the output easy to consume from other languages and APIs.

RUST
use serde::{Serialize, Deserialize};

// Adds a "type" field that identifies the variant
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum Event {
    Login { user_id: u64, ip: String },
    Logout { user_id: u64 },
    Purchase { item_id: u32, amount_cents: u64 },
}

fn main() {
    let events = vec![
        Event::Login { user_id: 1, ip: String::from("192.168.1.1") },
        Event::Logout { user_id: 1 },
        Event::Purchase { item_id: 99, amount_cents: 1999 },
    ];

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

    // Deserialize back — the "type" field drives variant selection
    let parsed: Vec<Event> = serde_json::from_str(&json).unwrap();
    for e in &parsed {
        println!("{:?}", e);
    }
}
[
  {
    "type": "Login",
    "user_id": 1,
    "ip": "192.168.1.1"
  },
  {
    "type": "Logout",
    "user_id": 1
  },
  {
    "type": "Purchase",
    "item_id": 99,
    "amount_cents": 1999
  }
]
Login { user_id: 1, ip: "192.168.1.1" }
Logout { user_id: 1 }
Purchase { item_id: 99, amount_cents: 1999 }
Error Handling

Both to_string and from_str return Result. In production code propagate or handle errors properly. serde_json::Error implements Display and provides a human-readable message that includes the line and column number where parsing failed.

RUST
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User { name: String, age: u32, active: bool }

fn parse_user(s: &str) -> Result<User, serde_json::Error> {
    // The ? operator propagates the error to the caller
    let user = serde_json::from_str::<User>(s)?;
    Ok(user)
}

fn main() {
    // Invalid JSON — trailing comma
    let bad = r#"{"name": "Dave", "age": 30,}"#;
    match serde_json::from_str::<serde_json::Value>(bad) {
        Ok(v)  => println!("parsed: {}", v),
        Err(e) => println!("parse error: {}", e),
    }

    // Valid JSON round-trip
    match parse_user(r#"{"name":"Eve","age":22,"active":true}"#) {
        Ok(u)  => println!("got user: {:?}", u),
        Err(e) => println!("error: {}", e),
    }
}
parse error: trailing comma at line 1 column 29
got user: User { name: "Eve", age: 22, active: true }
Other serde Format Crates

Because Serialize and Deserialize are format-agnostic, switching formats is a one-line change to the serialization call — your struct definitions stay identical.

Crate

Format

Notes

serde_json

JSON

Most popular; human-readable

serde_yaml

YAML

Human-readable; good for config files

toml

TOML

Rust-ecosystem favourite for config (Cargo.toml)

bincode

Binary

Very compact and fast; not human-readable

rmp-serde

MessagePack

Compact binary; cross-language

postcard

Binary

Optimised for embedded / no-std environments

RUST
// The same struct works with every format crate.
// Only the serialization call changes:

// JSON
let json_bytes = serde_json::to_vec(&user).unwrap();
let json_str   = serde_json::to_string(&user).unwrap();

// Bincode (add bincode = "1" to Cargo.toml)
// let bin_bytes = bincode::serialize(&user).unwrap();

// TOML (add toml = "0.8" to Cargo.toml)
// let toml_str = toml::to_string(&user).unwrap();

// YAML (add serde_yaml = "0.9" to Cargo.toml)
// let yaml_str = serde_yaml::to_string(&user).unwrap();
Performance Notes
  • serde generates specialised code for each type at compile time — there is no runtime reflection or type inspection.

  • Zero-copy deserialization: use &'de str instead of String in your structs to borrow directly from the input buffer, avoiding heap allocation entirely.

  • serde_json is one of the fastest JSON libraries in any language, regularly topping benchmarks against Go, Node.js, and Java.

  • For even higher throughput on known-shape JSON, consider the simd_json crate which uses SIMD CPU instructions.

  • Profile before optimising — for most workloads the default serde_json is more than sufficient.

Success
serde is the gold standard for data serialization in Rust. Derive the traits, add field attributes where needed, and swap format crates freely. The same type-safe, zero-overhead approach works whether you are building a REST API, reading config files, or encoding binary network packets.