Serde and JSON
Almost every real-world application needs to exchange data — with a REST API, a config file, or a database. In Rust, the serde ecosystem is the standard answer. It provides a powerful framework for serializing and deserializing data structures with minimal boilerplate.
What Is Serde?
Serde is a framework, not a format. It separates the data model (your Rust structs and enums) from the data format (JSON, TOML, YAML, MessagePack, etc.). The same #[derive(Serialize, Deserialize)] annotation works across all formats.
For JSON specifically, you add two crates to your Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
The derive feature unlocks the procedural macros that generate serialization logic automatically.
Basic Serialization
Serialization converts a Rust value into another format — in this case, a JSON string. You derive Serialize on any struct or enum you want to convert:
use serde::Serialize;
use serde_json;
#[derive(Serialize)]
struct User {
id: u32,
name: String,
email: String,
active: bool,
}
fn main() {
let user = User {
id: 1,
name: String::from("Alice"),
email: String::from("alice@example.com"),
active: true,
};
// Serialize to a compact JSON string
let json = serde_json::to_string(&user).unwrap();
println!("{}", json);
// Or pretty-print with indentation
let pretty = serde_json::to_string_pretty(&user).unwrap();
println!("{}", pretty);
}
The output from to_string_pretty will look exactly like the JSON you'd write by hand, with field names matching your struct fields.
Basic Deserialization
Deserialization is the reverse — parsing JSON text back into a typed Rust value. Derive Deserialize and call serde_json::from_str:
use serde::Deserialize;
use serde_json;
#[derive(Deserialize, Debug)]
struct Config {
host: String,
port: u16,
max_connections: u32,
debug: bool,
}
fn main() {
let json = r#"
{
"host": "localhost",
"port": 8080,
"max_connections": 100,
"debug": false
}
"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("Connecting to {}:{}", config.host, config.port);
println!("Max connections: {}", config.max_connections);
println!("Debug mode: {}", config.debug);
}
Notice the r#"..."# raw string literal — it lets you embed quotes and backslashes without escaping them, which is convenient for inline JSON.
Controlling Field Names and Behaviour
Serde attributes let you customize how fields are mapped without changing your Rust naming conventions. The most common is #[serde(rename_all)] for switching between naming conventions, and #[serde(rename)] for individual fields.
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")] // snake_case in Rust, camelCase in JSON
struct ApiResponse {
user_id: u32,
first_name: String,
last_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
profile_picture: Option<String>,
#[serde(default)] // use Default::default() if field is missing
follower_count: u64,
}
fn main() {
// Deserialize from camelCase JSON (e.g. from a JavaScript API)
let json = r#"{"userId":42,"firstName":"Bob","lastName":"Smith","followerCount":1500}"#;
let resp: ApiResponse = serde_json::from_str(json).unwrap();
println!("{:?}", resp);
// Serialize back — profile_picture is None so it won't appear in output
let out = serde_json::to_string(&resp).unwrap();
println!("{}", out);
}
Key attributes used here:
#[serde(rename_all = "camelCase")]— applies a naming convention to all fields#[serde(skip_serializing_if = "Option::is_none")]— omitsnullfields from output#[serde(default)]— allows missing JSON keys by falling back toDefault::default()
Handling Errors Properly
In production code you should propagate errors rather than calling unwrap. Serde's error type implements std::error::Error, so it composes naturally with ?:
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
struct Product {
id: u32,
name: String,
price: f64,
}
fn parse_product(input: &str) -> Result<Product, serde_json::Error> {
serde_json::from_str(input)
}
fn to_json(product: &Product) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(product)
}
fn main() {
// Valid JSON
let valid = r#"{"id": 7, "name": "Keyboard", "price": 129.99}"#;
match parse_product(valid) {
Ok(p) => println!("Parsed: {:?}", p),
Err(e) => eprintln!("Parse error: {}", e),
}
// Invalid JSON — missing required field
let invalid = r#"{"id": 8, "name": "Mouse"}"#;
match parse_product(invalid) {
Ok(p) => println!("Parsed: {:?}", p),
Err(e) => eprintln!("Parse error: {}", e),
}
// Serialize with error handling
let product = Product { id: 9, name: String::from("Monitor"), price: 399.00 };
match to_json(&product) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Serialize error: {}", e),
}
}
The error message for the invalid case will tell you exactly which field is missing and at which byte position — far more useful than a panic.
Nested Structures and Enums
Serde handles nested structs and enums automatically. This makes it straightforward to model complex JSON payloads:
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum OrderStatus {
Pending,
Processing,
Shipped,
Delivered,
}
#[derive(Serialize, Deserialize, Debug)]
struct Address {
street: String,
city: String,
country: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct Order {
id: u32,
status: OrderStatus,
items: Vec<String>,
shipping_address: Address,
}
fn main() {
let order = Order {
id: 101,
status: OrderStatus::Shipped,
items: vec![String::from("Widget"), String::from("Gadget")],
shipping_address: Address {
street: String::from("123 Rust Lane"),
city: String::from("Ferris City"),
country: String::from("Crabland"),
},
};
let json = serde_json::to_string_pretty(&order).unwrap();
println!("{}", json);
// Round-trip: deserialize what we just serialized
let restored: Order = serde_json::from_str(&json).unwrap();
println!("\nStatus after round-trip: {:?}", restored.status);
println!("City: {}", restored.shipping_address.city);
}
Enums serialize to their variant name as a string by default. The rename_all attribute converts Shipped to "shipped" in JSON.
Try It Yourself
Build a small task manager that serializes a list of tasks to JSON and deserializes it back. Add at least one optional field and one enum field:
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum Priority {
Low,
Medium,
High,
}
#[derive(Serialize, Deserialize, Debug)]
struct Task {
id: u32,
title: String,
priority: Priority,
completed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
due_date: Option<String>,
}
fn main() {
let tasks = vec![
Task {
id: 1,
title: String::from("Learn serde"),
priority: Priority::High,
completed: true,
due_date: Some(String::from("2024-02-01")),
},
Task {
id: 2,
title: String::from("Write unit tests"),
priority: Priority::Medium,
completed: false,
due_date: None,
},
];
// Serialize the task list
let json = serde_json::to_string_pretty(&tasks).unwrap();
println!("Serialized:\n{}\n", json);
// Deserialize it back
let restored: Vec<Task> = serde_json::from_str(&json).unwrap();
let pending: Vec<&Task> = restored.iter().filter(|t| !t.completed).collect();
println!("Pending tasks:");
for task in pending {
println!(" - [{}] {:?} priority", task.title, task.priority);
}
}
Try extending this by adding a tags: Vec<String> field with #[serde(default)] so existing JSON without that field still deserializes correctly.
Key Takeaways
- Serde is a framework: derive
SerializeandDeserializeon your types; plug in any format crate (serde_json,toml,serde_yaml) to_string/from_strare the core serde_json functions;to_string_prettyadds indentation- Attributes control mapping:
rename_all,rename,skip_serializing_if,default, and many more give fine-grained control without changing your struct - Errors are descriptive: serde's errors include field names and byte offsets — always propagate them with
?instead ofunwrapin production - Enums serialize cleanly: by default to their variant name as a string;
#[serde(tag = "type")]enables tagged union representations - Round-trips are reliable: serialize then deserialize produces an identical value — a property worth verifying in tests
Pro Tip: Use
serde_json::Valuewhen you need to handle arbitrary or dynamic JSON whose shape you don't know at compile time. It's an enum (Null,Bool,Number,String,Array,Object) that you can pattern-match against. This is an escape hatch for truly dynamic data, but prefer typed structs whenever the schema is known — the compiler will catch mismatches before they reach production.
Next Steps
With serde in your toolbox, you can handle virtually any data format Rust encounters. Next, we'll build command-line tools — reading arguments, validating input, and reporting errors — so your programs can talk to the outside world.
Next lesson
CLI and Args
Build command-line tools in Rust by parsing arguments, handling flags, and structuring real-world CLI applications
25 min