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.
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"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.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
name: String,
age: u32,
active: bool,
}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 JSONto_string_pretty— indented, human-readable JSON
Both return Result<String, serde_json::Error>.
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.
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: 25Nested 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.
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.
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 |
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.
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: 2Serializing 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.
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.
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 |
// 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 strinstead ofStringin 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.