How to handle JSON in Rust web server
You are building an API endpoint. A client sends a POST request with a JSON body. Your handler receives a blob of bytes or a string. You need to extract the user_id and email to save them to the database. If you try to parse that string manually, you will spend three days writing regex and handling edge cases. Rust gives you a better way. You derive traits, and the compiler generates the parsing code for you.
The serde ecosystem
Rust handles JSON through the serde framework. The name stands for "serialization framework." serde does not know about JSON. It is a generic engine that converts Rust data structures to and from formats. serde_json is the plugin that teaches serde how to speak JSON. You add both to your project.
Serialization turns your Rust data into JSON text. Deserialization does the reverse. You mark your structs with #[derive(Serialize, Deserialize)]. The macro expands your code to include the logic that walks your struct field by field and writes or reads JSON tokens. This happens at compile time. There is no reflection at runtime. The generated code is as fast as hand-written parsing.
Derive the traits. Let the compiler write the boilerplate.
Minimal example
Add serde and serde_json to your Cargo.toml. You need the derive feature for serde to use the macros.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
Define a struct and derive the traits. The Serialize trait lets you convert the struct to JSON. The Deserialize trait lets you convert JSON to the struct.
use serde::{Deserialize, Serialize};
/// Represents a user in the system.
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: u32,
name: String,
}
fn main() {
// Create a Rust struct instance.
let user = User { id: 1, name: "Alice".to_string() };
// Serialize to a JSON string.
// unwrap() panics if serialization fails, which is rare for simple types.
let json = serde_json::to_string(&user).unwrap();
println!("Serialized: {}", json);
// Deserialize back to the struct.
// The type annotation tells serde which struct to populate.
let parsed: User = serde_json::from_str(&json).unwrap();
println!("Deserialized: {:?}", parsed);
}
Convention aside: The community often groups Debug and Clone with the serde derives. A common pattern is #[derive(Debug, Clone, Serialize, Deserialize)]. Clone is useful in web apps where you pass data between async tasks. If you only serialize or only deserialize, derive only the trait you need. Deriving both adds compile time overhead for nothing.
What happens under the hood
When you compile, the #[derive] macro runs. It inspects your User struct and generates two functions. One walks the fields and writes JSON. The other reads JSON tokens and constructs the struct. This code lives in your binary. At runtime, to_string allocates a String buffer and writes the JSON bytes. from_str scans the input, matches tokens to fields, and returns a Result.
If the JSON is missing a field or has the wrong type, from_str returns an Err. The compiler forces you to handle it. You cannot ignore the error. This prevents silent data corruption.
Realistic web handler
In a web server, you rarely parse a string directly. You receive bytes from the network. You also need to return errors to the client. A realistic handler deserializes the request, performs business logic, and serializes the response.
use serde::{Deserialize, Serialize};
/// Input payload from the client.
#[derive(Debug, Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}
/// Response sent back to the client.
#[derive(Debug, Serialize)]
struct CreateUserResponse {
id: u32,
message: String,
}
/// Simulates a web handler processing a JSON body.
fn handle_create_user(body: &str) -> Result<String, serde_json::Error> {
// Deserialize the request body.
// Returns an error if the JSON is invalid or fields are missing.
let request: CreateUserRequest = serde_json::from_str(body)?;
// Business logic would go here.
let new_id = 42;
// Build the response struct.
let response = CreateUserResponse {
id: new_id,
message: format!("User {} created", request.name),
};
// Serialize the response to JSON.
serde_json::to_string(&response)
}
Convention aside: Web APIs expect camelCase keys. Rust uses snake_case. The community convention is to use #[serde(rename_all = "camelCase")] on every API struct. It saves you from writing rename on every field and keeps your Rust code idiomatic.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateUserRequest {
first_name: String, // Maps to "firstName" in JSON
email_address: String, // Maps to "emailAddress" in JSON
}
Handle the error. The client will send garbage.
Pitfalls and compiler errors
If you try to serialize a type without Serialize, the compiler rejects it with E0277 (the trait bound Serialize is not satisfied). You must derive or implement the trait. This error often appears when you wrap a type in a container that does not implement the trait.
At runtime, deserialization fails if the JSON does not match the struct. serde_json::from_str returns a Result. If the client sends {"name": "Alice"} but your struct requires email, you get a missing field email`` error. If the client sends {"id": "not_a_number"} for a u32 field, you get invalid type: string, expected u32. You must handle these errors. Returning a 400 Bad Request is standard.
Unwrapping in a handler is a time bomb. If a client sends malformed JSON, unwrap panics and crashes your worker thread. In a multi-threaded server, this can exhaust resources. Map the error to an HTTP response instead.
Advanced attributes
Structs often need more flexibility than a one-to-one mapping. serde provides attributes to control behavior.
Use #[serde(default)] when a field is optional in the API but has a sensible default in Rust. If the JSON omits the field, serde uses Default::default() for that field instead of erroring.
#[derive(Deserialize)]
struct Config {
timeout: u32,
#[serde(default)]
retries: u32, // Defaults to 0 if missing
}
Use #[serde(skip_serializing_if = "Option::is_none")] to omit optional fields from the output when they are None. This keeps the JSON clean.
#[derive(Serialize)]
struct UserResponse {
id: u32,
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
}
Use #[serde(flatten)] to merge nested objects into the parent. This is useful when you want to expose fields from a sub-struct directly.
#[derive(Serialize)]
struct Address {
city: String,
zip: String,
}
#[derive(Serialize)]
struct User {
name: String,
#[serde(flatten)]
address: Address, // Fields appear at top level
}
Trust the attributes. They give you precise control without manual parsing.
Performance considerations
serde_json is fast, but it allocates memory for the parsed data. In high-throughput servers, allocation can become a bottleneck. The simd-json crate uses SIMD instructions to parse JSON faster and with fewer allocations. It is compatible with serde.
Use simd-json when profiling shows JSON parsing is the bottleneck. It requires the serde feature flag. The API is similar, but you call simd_json::from_slice instead of serde_json::from_slice.
Keep serde_json for correctness and ecosystem compatibility. Switch to simd-json only when you have measured data proving it helps. Premature optimization adds complexity.
Decision matrix
Use serde_json::from_str when you have a String or &str and need a typed struct. Use serde_json::from_slice when you have &[u8] or Bytes from a network request; it avoids allocating a string intermediate. Use serde_json::to_string when you need a String for logging or storage. Use serde_json::to_vec when you are writing directly to a socket or response body; it avoids the string allocation. Use serde_json::Value when the JSON structure is dynamic or unknown at compile time. Use a typed struct when the schema is fixed; it gives you compile-time checks and faster parsing. Use #[serde(default)] when a field is optional in the API but has a sensible default in Rust. Use #[serde(skip_serializing_if = "Option::is_none")] to omit optional fields from the output when they are None. Use simd-json when measured profiling shows JSON parsing is the bottleneck and you need maximum throughput.
Pick the right function for the buffer you have. Avoid the string allocation when you can.