You have a string. You need data.
You're building a CLI tool that reads a configuration file. The file is JSON. You open it, read the text, and now you have a giant String. Strings are useless for logic. You can't check a boolean flag by looking at characters. You can't iterate over a list of servers by parsing brackets manually. You need to turn that text into something Rust can actually work with: structs, vectors, maps.
Or maybe you're writing a preprocessor for a documentation tool. You need to take JSON from standard input, tweak a few fields, and pipe the result back out to standard output. The input is a stream of bytes. The output must be valid JSON.
The bridge between raw JSON text and Rust data is serde_json. It doesn't just parse text; it connects JSON to Rust's type system. When you deserialize, the compiler checks that the JSON matches your types. When you serialize, the compiler guarantees the output is valid. You get safety without writing parsers.
The bridge: serde and serde_json
Rust's serialization ecosystem is split into two crates. serde provides the traits and macros that define how data converts to and from formats. serde_json implements those traits specifically for JSON. You'll always use them together.
Think of serde as a universal adapter and serde_json as the plug for a specific outlet. The adapter knows how to pack and unpack your data. The plug knows how to talk to JSON. You can swap serde_json for serde_yaml or serde_toml and your data structures stay the same. The adapter does the heavy lifting.
The core mechanism is the #[derive(Serialize, Deserialize)] macro. You attach this to a struct, and the macro generates the boilerplate code to convert between that struct and JSON. You don't write the conversion logic. The compiler generates it. This keeps your code fast and eliminates bugs in manual parsing.
Minimal example
Start with a simple struct and a JSON string. Add the dependencies to your Cargo.toml:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
The derive feature in serde is required. Without it, you can't use the macros.
use serde::{Deserialize, Serialize};
/// Represents a simple configuration for an application.
#[derive(Debug, Serialize, Deserialize)]
struct AppConfig {
/// The name of the application.
name: String,
/// Whether debug mode is enabled.
debug: bool,
/// List of allowed hostnames.
hosts: Vec<String>,
}
fn main() {
// Raw JSON string from a file or network response.
let json_input = r#"
{
"name": "my-app",
"debug": true,
"hosts": ["localhost", "127.0.0.1"]
}
"#;
// Deserialize the string into our struct.
// from_str returns a Result. We unwrap here for brevity.
let config: AppConfig = serde_json::from_str(json_input).expect("Valid JSON");
println!("Parsed config: {config:?}");
// Serialize the struct back to a pretty-printed JSON string.
let json_output = serde_json::to_string_pretty(&config).expect("Serialization failed");
println!("Serialized back:\n{json_output}");
}
The #[derive(Serialize, Deserialize)] line does the work. It tells serde to generate code that maps the struct fields to JSON keys. The field names must match the JSON keys exactly. name maps to "name". debug maps to "debug". If the JSON has a key that doesn't exist in the struct, serde_json ignores it by default. If the JSON is missing a key that exists in the struct, deserialization fails.
Trust the compiler here. If the types don't match, you get an error before the code runs. That error saves hours of debugging runtime crashes.
Real-world JSON is messy
JSON from the web rarely matches Rust naming conventions. JavaScript developers use camelCase. Rust uses snake_case. If you receive {"appName": "my-app"}, a struct with app_name will fail to deserialize. You could rename every field manually, but that's tedious.
Use the rename_all attribute to handle naming conventions automatically.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiUser {
/// The user's unique identifier.
user_id: u64,
/// The user's email address.
email_address: String,
/// Whether the account is active.
is_active: bool,
}
The rename_all attribute applies to all fields. user_id becomes "userId" in JSON. email_address becomes "emailAddress". This keeps your Rust code idiomatic while satisfying external APIs.
Missing fields are another common issue. APIs evolve. A field might be optional in the JSON but required in your struct. Use #[serde(default)] to provide a fallback value.
#[derive(Debug, Serialize, Deserialize)]
struct FeatureFlags {
/// Enable the new dashboard.
new_dashboard: bool,
/// Enable experimental features. Defaults to false if missing.
#[serde(default)]
experimental: bool,
}
If the JSON lacks "experimental", serde uses Default::default() for the type. For bool, that's false. For String, that's an empty string. For Vec, that's an empty vector. This makes your code resilient to incomplete data.
Sometimes you want the opposite behavior. You want to reject JSON that contains unexpected fields. This is useful for configuration files where typos in keys should fail fast. Use #[serde(deny_unknown_fields)].
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictConfig {
version: u32,
mode: String,
}
If the JSON contains "version": 1, "mode": "prod", "typo": true, deserialization fails. The error message points to the unknown field. This catches mistakes early.
Annotate your structs like you're writing a contract. The compiler enforces the terms.
When you don't know the shape
Structs work when you know the JSON structure at compile time. Sometimes you don't. You might be building a tool that inspects arbitrary JSON. You might be forwarding data without parsing it. In those cases, use serde_json::Value.
Value is an enum that represents any valid JSON value. It can be a null, a boolean, a number, a string, an array, or an object. It holds the data in memory without requiring a custom struct.
use serde_json::Value;
fn main() {
let json = r#"
{
"status": "ok",
"count": 42,
"tags": ["rust", "serde"],
"metadata": {
"source": "api"
}
}
"#;
let data: Value = serde_json::from_str(json).expect("Valid JSON");
// Access fields using indexing.
// Returns a reference to the Value.
let status = &data["status"];
println!("Status: {status}");
// Check the type before extracting.
if let Some(count) = data["count"].as_u64() {
println!("Count: {count}");
}
// Iterate over an array.
if let Some(tags) = data["tags"].as_array() {
for tag in tags {
if let Some(t) = tag.as_str() {
println!("Tag: {t}");
}
}
}
}
Indexing with data["key"] returns a &Value. If the key doesn't exist, it returns Value::Null. This is convenient but dangerous. You might treat a missing field as a null value without realizing it. Use get instead when you need to distinguish between missing and null.
// get returns Option<&Value>.
// None means the key is missing.
// Some(Value::Null) means the key exists but is null.
if let Some(metadata) = data.get("metadata") {
println!("Metadata exists: {metadata}");
} else {
println!("Metadata is missing");
}
Value is slower than structs. It requires heap allocations for every node. It also lacks compile-time type checking. You can access data["count"] and try to call .as_str() on it, and the code compiles. The check happens at runtime. Use Value only when you have to.
Dynamic JSON is a performance tax. Pay it only when you have to.
Pitfalls and errors
The compiler catches many mistakes, but JSON parsing has runtime risks. Deserialization returns a Result. If you ignore the error, your program panics on bad input. Always handle the Err case in production code.
If you forget to derive Deserialize, the compiler rejects the code with E0277 (the trait bound MyStruct: Deserialize is not satisfied). This is a compile-time error. Fix it by adding the derive macro.
If you derive Deserialize but the JSON types don't match the struct, you get a runtime error. For example, if the JSON has "count": "42" but the struct expects count: u64, from_str returns an error. The error message describes the mismatch. Log the error and return it to the caller. Don't unwrap in library code.
Type mismatches also trigger E0308 (mismatched types) if you try to assign a Value to a typed variable without conversion. For example, let count: u64 = data["count"]; fails because data["count"] is a Value, not a u64. You must extract the value using .as_u64() or similar methods.
Convention aside: when writing JSON to a file or standard output, use serde_json::to_writer instead of to_string. to_string allocates a String in memory, then you write that string to the file. to_writer serializes directly to the writer, avoiding the intermediate allocation. This saves memory and improves performance for large payloads.
use serde::Serialize;
use std::fs::File;
#[derive(Serialize)]
struct Data {
items: Vec<String>,
}
fn save_to_file(data: &Data, path: &str) -> Result<(), Box<dyn std::error::Error>> {
let file = File::create(path)?;
// to_writer serializes directly to the file.
// No intermediate String allocation.
serde_json::to_writer(file, data)?;
Ok(())
}
Decision: Choosing the right tool
Use serde_json::from_str when you already have a &str or String and performance isn't the bottleneck. Use serde_json::from_slice when you read raw bytes from a file or network and want to skip the UTF-8 validation step for a small speed gain. Use serde_json::Value when the JSON structure is dynamic, unknown at compile time, or you only need to inspect a few fields without defining a full struct. Use structs with #[derive(Serialize, Deserialize)] when you know the shape of the data; this gives you compile-time safety and zero-cost access to fields. Use #[serde(rename_all)] when the external JSON uses a different naming convention than Rust. Use #[serde(default)] when fields might be missing and you want sensible fallbacks. Use #[serde(deny_unknown_fields)] when you need strict validation of configuration files. Use serde_json::to_writer when serializing to files or streams to avoid allocating the full JSON string in memory.