In the previous tutorial, we learned advanced error handling. Now we learn Serde — the serialization framework that powers most data handling in Rust.
Serde converts Rust structs and enums to and from formats like JSON, TOML, YAML, and more. It is not just a JSON library. Serde separates “what to serialize” from “what format to use.” You write #[derive(Serialize, Deserialize)] once, and your type works with every supported format.
Setting Up
Add Serde and the formats you need to Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1" # JSON format
toml = "0.8" # TOML format
The "derive" feature enables #[derive(Serialize, Deserialize)]. Without it, the derive macros will not work.
Basic Serialize and Deserialize
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct User {
name: String,
email: String,
age: u32,
}
Serialization — Rust to JSON
fn main() {
let user = User {
name: "Alex".to_string(),
email: "alex@example.com".to_string(),
age: 25,
};
// Compact JSON
let json = serde_json::to_string(&user).unwrap();
println!("{}", json);
// {"name":"Alex","email":"alex@example.com","age":25}
// Pretty-printed JSON
let pretty = serde_json::to_string_pretty(&user).unwrap();
println!("{}", pretty);
// {
// "name": "Alex",
// "email": "alex@example.com",
// "age": 25
// }
}
Deserialization — JSON to Rust
fn main() {
let json = r#"{"name":"Alex","email":"alex@example.com","age":25}"#;
let user: User = serde_json::from_str(json).unwrap();
assert_eq!(user.name, "Alex");
assert_eq!(user.age, 25);
}
Roundtrip Test
Always verify that your types survive a serialize-deserialize cycle:
fn main() {
let original = User {
name: "Alex".to_string(),
email: "alex@example.com".to_string(),
age: 25,
};
let json = serde_json::to_string(&original).unwrap();
let parsed: User = serde_json::from_str(&json).unwrap();
assert_eq!(original, parsed);
}
Serde Attributes
Attributes let you customize how fields are serialized and deserialized. They are the most powerful part of Serde.
rename — Change Field Names
When JSON uses different naming conventions than Rust:
#[derive(Serialize, Deserialize)]
struct ApiResponse {
#[serde(rename = "statusCode")]
status_code: u16,
#[serde(rename = "responseBody")]
response_body: String,
}
The Rust code uses snake_case, but the JSON uses camelCase.
rename_all — Rename All Fields at Once
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Config {
database_url: String, // → "databaseUrl"
max_connections: u32, // → "maxConnections"
enable_logging: bool, // → "enableLogging"
}
Common options:
"camelCase"— JavaScript/JSON style"PascalCase"— C#/Go style"snake_case"— Python/Rust style (default)"SCREAMING_SNAKE_CASE"— constants"kebab-case"— CSS/URL style
default — Default Values for Missing Fields
#[derive(Serialize, Deserialize)]
struct UserProfile {
name: String,
#[serde(default)]
bio: String, // Defaults to "" if missing
#[serde(default = "default_role")]
role: String, // Defaults to "user" if missing
}
fn default_role() -> String {
"user".to_string()
}
Now you can deserialize JSON that is missing optional fields:
fn main() {
let json = r#"{"name":"Alex"}"#;
let profile: UserProfile = serde_json::from_str(json).unwrap();
assert_eq!(profile.bio, "");
assert_eq!(profile.role, "user");
}
skip — Skip Fields
#[derive(Serialize, Deserialize)]
struct Account {
name: String,
#[serde(skip_serializing)]
password_hash: String, // Never included in JSON output
#[serde(skip_serializing_if = "Option::is_none")]
avatar_url: Option<String>, // Skipped when None
}
skip_serializing is useful for sensitive data. skip_serializing_if removes fields when they have no meaningful value.
flatten — Merge Nested Structs
#[derive(Serialize, Deserialize)]
struct Pagination {
page: u32,
per_page: u32,
}
#[derive(Serialize, Deserialize)]
struct SearchRequest {
query: String,
#[serde(flatten)]
pagination: Pagination,
}
Without flatten: {"query": "rust", "pagination": {"page": 1, "per_page": 20}}
With flatten: {"query": "rust", "page": 1, "per_page": 20}
The pagination fields are merged into the parent object.
Enum Serialization
Serde supports four enum representations. Choosing the right one matters for API design.
Externally Tagged (Default)
#[derive(Serialize, Deserialize)]
enum Message {
Text(String),
Number(i64),
}
Produces: {"Text":"hello"} or {"Number":42}
Internally Tagged
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
Produces: {"type":"Circle","radius":5.0}
This is the most common format for APIs. The "type" field tells you which variant it is.
Adjacently Tagged
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
Click { x: i32, y: i32 },
KeyPress(char),
}
Produces: {"type":"Click","data":{"x":10,"y":20}}
Untagged
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum FlexibleValue {
Integer(i64),
Float(f64),
Text(String),
}
Serde tries each variant in order until one works. Use this when the JSON has no type indicator.
Dynamic JSON with serde_json::Value
Sometimes you do not know the JSON structure at compile time. Use serde_json::Value:
use serde_json::json;
fn main() {
let value = json!({
"name": "Alex",
"age": 25,
"tags": ["rust", "programming"],
"address": {
"city": "Berlin",
"country": "Germany"
}
});
// Access fields
let name = value["name"].as_str().unwrap_or("unknown");
let age = value["age"].as_i64().unwrap_or(0);
let city = value["address"]["city"].as_str();
println!("Name: {}, Age: {}", name, age);
println!("City: {:?}", city);
}
The json! macro creates serde_json::Value from JSON-like syntax.
Safe Navigation
Use .get() instead of direct indexing for safe access:
let name = value
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Direct indexing (value["key"]) returns Value::Null for missing keys. It does not panic. But .get() returns Option, which is more explicit.
Mixing Typed and Dynamic
Convert between typed structs and Value:
// Struct to Value
let user = User {
name: "Alex".to_string(),
email: "alex@example.com".to_string(),
age: 25,
};
let value = serde_json::to_value(&user).unwrap();
// Value to struct
let parsed: User = serde_json::from_value(value).unwrap();
Working with TOML
TOML is common for configuration files. It works the same way as JSON:
#[derive(Serialize, Deserialize)]
struct AppConfig {
title: String,
debug: bool,
database: DatabaseConfig,
}
#[derive(Serialize, Deserialize)]
struct DatabaseConfig {
url: String,
max_connections: u32,
}
Serialize to TOML
fn main() {
let config = AppConfig {
title: "My App".to_string(),
debug: true,
database: DatabaseConfig {
url: "sqlite:app.db".to_string(),
max_connections: 5,
},
};
let toml_str = toml::to_string_pretty(&config).unwrap();
println!("{}", toml_str);
}
Output:
title = "My App"
debug = true
[database]
url = "sqlite:app.db"
max_connections = 5
Parse from TOML
fn main() {
let toml_str = r#"
title = "My App"
debug = false
[database]
url = "postgres://localhost/mydb"
max_connections = 10
"#;
let config: AppConfig = toml::from_str(toml_str).unwrap();
println!("Database: {}", config.database.url);
}
Converting Between Formats
Since Serde uses the same traits for all formats, conversion is simple:
fn json_to_toml(json_str: &str) -> Result<String, Box<dyn std::error::Error>> {
let config: AppConfig = serde_json::from_str(json_str)?;
let toml_str = toml::to_string_pretty(&config)?;
Ok(toml_str)
}
Deserialize from one format, serialize to another.
Handling Errors
Serde returns descriptive errors when deserialization fails:
fn main() {
// Missing required field
let json = r#"{"name":"Alex"}"#;
let result: Result<User, _> = serde_json::from_str(json);
// Error: missing field `email` at line 1 column 15
// Wrong type
let json = r#"{"name":"Alex","email":"a@b.com","age":"not a number"}"#;
let result: Result<User, _> = serde_json::from_str(json);
// Error: invalid type: string "not a number", expected u32
}
To reject extra fields, use #[serde(deny_unknown_fields)]:
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictUser {
name: String,
age: u32,
}
Common Mistakes
Mistake 1: Forgetting the derive Feature
# BAD: derive macros will not work
serde = "1"
# GOOD: enables #[derive(Serialize, Deserialize)]
serde = { version = "1", features = ["derive"] }
Mistake 2: Using unwrap() in Production
// BAD: panics on invalid JSON
let user: User = serde_json::from_str(input).unwrap();
// GOOD: handle the error
let user: User = serde_json::from_str(input)
.map_err(|e| format!("Invalid JSON: {}", e))?;
Mistake 3: Not Using skip_serializing_if for Option
// BAD: serializes as {"name":"Alex","avatar":null}
#[derive(Serialize)]
struct Profile {
name: String,
avatar: Option<String>,
}
// GOOD: omits the field when None → {"name":"Alex"}
#[derive(Serialize)]
struct Profile {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
avatar: Option<String>,
}
Summary
| Concept | Purpose |
|---|---|
Serialize / Deserialize | Convert Rust types to/from data formats |
serde_json::to_string | Rust struct to JSON string |
serde_json::from_str | JSON string to Rust struct |
#[serde(rename)] | Change field name in output |
#[serde(rename_all)] | Rename all fields (camelCase, etc.) |
#[serde(default)] | Use default value for missing fields |
#[serde(skip_serializing)] | Exclude field from output |
#[serde(flatten)] | Merge nested struct fields |
#[serde(tag)] | Choose enum representation |
serde_json::Value | Dynamic JSON without typed structs |
json! macro | Create Value from JSON syntax |
Source Code
Find the complete code on GitHub: tutorial-20-serde
What’s Next?
In this tutorial, we learned Serde — the most important data processing library in Rust. We covered derive macros, attributes, enum serialization, dynamic JSON, and TOML.
In the next tutorial, we will make HTTP requests with Reqwest — calling APIs, sending JSON, handling responses, and building an API client.
Next: Rust Tutorial #21: HTTP with Reqwest
Related Articles
- Rust Tutorial #19: Advanced Error Handling – previous tutorial
- Rust Tutorial #21: HTTP with Reqwest – next tutorial
- Rust Tutorial Series – all tutorials