When JSON has layers
You fetch a JSON response from an API. It contains a user object, which contains an address object, which contains a list of phone numbers. You need the primary phone number. In Python, you chain dictionary lookups. In JavaScript, you dot-drill. In Rust, you define a structure that mirrors the JSON hierarchy, then ask the compiler to verify the match.
Deserialization is the process of converting a serialized format like JSON into native Rust types. Serde is the standard library for this. It works by generating code at compile time that knows how to read JSON and populate your structs. You don't write the parsing logic. You write the types. Serde writes the parser.
Think of your struct as a mold and the JSON as molten metal. Serde is the machine that pours the metal into the mold. If the metal has the right shape and temperature, you get a perfect cast. If the metal is too cold or the shape is wrong, the machine rejects it. You can't force square metal into a round mold. The compiler ensures your mold matches the data before you ever run the code.
Minimal example
Start with a struct that matches the JSON keys. Derive Deserialize on the struct. Call serde_json::from_str to parse the string.
use serde::Deserialize;
/// Top-level config struct matching the JSON root object.
#[derive(Debug, Deserialize)]
struct Config {
/// Maps to the "name" key in JSON.
name: String,
/// Maps to the "server" key, expecting a nested object.
server: ServerSettings,
}
/// Nested struct for the "server" object.
#[derive(Debug, Deserialize)]
struct ServerSettings {
/// Expects a number for the port.
port: u16,
/// Expects a string for the host.
host: String,
}
fn main() {
// Raw string literal avoids escaping quotes.
let json = r#"{
"name": "MyApp",
"server": {
"port": 8080,
"host": "localhost"
}
}"#;
// from_str takes the JSON text and returns a Result.
// The type annotation `: Config` tells serde which struct to fill.
let config: Config = serde_json::from_str(json).unwrap();
// Access nested data through the struct fields.
println!("Port: {}", config.server.port);
}
Convention: Always derive Debug alongside Deserialize. You will need to inspect the parsed data during development. The combination #[derive(Debug, Deserialize)] is the standard pattern. It costs nothing and saves hours of debugging.
Convention: Use raw string literals r#"..."# for JSON in tests and examples. They let you include quotes and newlines without escaping. This keeps the JSON readable and matches the format you see in network logs.
How it actually works
When you add #[derive(Deserialize)], the compiler invokes a macro. This macro inspects your struct and generates an implementation of the Deserialize trait. This generated code contains the exact logic to read JSON keys, match them to fields, and construct the struct.
At runtime, serde_json::from_str parses the input string token by token. It calls the generated code for your type. The generated code asks for the next key, checks if it matches a field name, and delegates to the field's type. If the JSON has "port": "8080" but your field is u16, the generated code attempts to parse the string as a number and returns an error. The error tells you exactly which field failed and why.
If you forget the derive macro, the compiler rejects you with E0277 (the trait bound Config: Deserialize is not satisfied). The compiler tells you the type doesn't implement the trait needed for parsing. The fix is adding #[derive(Deserialize)].
Trust the type system. If it compiles, the JSON shape matches your struct.
Realistic example
Real-world JSON often has optional fields, arrays, and enums. Serde handles these with standard Rust types and attributes.
use serde::Deserialize;
/// API response containing a list of users.
#[derive(Debug, Deserialize)]
struct ApiResponse {
/// Total count of users available.
total: u32,
/// List of user objects.
users: Vec<User>,
}
/// A single user from the API.
#[derive(Debug, Deserialize)]
struct User {
/// User's unique identifier.
id: u64,
/// Display name, required.
display_name: String,
/// Email might be missing for some users.
#[serde(default)]
email: Option<String>,
/// Role determines permissions.
role: UserRole,
}
/// Possible roles for a user.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum UserRole {
Admin,
Editor,
Viewer,
}
fn main() {
let json = r#"{
"total": 2,
"users": [
{
"id": 1,
"display_name": "Alice",
"email": "alice@example.com",
"role": "admin"
},
{
"id": 2,
"display_name": "Bob",
"role": "viewer"
}
]
}"#;
// Parse the JSON into the ApiResponse struct.
let response: ApiResponse = serde_json::from_str(json).unwrap();
for user in &response.users {
println!("{} is a {:?}", user.display_name, user.role);
if let Some(email) = &user.email {
println!(" Contact: {}", email);
}
}
}
The Option<String> field handles missing data. Serde treats null as None and a missing key as None for Option fields. The #[serde(default)] attribute changes this behavior. When the key is missing, Serde calls Default::default() for the type. For Option, the default is None, so the result is the same. For String, the default is an empty string. Use default when you want a concrete value instead of None.
The #[serde(rename_all = "lowercase")] attribute controls how enum variants map to JSON strings. Without it, Serde expects "Admin". With it, Serde accepts "admin". This matches common API conventions where enum values are lowercase.
Convention: Keep #[serde(...)] attributes close to the field or type they affect. This makes the mapping obvious to readers. Don't scatter attributes across the file.
Pitfalls and errors
Missing fields cause missing field errors. If your API drops a field, parsing fails. Use Option<T> for fields that might be absent. If you want a default value instead of None, use #[serde(default)].
Type mismatches cause invalid type errors. JSON numbers are untyped. A JSON 1 can be an integer or a float. If your struct expects u16 and the JSON has 1.5, parsing fails. If the JSON has "1", parsing also fails because it's a string. Ensure your types match the data source. If the API sends IDs as strings, use String in your struct, not u64.
Unknown fields are ignored by default. If your JSON has extra keys, Serde skips them. This is safe but can hide bugs. Add #[serde(deny_unknown_fields)] to the struct to fail on extra keys. This makes your code strict about the schema. Use this when you control the JSON source and want to catch drift early.
Floating point precision can be tricky. JSON numbers might lose precision when parsed as f64. If you need exact decimal values, use serde_json::Number or a decimal crate. Don't use f64 for currency or precise measurements.
Performance matters for large payloads. serde_json::from_str allocates memory for the parsed data. If you read JSON from a file or network, use serde_json::from_slice with the raw bytes. This avoids copying data into a string first. For streaming huge files, consider a stream parser, but that adds complexity. Stick to from_slice for most cases.
Don't fight the schema. If the JSON changes, update the struct. Serde will tell you exactly what broke.
Decision: when to use this vs alternatives
Use serde_json::from_str when you have a JSON string and a known structure. This is the standard path for config files and API responses.
Use serde_json::from_slice when you already have the JSON bytes in a Vec<u8> or &[u8]. This avoids copying data if you read from a file or network buffer.
Use serde_json::Value when the structure is dynamic or unknown at compile time. This lets you drill down with get() and pattern matching, at the cost of runtime checks and performance.
Use #[serde(flatten)] when you have a struct with many fields and want to merge a nested object into the parent level. This keeps your code DRY when multiple structs share common fields.
Use #[serde(with = "...")] when you need custom serialization logic for a specific field, like parsing a timestamp or a custom ID format.
Use #[serde(tag = "...")] when your JSON uses internally tagged enums, where a key indicates the variant. This handles polymorphic data without manual matching.
Treat unwrap() as a promise. If you can't promise the JSON is valid, handle the error.