The missing JSON button
You come from JavaScript. You have an object. You call JSON.stringify(obj) and get a string. You switch to Rust. You have a struct. You look for a to_json method. It's not there. The standard library doesn't know what JSON is. Rust treats data formats as external concerns. The core language focuses on memory safety and performance, not on parsing text protocols.
This separation is intentional. Rust avoids baking in opinions about data formats. Instead, the ecosystem converged on a single, powerful abstraction. You don't write code to serialize JSON. You write code to describe your data. Then you plug in a crate that knows how to turn that description into JSON, YAML, TOML, or binary formats.
The crate is serde. The name stands for "serialization framework." It defines the traits. Other crates implement the formats. Your structs implement the traits once. You can swap formats without touching your data model. This is the "write once, serialize anywhere" pattern.
serde is the translator, not the format
serde provides two core traits: Serialize and Deserialize. A type that implements Serialize can be turned into a stream of data. A type that implements Deserialize can be reconstructed from a stream. Most of the time, you want both.
You rarely implement these traits by hand. serde provides a procedural macro that generates the implementations for you. You add #[derive(Serialize, Deserialize)] to your struct, and the compiler writes the code. The macro inspects your fields, checks their types, and emits trait implementations that call into the format crate.
This approach has a major advantage. The code generation happens at compile time. There is no reflection. There is no runtime type introspection. The generated code is as fast as if you had written the serialization logic manually. You get flexibility without paying a performance penalty.
use serde::{Serialize, Deserialize};
/// Configuration for a network service.
#[derive(Serialize, Deserialize)]
struct ServiceConfig {
host: String,
port: u16,
debug: bool,
}
fn main() {
let config = ServiceConfig {
host: "localhost".to_string(),
port: 8080,
debug: true,
};
// Serialize to a JSON string.
let json = serde_json::to_string(&config).unwrap();
println!("Serialized: {}", json);
// Deserialize back to the struct.
let parsed: ServiceConfig = serde_json::from_str(&json).unwrap();
println!("Parsed port: {}", parsed.port);
}
The #[derive(...)] attribute triggers the macro. The macro generates impl Serialize for ServiceConfig and impl Deserialize for ServiceConfig. The serde_json::to_string function takes any type that implements Serialize. The serde_json::from_str function returns any type that implements Deserialize. The format crate bridges your type to the JSON syntax.
Check Cargo.toml for the derive feature before you question reality. The macro is optional. If you add serde to dependencies without the feature, the derive macro won't exist. The compiler will complain that the attribute is unknown.
Real code handles errors and naming
The example above uses unwrap(). That's fine for a quick test. Real code handles errors. Serialization and deserialization can fail. The input string might be malformed. The types might not match. The serde_json functions return Result types. You need to handle the Err case.
Web APIs often use camelCase for field names. Rust uses snake_case. If you derive serialization directly, your JSON will have snake_case keys. The API might reject it. You can fix this with attributes. serde provides a rich set of attributes to customize serialization without writing manual code.
use serde::{Serialize, Deserialize};
/// User data from an external API.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ApiUser {
user_id: u64,
email_address: String,
is_active: bool,
/// Fields marked with skip_serializing_if are omitted when the predicate is true.
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
}
fn process_user(json_input: &str) -> Result<ApiUser, serde_json::Error> {
// from_str returns a Result. Use the ? operator to propagate errors.
let user: ApiUser = serde_json::from_str(json_input)?;
Ok(user)
}
fn main() {
let json = r#"{"userId": 123, "emailAddress": "test@example.com", "isActive": true}"#;
match process_user(json) {
Ok(user) => println!("Got user: {:?}", user),
Err(e) => eprintln!("Parse failed: {}", e),
}
}
The #[serde(rename_all = "camelCase")] attribute applies to all fields. user_id becomes userId. email_address becomes emailAddress. This saves you from annotating every field individually. The #[serde(skip_serializing_if = "Option::is_none")] attribute omits the field when the value is None. This keeps the output clean and matches common API conventions.
Attributes are your first line of defense against format friction. Write manual impls only when attributes run out of steam. The attribute system covers most real-world needs, including renaming, defaults, skipping, and custom serializers for specific fields.
When you don't know the shape
Sometimes you receive JSON from an external source and you don't want to define a struct for it. The structure might be dynamic. The schema might change frequently. You can use serde_json::Value. This type represents any JSON value. It's an enum with variants for objects, arrays, strings, numbers, booleans, and null.
use serde_json::Value;
fn check_config(key: &str, json: &str) -> Option<String> {
let value: Value = serde_json::from_str(json).ok()?;
// Navigate the Value tree safely.
value.get("settings")
.and_then(|s| s.get(key))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn main() {
let json = r#"{"settings": {"timeout": 30, "mode": "fast"}}"#;
if let Some(mode) = check_config("mode", json) {
println!("Mode is {}", mode);
}
}
Value is flexible but comes with trade-offs. You lose type safety. The compiler can't check if you're accessing the right keys or types. You get runtime errors instead of compile-time errors. Parsing into Value is also slower than parsing into a typed struct because it allocates nodes for the entire tree.
Use Value for truly dynamic data or as a temporary measure during prototyping. Don't use it as a crutch for lazy design. If the shape is stable, define a struct. The type system will catch mismatches before the code runs.
Dynamic data is a smell. Reach for Value sparingly and convert to typed structs as soon as possible.
Pitfalls that trip up beginners
Missing the derive feature. This is the most common error. You add serde to Cargo.toml, add #[derive(Serialize)], and the compiler says the attribute is not found. The derive macro is behind a feature flag. You need serde = { version = "1.0", features = ["derive"] }. Without the feature, the crate compiles but the macro is missing.
Borrowing lifetimes. serde_json::from_str borrows the input string. If your struct contains &str fields, the deserialized struct borrows from the input. The struct cannot outlive the JSON string. If you need owned data, use String fields. The deserializer will allocate new strings. This is a common source of lifetime errors. If you see E0597 (borrowed value does not live long enough), check if your struct borrows from the input when it should own the data.
Trait bounds. If you try to serialize a type that doesn't implement Serialize, the compiler rejects the code with E0277 (trait bound not satisfied). This happens when you have a field of a type that serde doesn't know about. You need to derive the traits on that type, or implement them manually, or skip the field.
Error handling. serde_json::Error implements std::error::Error. You can use the ? operator to propagate errors. The error messages are detailed. They include the line and column where the parse failed, and what was expected. Log these errors. They help debug integration issues.
Enums. Enums serialize to JSON objects by default. A variant Status::Active becomes {"Active": null}. A variant Status::Pending(String) becomes {"Pending": "reason"}. This is the externally tagged representation. It's explicit and unambiguous. If you need a different format, like a plain string, use #[serde(untagged)] or #[serde(tag = "...")]. Untagged enums can be ambiguous during deserialization. The deserializer tries variants in order and stops at the first match. This can lead to subtle bugs if variants overlap.
Treat serde errors as data validation failures, not just parsing bugs. The error message tells you exactly where the contract broke.
Decision matrix
Use #[derive(Serialize, Deserialize)] for standard structs and enums where the data shape matches the format. This is the default for 99% of use cases. The macro generates efficient, correct code with zero boilerplate.
Use serde_json::Value when the input structure is dynamic or unknown at compile time, accepting the trade-off of losing type safety and incurring higher allocation overhead.
Use #[serde(rename_all = "...")] when the external format uses a different naming convention than Rust's snake_case. Apply this at the struct level to avoid annotating every field.
Use manual impl Serialize only when attribute-based customization cannot express the required logic, such as complex conditional serialization based on internal state that spans multiple fields.
Reach for serde with a specific format crate like serde_yaml or serde_cbor when you need to switch formats without rewriting your data model. Your structs stay the same. You only change the serialization function calls.