The everyday JSON job
You're writing a Rust service that takes some data, does something useful with it, and needs to spit out the result as JSON. Maybe it's a CLI that prints structured logs. Maybe it's an HTTP handler returning a payload. Maybe it's reading config from a file at startup. Whatever the shape, you keep bumping into JSON, and you need a tool that handles it without ceremony.
That tool is serde_json. It's the JSON arm of Rust's wider serialization framework, serde. The split is deliberate: serde defines the abstract idea of "turning data into bytes" with traits like Serialize and Deserialize. serde_json is one concrete implementation of that idea, specialised for JSON. There are sibling crates for YAML, TOML, MessagePack, and more, all sharing the same derive macros. Learn one, and the rest come almost for free.
Adding it to your project
Two crates, both pinned to recent stable versions:
[dependencies]
# The trait machinery and derive macros. The "derive" feature enables
# #[derive(Serialize, Deserialize)] on your own types.
serde = { version = "1", features = ["derive"] }
# The JSON-specific encoder and decoder, plus the Value type and json! macro.
serde_json = "1"
That's it. No build script, no native dependencies. cargo build will compile both and you're ready.
Three flavours of "use serde_json"
There are essentially three ways to work with JSON in Rust, and which one you reach for depends on whether you know the shape of the data ahead of time.
Strongly typed. You define a struct that mirrors the JSON, derive the traits, and let the library map fields by name. This is what you want for almost everything: it's fast, type-checked, and the compiler tells you when the shape changes.
Loosely typed. You use serde_json::Value, a tagged enum that can be a number, string, array, object, etc. You poke at it with value["users"][0]["name"]. Useful when the shape varies, or when you only care about a few fields out of many.
Direct streaming. For huge files or network sockets where you don't want to hold the whole document in memory.
We'll touch on all three.
A first example: turning a struct into JSON
use serde::Serialize;
// The derive macro generates code that knows how to walk this struct's fields
// and emit them as JSON keys. Field names match by default; you can rename
// individual fields with #[serde(rename = "...")].
#[derive(Serialize)]
struct Cat {
name: String,
age: u8,
indoor: bool,
}
fn main() {
let cat = Cat {
name: "Mittens".into(),
age: 4,
indoor: true,
};
// to_string produces a compact JSON string. to_string_pretty does the same
// with indentation. Both return Result because serialization can fail
// (e.g. a HashMap with non-string keys can't become a JSON object).
let json = serde_json::to_string(&cat).unwrap();
println!("{json}");
}
Output:
{"name":"Mittens","age":4,"indoor":true}
The #[derive(Serialize)] is doing real work. At compile time, it generates a method that visits each field and tells serde_json "this is a string, this is a u8, this is a bool." No reflection, no runtime overhead beyond the actual encoding.
Going the other way: parsing JSON into a struct
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Cat {
name: String,
age: u8,
// #[serde(default)] tells the deserializer "if the JSON omits this field,
// use the type's Default::default(). For bool, that's false." Without it,
// a missing field causes a hard error.
#[serde(default)]
indoor: bool,
}
fn main() {
let raw = r#"{"name": "Whiskers", "age": 7}"#;
// from_str parses a complete JSON document. There's also from_slice for
// &[u8] and from_reader for any std::io::Read.
let cat: Cat = serde_json::from_str(raw).unwrap();
println!("{cat:?}");
}
Note what happened with the indoor field. The JSON didn't include it. Without #[serde(default)], you'd get an error like "missing field indoor". With it, you get false and life moves on. Small attribute, big quality-of-life upgrade.
Writing JSON straight to a stream
When the destination is a file, a socket, or stdout, going through an intermediate String is wasteful. to_writer and to_writer_pretty push bytes directly into anything that implements std::io::Write:
use std::io;
use serde::Serialize;
#[derive(Serialize)]
struct Status<'a> {
service: &'a str,
healthy: bool,
}
fn main() -> serde_json::Result<()> {
let status = Status { service: "api", healthy: true };
// io::stdout() is buffered when used through a lock, but for a single
// small write the unlocked handle is fine. For high-throughput writes,
// wrap stdout in BufWriter::new(io::stdout().lock()) first.
serde_json::to_writer(io::stdout(), &status)?;
// No trailing newline is added. If you want one, write it yourself.
println!();
Ok(())
}
to_writer returns serde_json::Result<()>, which is an alias for Result<(), serde_json::Error>. The ? operator turns any error (an I/O failure, a non-string map key, an invalid float like NaN) into an early return.
The loose-typed escape hatch
Sometimes the JSON is wild. Each request has a different shape. Or you're writing a quick script and don't want to define a struct. Reach for Value:
use serde_json::{json, Value};
fn main() {
// The json! macro is a literal: it parses Rust-flavoured JSON at compile
// time and produces a serde_json::Value at runtime. Great for tests
// and ad-hoc payloads.
let blob: Value = json!({
"user": {
"name": "Sam",
"roles": ["admin", "billing"]
}
});
// Index into Value with strings or integers. Returns &Value, which can be
// Null if the path doesn't exist. .as_str() / .as_i64() / etc. extract
// the typed view, returning Option.
let name = blob["user"]["name"].as_str().unwrap_or("anonymous");
let role_count = blob["user"]["roles"].as_array().map_or(0, |a| a.len());
println!("{name} has {role_count} roles");
}
Value is convenient but slower and weaker. Every access is a dictionary lookup, and the compiler can't tell you when you misspell "name" as "nmae". Use it sparingly, for the parts of your data that really are dynamic.
Common pitfalls
The error message missing field "x" is the most common parsing failure. Either add #[serde(default)], or change the field type to Option<T> so null and "absent" both map to None.
Floats and NaN. JSON has no representation for NaN or Infinity. If your struct has an f64 and that field happens to be NaN at serialize time, serde_json returns an error rather than emitting invalid JSON. Catch it, or coerce non-finite floats before serialising.
Untagged enums and ambiguity. If you have enum Action { Stop, Pause(u32) } and ask for #[serde(untagged)], the deserializer has to guess based on shape. That works until two variants overlap, at which point it picks the first match silently. Use #[serde(tag = "kind")] for an explicit discriminator field whenever you can.
Forgetting the derive feature. If you write use serde::Serialize; and the compiler complains:
error: cannot find derive macro `Serialize` in this scope
--> src/main.rs:3:10
|
3 | #[derive(Serialize)]
| ^^^^^^^^^
You forgot features = ["derive"] on the serde line in Cargo.toml. The macros are gated behind that feature flag.
When to reach for what
If you control the schema and own the structs, derive Serialize and Deserialize on them. It's the fastest, safest, most readable option. If the schema is alien or wildly variable, use Value. If the document is gigantic and you only need a few fields, look at streaming parsers like serde_json::Deserializer::from_reader chained with into_iter, or step up to a pull-parser like struson for true streaming.
For a deeper walkthrough of the Serialize/Deserialize derive and how to customise field names, default values, and skipping, see the linked articles below. They build on the same foundations.