Rust Serde Tutorial
A complete guide to Serde, Rust's de facto serialization framework, covering derive macros, attributes, custom types, and common JSON patterns.
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(())
}
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.
Related articles
- Rust Rust Actix vs Axum Comparison
A side-by-side comparison of Actix Web and Axum, covering architecture, ergonomics, performance, ecosystem, and how to pick the right one for your project.
- Rust Rust async/await and Futures Explained
How Rust's async/await desugars into a state machine, what a Future actually is, and the runtime model that makes it efficient on real workloads.
- Rust Rust Axum Web Framework Tutorial
A practical introduction to building HTTP services in Rust using the Axum web framework, with routing, extractors, state, and JSON handling.
- Rust Rust Builder Pattern Explained
Learn the builder pattern in Rust, why it fits the language so well, and how to use it for ergonomic, type-safe configuration of complex structs.