Skip to content
C Codeloom
Rust

Rust Serde Tutorial

A complete guide to Serde, Rust's de facto serialization framework, covering derive macros, attributes, custom types, and common JSON patterns.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Core concept introduced
  • How the API is structured
  • Typical idiomatic usage
  • Common pitfalls to avoid
  • When and where to apply it

Prerequisites

  • Basic Rust familiarity

What and Why

Serde is Rust’s serialization framework. The name is a portmanteau of “serialize” and “deserialize.” What makes it special is that it decouples your data types from the wire format. You annotate a struct with #[derive(Serialize, Deserialize)], and then any Serde-compatible backend - serde_json, serde_yaml, bincode, toml, ciborium, and more - can read and write it.

The “why” is performance and ergonomics. Serde generates code at compile time using procedural macros, so there’s no runtime reflection cost. Your structs become zero-cost data definitions that any format can speak.

Mental Model

Think of Serde as a two-sided pipeline. On one side, your Rust type implements Serialize, describing how to walk its fields and emit them as a sequence of typed tokens. On the other side, your type implements Deserialize, describing how to construct itself from a sequence of incoming tokens. The format crate (like serde_json) sits in the middle, translating between those tokens and concrete bytes.

You rarely write these traits by hand. The derive macros generate them, and a rich set of attributes lets you customize naming, defaults, skipping, and validation without writing boilerplate.

Hands-on Example

Here is a typical use case: parsing a JSON API response with renamed fields and an optional value.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct User {
    id: u64,
    full_name: String,
    #[serde(default)]
    nickname: Option<String>,
}

fn main() -> Result<(), serde_json::Error> {
    let raw = r#"{"id":42,"fullName":"Ada Lovelace"}"#;
    let user: User = serde_json::from_str(raw)?;
    println!("{:?}", user);

    let out = serde_json::to_string(&user)?;
    println!("{}", out);
    Ok(())
}
How Serde moves data between Rust types and a wire format

The diagram emphasizes that your type only talks to a token stream, never to JSON directly. That indirection is why you can swap formats without changing your structs.

Common Pitfalls

A common pitfall is forgetting that #[serde(default)] requires Default for the type or the field. For Option<T>, missing fields become None automatically, which is usually what you want.

Another trap is enum tagging. By default, Serde uses “externally tagged” enums, which produces JSON like {"Variant": {...}}. APIs often expect internally tagged enums ({"type": "variant", ...}). Use #[serde(tag = "type")] to match common API styles.

A subtle issue: #[serde(flatten)] is powerful but breaks zero-copy deserialization and can silently swallow unknown fields. Pair it with #[serde(deny_unknown_fields)] when validating inputs.

Finally, large integers in JSON can lose precision when treated as f64. If you need exact u64, use serde_json::Number carefully or pass them as strings.

Practical Tips

Use #[serde(rename_all = "...")] once at the struct level instead of renaming each field. For optional output fields, combine Option<T> with #[serde(skip_serializing_if = "Option::is_none")] to omit nulls.

For external APIs you don’t control, derive Deserialize on a “DTO” struct and convert to your internal domain type with a From or TryFrom impl. This isolates wire-format quirks from your business logic.

If compile times start to hurt, the serde_derive macros are usually the cause. You can offset that with the derive_more crate for simpler derives, or by reducing how many crates derive Serialize/Deserialize.

Wrap-up

Serde is one of the best examples of Rust’s compile-time philosophy paying off: type-driven, zero-overhead, and format-agnostic. Learn the derive macros, memorize a handful of attributes (rename_all, default, flatten, tag, skip_serializing_if), and you’ll handle 95% of real-world serialization tasks cleanly.