When strings become structs
You are building a web service. A client sends a request body like {"user": "alice", "score": 42}. Your handler receives this as a blob of bytes. You need to extract the username and score, validate them, and use them in business logic. Later, you calculate a result and need to send {"status": "ok", "result": 100} back to the client.
In many languages, you write parsers, check for nulls, and hope the types match. You might manually parse keys, convert strings to integers, and handle edge cases where a number arrives as a string. In Rust, you describe the shape of the data once. The compiler enforces the rest. You get type safety, validation, and zero boilerplate.
The ecosystem standard for this is serde. It handles serialization (struct to JSON) and deserialization (JSON to struct). You pair serde with serde_json for JSON support. The combination is fast, safe, and ubiquitous. Every major web framework in Rust uses it under the hood.
The shipping container analogy
Think of JSON as a shipping container. Your data lives inside Rust structs. To send data over the network, you pack the struct into a JSON string. That is serialization. When you receive a JSON string, you unpack it back into a struct. That is deserialization.
serde is the machinery that does the packing and unpacking. You do not write the packing logic by hand. You attach a label to your struct, and serde generates the code to handle the container. The label tells serde how many fields there are, what their names are, and what types they hold. The generated code walks the struct, converts each field to JSON, and assembles the string. On the receiving end, it parses the JSON and fills the struct field by field.
This approach eliminates manual parsing errors. If the JSON is missing a field, serde reports a clear error. If a type is wrong, serde rejects the data. You never accidentally treat a string as an integer. The compiler guarantees that your struct matches the data you expect.
Minimal example
Here is the basic pattern. You define a struct, derive the traits, and use serde_json functions to convert.
use serde::{Deserialize, Serialize};
/// Represents a user profile sent over the network.
#[derive(Debug, Serialize, Deserialize)]
struct UserProfile {
username: String,
age: u32,
}
fn main() {
// Incoming JSON from a client request.
let json_input = r#"{"username": "alice", "age": 28}"#;
// Deserialize: Parse JSON string into a UserProfile struct.
// serde_json::from_str returns Result<UserProfile, Error>.
// The compiler checks types at compile time.
let user: UserProfile = serde_json::from_str(json_input).unwrap();
println!("Received: {:?}", user);
// Serialize: Convert struct back to JSON string for response.
// serde_json::to_string returns Result<String, Error>.
let json_output = serde_json::to_string(&user).unwrap();
println!("Sending: {}", json_output);
}
The #[derive(Serialize, Deserialize)] attribute does the heavy lifting. It tells the compiler to generate implementations for the Serialize and Deserialize traits. The Debug derive is for printing. from_str takes a &str and returns a Result. unwrap panics if parsing fails. In production code, you handle the Result with match or the ? operator. to_string takes a reference to the struct and returns a Result<String, Error>.
How the derive macro works
The #[derive] syntax is not just a marker. It is a procedural macro. When you compile, the macro runs and generates the actual impl blocks. You can see the generated code by running cargo expand in your terminal.
The generated code walks every field of the struct. It calls the serializer for each field and handles the JSON syntax. This means serde is fast. It is not reflection. Reflection inspects types at runtime and is slow. serde generates code at compile time. The compiler optimizes the generated code just like your hand-written code. It inlines functions, eliminates bounds checks, and produces efficient machine code.
You can also implement Serialize and Deserialize manually. This is useful for complex types or when you need custom logic. Most of the time, the derive macro is sufficient. The macro supports attributes that customize behavior. You can rename fields, skip optional values, and handle enums.
The macro writes the code. The compiler optimizes the code. You get the safety of types with the speed of hand-written parsers.
Realistic example: Polymorphism and wrappers
Real APIs rarely send flat structs. You often need response wrappers, generic types, and polymorphic requests. serde handles these patterns elegantly.
use serde::{Deserialize, Serialize};
/// API response wrapper that includes metadata.
#[derive(Serialize)]
struct ApiResponse<T> {
success: bool,
data: T,
/// Omit error field if it is None to keep JSON clean.
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
/// Different types of items in the store.
/// Uses internally tagged enum to flatten JSON structure.
#[derive(Deserialize, Debug)]
#[serde(tag = "type", content = "payload")]
enum ItemRequest {
Book { isbn: String, title: String },
Game { id: u32, platform: String },
}
/// Handles a raw JSON request and returns a JSON response.
fn handle_request(body: &str) -> String {
// Parse the polymorphic request.
// serde_json::from_str returns Result<ItemRequest, Error>.
let request = match serde_json::from_str::<ItemRequest>(body) {
Ok(req) => req,
Err(e) => {
// Return a JSON error response if parsing fails.
let error_resp = ApiResponse {
success: false,
data: (),
error: Some(e.to_string()),
};
return serde_json::to_string(&error_resp).unwrap();
}
};
// Process based on variant.
let message = match request {
ItemRequest::Book { title, .. } => format!("Processing book: {}", title),
ItemRequest::Game { id, .. } => format!("Processing game ID: {}", id),
};
// Return success response.
let response = ApiResponse {
success: true,
data: message,
error: None,
};
serde_json::to_string(&response).unwrap()
}
This example shows several advanced patterns. ApiResponse<T> is a generic struct. It works with any type that implements Serialize. The skip_serializing_if attribute omits the error field when it is None. This keeps the JSON output clean. The ItemRequest enum uses tag and content attributes. This flattens the enum into a single JSON object with a type field and a payload field. Without these attributes, serde would nest the data inside an object named after the variant.
The handle_request function demonstrates error handling. It matches on the Result from from_str. If parsing fails, it returns an error response. This pattern is common in web handlers. You parse the input, handle errors, process the data, and return a response.
Annotate the struct, not the fields. Let rename_all do the heavy lifting.
Attributes and conventions
JSON keys often differ from Rust field names. Rust uses snake_case. APIs often use camelCase or PascalCase. You can rename fields individually with #[serde(rename = "userName")]. Or apply a rule to the whole struct with #[serde(rename_all = "camelCase")].
The community convention is to use rename_all at the struct level. It keeps the code clean and prevents drift when you add new fields. If you rename fields individually, you might forget to update a new field. rename_all ensures consistency.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UserData {
first_name: String,
last_name: String,
is_active: bool,
}
This struct serializes to {"firstName": "...", "lastName": "...", "isActive": ...}. The field names in Rust remain snake_case. The JSON keys are camelCase.
Another common attribute is deny_unknown_fields. By default, serde ignores extra fields in JSON. This is safe but can hide typos in client requests. If a client sends {"userName": "alice"} instead of {"username": "alice"}, serde ignores the typo and leaves the field as default or missing. Adding #[serde(deny_unknown_fields)] makes parsing strict. It fails if the JSON contains any field not defined in the struct. This is useful for validating client input.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictRequest {
username: String,
}
Use deny_unknown_fields when you want to enforce a strict contract. Use the default behavior when you want to be tolerant of client updates.
Convention aside: serde_json::from_slice takes &[u8] and avoids allocating a string. If you receive data as bytes from a socket, use from_slice. It is more efficient than converting to String first. Similarly, to_vec returns Vec<u8> and avoids string allocation. Use to_string only when you need a String.
Pitfalls and compiler errors
Forgetting the derive macro triggers E0277. The compiler complains that the type does not implement Serialize or Deserialize. The error message points to the function call and says the trait is not implemented. Add the derive or implement the trait manually.
Type mismatches do not cause compile errors. They cause runtime errors. If the JSON contains "age": "twenty" but the struct has age: u32, from_str returns an Err. The error message describes the mismatch. Always handle the Result. Using unwrap on user input crashes your server. Match on the result or use the ? operator to propagate the error.
let user: UserProfile = match serde_json::from_str(json_input) {
Ok(u) => u,
Err(e) => {
eprintln!("Parse error: {}", e);
return;
}
};
Generic types can cause trait bound errors. If you have a struct with a generic field, you need to constrain the generic parameter.
#[derive(Serialize)]
struct Wrapper<T: Serialize> {
value: T,
}
The T: Serialize bound tells the compiler that T must implement Serialize. Without the bound, you get E0277 when you try to serialize Wrapper.
Nested structs work automatically. If a field is a struct that implements Serialize, serde serializes it recursively. You do not need to do anything special. Enums work too. serde serializes enums as strings by default. You can change this with attributes.
Optimize for correctness first. serde is fast enough for almost everything. Profile before you reach for streaming or zero-copy.
Decision matrix
Use serde_json when you need to parse or generate standard JSON. It is the standard library for JSON in the Rust ecosystem. It is fast, well-tested, and supported by all major frameworks.
Use serde with a different format crate when you need YAML, TOML, or MessagePack. serde is the engine. serde_json is just one adapter. Swap serde_json for serde_yaml and the same structs work. This makes it easy to support multiple formats.
Use serde_json::Value when the JSON structure is dynamic or unknown at compile time. Value is an enum that represents any JSON value. It is slower than typed structs but flexible. You can inspect keys and values at runtime. Use it for configuration files or debugging tools.
Use manual parsing only when you are processing gigabytes of streaming data and need zero-allocation performance. For 99% of applications, serde is fast enough and safer. Manual parsing requires unsafe code and careful lifetime management. The performance gain is rarely worth the complexity.
Pick the tool that matches your data shape. Structs for known data. Value for chaos. serde for the engine.