When JSON meets Rust
You're building a CLI tool that reads a configuration file. The file is JSON. You open it, read the bytes, and stare at the string. Now you need to extract the database_url and the max_retries. You could write a regex. You could split by commas and parse braces. Or you could spend three hours debugging a custom parser that chokes on escaped quotes.
There's a better way. Rust has a standard ecosystem for this. It turns JSON blobs into safe structs with zero runtime overhead for the parsing logic itself. You define the shape of your data, and the compiler generates the parser for you.
The bridge between text and memory
JSON is text. It's a string of characters sitting in a file or a network buffer. Rust structs are typed memory layouts. You cannot cast a string to a struct. You need a translator.
That translator is serde. The name is short for "serialize/deserialize." Serialization turns your Rust data into JSON text. Deserialization turns JSON text back into Rust data.
Think of serde like a customs inspector. When data leaves Rust, serde packs it into JSON format and stamps it. When data enters, serde checks the JSON against your struct definition, verifies every field matches the expected type, and only then lets the data into your safe Rust world. If the JSON has a string where you expect a number, serde rejects it immediately. No silent corruption. No undefined behavior.
Rust has no reflection. You can't inspect a struct's fields at runtime. Serde works around this by using procedural macros. The #[derive] attribute runs a macro during compilation. That macro generates the serialization and deserialization functions for your specific struct. The generated code is as fast as if you wrote it by hand. There's no runtime overhead for type checking. The compiler bakes the logic into your binary.
Serde generates code at compile time. You get reflection-like flexibility with zero runtime cost.
Minimal example
You need two crates. serde is the framework that defines the traits. serde_json is the implementation for JSON. In your Cargo.toml, you must enable the derive feature on serde. Without it, the #[derive(Serialize, Deserialize)] macro won't work. This is a common first-day trap.
// Add to Cargo.toml:
// serde = { version = "1.0", features = ["derive"] }
// serde_json = "1.0"
use serde::{Deserialize, Serialize};
// Derive macros generate the boilerplate code for converting to/from JSON.
// Debug is just for printing in main.
#[derive(Serialize, Deserialize, Debug)]
struct Config {
host: String,
port: u16,
}
fn main() {
// Raw string literal avoids escaping quotes.
let json = r#"{"host": "localhost", "port": 8080}"#;
// from_str reads the JSON string and builds the struct.
// It returns a Result, so you must handle errors.
let config: Config = serde_json::from_str(json).expect("Valid JSON");
println!("{:?}", config);
// to_string converts the struct back to a JSON string.
// It also returns a Result.
let output = serde_json::to_string(&config).expect("Serialization failed");
println!("{}", output);
}
Pin your versions. Serde and serde_json move in lockstep.
Walkthrough: what happens under the hood
When you call serde_json::from_str, the parser scans the JSON token by token. It matches the opening brace to the struct. It finds "host", sees a string, and allocates a String. It finds "port", sees a number, and checks if it fits in a u16.
If the JSON says "port": 99999, the parser fails because 99999 overflows u16. The error tells you exactly which field failed. If the JSON says "port": "eight", the parser fails because it expected a number but found a string.
Serialization works in reverse. to_string walks the struct fields, formats them as JSON, and returns the string. The &config borrow is required because serialization reads the data but doesn't take ownership.
The Result type is mandatory. JSON parsing can fail for many reasons: malformed syntax, type mismatches, missing fields, or overflow. serde_json functions always return Result<T, serde_json::Error>. You must handle the error. Using .unwrap() is fine for examples, but production code should use ? or match on the error to provide context.
Realistic example: files, options, and conventions
Real code reads from files, handles missing fields, and deals with naming mismatches. JSON APIs often use camelCase. Rust uses snake_case. You don't want to rename every field manually.
use serde::{Deserialize, Serialize};
use std::fs;
// rename_all maps snake_case fields to camelCase in JSON automatically.
// This is a standard convention for interoperating with web APIs.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct User {
id: u64,
full_name: String, // Becomes "fullName" in JSON
// Option<T> handles missing fields gracefully.
// If "email" is absent in JSON, serde sets it to None.
email: Option<String>,
// skip tells serde to ignore this field entirely.
// Useful for internal state that shouldn't appear in JSON.
#[serde(skip)]
internal_cache: Vec<u8>,
}
/// Reads a JSON file and returns a vector of users.
/// Returns an error if the file is missing or the JSON is invalid.
fn load_users(path: &str) -> Result<Vec<User>, Box<dyn std::error::Error>> {
// read_to_string returns a String.
// The ? operator propagates IO errors up to the caller.
let content = fs::read_to_string(path)?;
// from_str parses the string into a Vec<User>.
// The turbofish syntax <Vec<User>> tells the compiler the target type.
let users: Vec<User> = serde_json::from_str(&content)?;
Ok(users)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"[
{"id": 1, "fullName": "Alice", "email": "alice@example.com"},
{"id": 2, "fullName": "Bob"}
]"#;
let users = serde_json::from_str::<Vec<User>>(json)?;
for user in &users {
println!("{} (id: {})", user.full_name, user.id);
}
// to_string_pretty adds indentation for human-readable output.
// Useful for config files or logs.
let pretty_json = serde_json::to_string_pretty(&users)?;
fs::write("users.json", pretty_json)?;
Ok(())
}
Use to_string for network payloads and APIs. It produces compact JSON with no whitespace, saving bandwidth. Use to_string_pretty for configuration files or logs that humans might read. The community convention is to default to compact unless you have a reason to format.
Default to compact JSON. Format only when humans need to read it.
Pitfalls and compiler errors
If you forget the #[derive(Serialize, Deserialize)] attribute, the compiler rejects you with E0277 (the trait bound Serialize is not satisfied). The struct exists, but it doesn't know how to convert to JSON. You must add the derive or implement the traits manually.
If your struct has a field that isn't in the JSON, deserialization fails. You get a MissingField error. To fix this, make the field Option<T> or add #[serde(default)]. The default attribute tells serde to use Default::default() for the field if it's missing.
If you read a file into bytes using std::fs::read, you get Vec<u8>. Using from_str requires converting bytes to a string, which can fail if the bytes aren't valid UTF-8. Use serde_json::from_slice instead. It parses directly from bytes and avoids the intermediate string allocation.
If you try to serialize a type that doesn't implement Serialize, you get E0277. Common culprits include std::path::Path or custom enums without the derive. Add the derive or use a wrapper type.
Read the error messages. Serde tells you exactly which field failed and why. Fix the struct. Move on.
Decision: when to use what
Use serde_json::from_str when you have a &str or String containing JSON.
Use serde_json::from_slice when you have &[u8] bytes, such as from std::fs::read. It avoids an intermediate string allocation and handles UTF-8 validation internally.
Use serde_json::to_string when you need compact JSON for APIs or storage.
Use serde_json::to_string_pretty when writing configuration files or logs for humans.
Use a typed struct when the JSON schema is known and stable. Structs give you compile-time safety, IDE autocomplete, and zero runtime type checks.
Use serde_json::Value when the JSON structure is dynamic or unknown at compile time. Value is an enum that holds any JSON type, but you lose type safety and pay a runtime cost for every access.
Use Option<T> for struct fields that might be missing in the JSON.
Use #[serde(default)] when a missing field should get a default value instead of failing.
Use #[serde(rename_all = "camelCase")] when the JSON uses camelCase and your Rust code uses snake_case.
Structs for known data. Value for chaos. The compiler will guide you.