Rust Serde Serialization and Deserialization Guide
Master Serde in Rust with practical patterns for JSON, custom serialization, field renaming, enums, and error handling in real-world projects.
What you'll learn
- ✓How Serde derive macros work
- ✓Field-level attributes for renaming and defaults
- ✓Enum serialization strategies
- ✓Custom serializers and deserializers
Prerequisites
- •Basic Rust knowledge
- •Familiarity with structs and enums
Serde is Rust’s serialization framework. It sits between your Rust types and data formats like JSON, TOML, YAML, and MessagePack. You describe how your types map to data, and Serde handles the rest. This guide covers the patterns you will use most in production code.
Getting Started
Add Serde and a format crate to your Cargo.toml:
// Cargo.toml
// [dependencies]
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
The derive feature gives you #[derive(Serialize, Deserialize)], which handles most types automatically:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
name: String,
email: String,
age: u32,
}
fn main() {
let user = User {
name: "Alice".into(),
email: "alice@example.com".into(),
age: 30,
};
// Serialize to JSON
let json = serde_json::to_string_pretty(&user).unwrap();
println!("{json}");
// Deserialize from JSON
let parsed: User = serde_json::from_str(&json).unwrap();
println!("{:?}", parsed);
}
Field Attributes
Serde’s field attributes let you control how each field maps to the serialized format.
Renaming Fields
Rust uses snake_case, but APIs often use camelCase or PascalCase:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
status_code: u16,
error_message: Option<String>,
request_id: String,
}
fn main() {
let resp = ApiResponse {
status_code: 200,
error_message: None,
request_id: "abc-123".into(),
};
let json = serde_json::to_string(&resp).unwrap();
// {"statusCode":200,"errorMessage":null,"requestId":"abc-123"}
println!("{json}");
}
You can also rename individual fields:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct Config {
#[serde(rename = "type")]
kind: String, // "type" is a Rust keyword
#[serde(rename = "max-retries")]
max_retries: u32,
}
Default Values
Handle missing fields gracefully with defaults:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct Settings {
host: String,
#[serde(default = "default_port")]
port: u16,
#[serde(default)]
debug: bool, // defaults to false
#[serde(default)]
tags: Vec<String>, // defaults to empty vec
}
fn default_port() -> u16 {
8080
}
fn main() {
let json = r#"{"host": "localhost"}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
println!("{:?}", settings);
// Settings { host: "localhost", port: 8080, debug: false, tags: [] }
}
Skipping and Flattening
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize)]
struct Event {
name: String,
timestamp: u64,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip)]
internal_id: u64, // never serialized or deserialized
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>, // catch-all for unknown fields
}
fn main() {
let json = r#"{
"name": "deploy",
"timestamp": 1719800000,
"region": "us-east-1",
"version": "2.1.0"
}"#;
let event: Event = serde_json::from_str(json).unwrap();
println!("Extra fields: {:?}", event.extra);
// Extra fields: {"region": "us-east-1", "version": "2.1.0"}
}
Enum Serialization
Serde supports four enum representations. Choosing the right one depends on your API design.
use serde::{Serialize, Deserialize};
// Externally tagged (default): {"variant_name": data}
#[derive(Debug, Serialize, Deserialize)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
// Internally tagged: {"type": "circle", "radius": 5.0}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
UserCreated { user_id: u64, name: String },
UserDeleted { user_id: u64 },
}
// Adjacently tagged: {"t": "circle", "c": {"radius": 5.0}}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum Message {
Text(String),
Image { url: String, width: u32 },
}
// Untagged: tries each variant in order
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Value {
Integer(i64),
Float(f64),
Text(String),
}
fn main() {
// Internally tagged
let event = Event::UserCreated { user_id: 1, name: "Alice".into() };
let json = serde_json::to_string(&event).unwrap();
println!("{json}");
// {"type":"user_created","user_id":1,"name":"Alice"}
// Untagged
let val: Value = serde_json::from_str("42").unwrap();
println!("{:?}", val); // Integer(42)
}
Custom Serialization
Sometimes the derive macro is not flexible enough. You can write custom serialize/deserialize functions for individual fields:
use serde::{Serialize, Deserialize, Serializer, Deserializer};
#[derive(Debug, Serialize, Deserialize)]
struct Record {
name: String,
#[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")]
color: u32,
}
fn serialize_hex<S: Serializer>(value: &u32, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&format!("#{:06x}", value))
}
fn deserialize_hex<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
let s: &str = Deserialize::deserialize(deserializer)?;
u32::from_str_radix(s.trim_start_matches('#'), 16)
.map_err(serde::de::Error::custom)
}
fn main() {
let record = Record {
name: "sky".into(),
color: 0x87CEEB,
};
let json = serde_json::to_string(&record).unwrap();
println!("{json}"); // {"name":"sky","color":"#87ceeb"}
let parsed: Record = serde_json::from_str(&json).unwrap();
println!("{:?}", parsed);
}
Handling Errors Gracefully
In production, use proper error handling instead of unwrap():
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
struct AppConfig {
database_url: String,
port: u16,
}
fn load_config(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
let config: AppConfig = serde_json::from_str(&content)?;
Ok(config)
}
fn main() {
match load_config("config.json") {
Ok(config) => println!("Loaded: {:?}", config),
Err(e) => eprintln!("Failed to load config: {e}"),
}
}
Serde error messages are detailed. A missing field error looks like: missing field 'database_url' at line 3 column 1. This makes debugging straightforward.
Working with Multiple Formats
Because Serde is format-agnostic, switching formats is trivial:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct Config {
host: String,
port: u16,
}
fn main() {
let config = Config {
host: "localhost".into(),
port: 3000,
};
// JSON
let json = serde_json::to_string(&config).unwrap();
println!("JSON: {json}");
// The same struct works with TOML, YAML, MessagePack, etc.
// Just swap the crate: toml::to_string, serde_yaml::to_string
}
Wrapping Up
Serde handles the vast majority of serialization needs through derive macros and field attributes. Use rename_all for casing conventions, default for optional fields, skip_serializing_if to omit nulls, and flatten for catch-all fields. Choose the right enum representation for your API. When derive is not enough, write custom serializers for individual fields rather than implementing the traits from scratch. Serde’s format-agnostic design means your type definitions work unchanged across JSON, TOML, YAML, and dozens of other formats.
Related articles
- 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.
- Go Custom JSON Marshaling and Unmarshaling in Go
Implement the json.Marshaler and json.Unmarshaler interfaces to control how Go types serialize to and from JSON.
- Rust Rust Async Programming with Tokio: Getting Started
Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.
- Rust Rust Concurrency: Channels, Mutex, and Arc Patterns
Learn Rust concurrency with channels, Mutex, Arc, and thread-safe patterns. Covers mpsc, shared state, thread pools, and real-world examples.