Trong bài này:
serde is Rust's de facto serialization framework. It separates the data model (your structs and enums) from the data format (JSON, YAML, TOML, bincode, ...). serde_json handles the JSON format layer. Together they give you typed, zero-cost JSON with minimal boilerplate.
Setup
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
The derive feature enables #[derive(Serialize, Deserialize)] — without it you'd have to implement these traits by hand.
Serialize & Deserialize
Define a struct, derive the traits, and use serde_json::from_str / serde_json::to_string:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
name: String,
age: u32,
email: String,
}
fn main() -> Result<(), serde_json::Error> {
// Deserialize: JSON string → Rust struct
let json = r#"{"name":"Alice","age":25,"email":"alice@example.com"}"#;
let user: User = serde_json::from_str(json)?;
println!("{:?}", user);
// User { name: "Alice", age: 25, email: "alice@example.com" }
// Serialize: Rust struct → JSON string
let out = serde_json::to_string(&user)?;
println!("{}", out);
// {"name":"Alice","age":25,"email":"alice@example.com"}
// Pretty-printed
let pretty = serde_json::to_string_pretty(&user)?;
println!("{}", pretty);
Ok(())
}
That's the core loop. Everything else is controlling how the mapping works.
Field Attributes
Serde provides #[serde(...)] attributes to customize serialization without changing your struct.
rename / rename_all
When your Rust naming convention doesn't match the JSON keys:
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Person {
first_name: String, // → "firstName"
last_name: String, // → "lastName"
}
For a single field:
#[derive(Deserialize)]
struct Config {
#[serde(rename = "type")]
kind: String, // JSON key is "type" (Rust reserved word)
}
rename_all supports: lowercase, UPPERCASE, PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE.
skip / skip_serializing
Exclude fields from serialization, deserialization, or both:
use std::collections::HashMap;
#[derive(Serialize, Deserialize)]
struct Resource {
name: String,
#[serde(skip_serializing)]
checksum: String, // never written to JSON
#[serde(skip_serializing_if = "HashMap::is_empty")]
metadata: HashMap<String, String>, // omitted when empty
#[serde(skip)]
cache: Vec<u8>, // ignored both ways
}
skip_serializing_if takes a function pointer — the field is omitted from output when the function returns true. Deserialization still works normally.
default values
Handle missing fields in JSON by providing defaults:
#[derive(Deserialize, Debug)]
struct Request {
#[serde(default)]
timeout: u32, // defaults to 0
#[serde(default = "default_path")]
path: String, // defaults to "/"
}
fn default_path() -> String {
"/".to_string()
}
fn main() {
let json = r#"{}"#;
let req: Request = serde_json::from_str(json)?;
// Request { timeout: 0, path: "/" }
}
#[serde(default)] uses the type's Default impl. #[serde(default = "fn_name")] calls the named function.
Enum Representations
Serde supports four ways to map Rust enums to JSON. The default is externally tagged:
#[derive(Serialize, Deserialize)]
#[derive(Debug)]
enum Message {
Request { id: String, method: String },
Response { id: String, result: i32 },
}
{"Request": {"id": "1", "method": "ping"}}
{"Response": {"id": "1", "result": 42}}
Most real-world APIs use internally tagged instead:
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
Push { repo: String },
PullRequest { repo: String, number: u64 },
}
{"type": "Push", "repo": "serde"}
{"type": "PullRequest", "repo": "serde", "number": 1234}
Other options: #[serde(tag = "t", content = "c")] for adjacently tagged, and #[serde(untagged)] for no tag at all (tries each variant in order).
Pick the representation that matches your JSON schema — don't transform the data to fit Rust.
Reading & Writing Files
For file I/O, use std::fs with serde's typed API:
use std::fs;
fn read_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
let data = fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&data)?;
Ok(config)
}
fn write_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_string_pretty(config)?;
fs::write(path, json)?;
Ok(())
}
For large files, serde_json::from_reader() streams directly from a Read implementor without loading the whole string into memory first:
use std::fs::File;
fn read_large(path: &str) -> Result<Vec<User>, serde_json::Error> {
let file = File::open(path)?;
serde_json::from_reader(file)
}
Dynamic JSON with serde_json::Value
When the schema is unknown or variable, use serde_json::Value — a dynamic JSON tree:
use serde_json::Value;
fn main() -> Result<(), serde_json::Error> {
let json = r#"{"name":"Alice","scores":[95,87,72]}"#;
let v: Value = serde_json::from_str(json)?;
println!("{}", v["name"]); // "Alice"
println!("{}", v["scores"][0]); // 95
// Iterate an array
if let Some(scores) = v["scores"].as_array() {
for s in scores {
println!("score: {}", s);
}
}
Ok(())
}
Value is an enum: Null, Bool(bool), Number(...), String(String), Array(Vec<Value>), Object(Map<String, Value>). Use it for exploratory parsing, then migrate to typed structs once the schema stabilizes.
Flatten Extra Fields
Capture arbitrary JSON keys you didn't define in the struct:
use std::collections::HashMap;
use serde_json::Value;
#[derive(Serialize, Deserialize)]
struct Webhook {
event: String,
timestamp: u64,
#[serde(flatten)]
extra: HashMap<String, Value>,
}
Any key other than event and timestamp lands in extra. This is useful for pass-through proxies or logging unknown payloads without failing.
Error Handling
serde_json::Error implements std::error::Error. It carries line/column info for deserialization failures:
fn main() {
let bad = r#"{"name":}"#;
match serde_json::from_str::<User>(bad) {
Ok(user) => println!("{:?}", user),
Err(e) => {
// Error at line 1 column 10: ...
eprintln!("parse failed: {}", e);
}
}
}
For APIs, wrap in your own error type using thiserror:
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("invalid config: {0}")]
Config(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
The ? operator converts serde_json::Error → AppError::Config automatically via the From impl.
Serde's attribute system covers more than what's shown here — custom serializers with #[serde(serialize_with)], lifetime-based zero-copy deserialization with &'a str, and #[serde(borrow)] for avoiding allocations. See the serde documentation for the full reference.