Serde and JSON
Intermediate · Runtime & ecosystem
What & why
Serde is the crate the whole Rust world uses to turn structs into JSON (and back). Anytime your program talks to the outside — a web API, a config file, a saved game — it needs to convert between “data as text” and “data as Rust types.” Serde does that conversion for you, safely, from a struct you already wrote.
The idea, slowly
Two words: serialize and deserialize
The name Serde is just “Serialize + Deserialize” smashed together.
- Serialize = take a Rust value and write it out as text (or bytes). Struct → JSON string.
- Deserialize = read text back into a Rust value. JSON string → struct.
Think of a struct as a piece of furniture and JSON as the flat-pack box. Serializing is packing the furniture into the box to ship it. Deserializing is opening the box and assembling it again. Serde reads the “shape” of your struct and figures out the packing instructions automatically.
The magic line: #[derive(Serialize, Deserialize)]
You don’t write the packing code by hand. You put one attribute above your struct and Serde generates all of it at compile time:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
id: u64,
name: String,
}
fn main() {
let user = User { id: 7, name: String::from("Shamirul") };
// Rust struct -> JSON text
let json = serde_json::to_string(&user).unwrap();
println!("{json}"); // {"id":7,"name":"Shamirul"}
// JSON text -> Rust struct
let back: User = serde_json::from_str(&json).unwrap();
println!("{back:?}"); // User { id: 7, name: "Shamirul" }
}
This needs two external crates, so the Playground’s Run button won’t help here. In a real project,
add them to Cargo.toml and run cargo run:
cargo add serde --features derive
cargo add serde_json
Read the flow slowly: to_string takes a reference (&user) and gives back a String of
JSON. from_str takes JSON text and — because we annotated the variable as : User — knows what
type to build. Both return a Result, because the outside world can always hand you broken data;
we’ll .unwrap() here for learning, but real code handles the error.
Why a Result? Because deserializing can fail
Serializing your own struct basically never fails — you control the data. But deserializing
reads text from somewhere you don’t trust. If the JSON is missing a field, has the wrong type, or
is malformed, from_str returns an Err instead of crashing. That’s Serde protecting you: bad
input becomes a value you can handle, not a panic.
Renaming fields to match the outside world
Rust likes snake_case; lots of JSON APIs use camelCase. You bridge the gap with attributes so
your Rust stays idiomatic while the wire format stays whatever the API demands:
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct Product {
id: u64,
#[serde(rename = "displayName")] // JSON says displayName...
display_name: String, // ...Rust keeps snake_case
}
}
You can even rename the whole struct’s fields at once with
#[serde(rename_all = "camelCase")]. In a real backend like yours (Axum + SeaORM), the entity
structs derive Serialize/Deserialize exactly like this so that database rows become API JSON
with no hand-written conversion.
Optional and missing fields
The outside world is messy: sometimes a field is there, sometimes it isn’t. Model that with
Option<T>. If the JSON has the field, you get Some(value); if it’s absent, you get None
instead of an error:
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Settings {
theme: String,
nickname: Option<String>, // may or may not be present
}
}
This is how you plan for “extra or missing fields” without your program falling over.
Common mistakes
- Forgetting the
derivefeature onserde.#[derive(Serialize)]only exists if you add serde withfeatures = ["derive"]. Without it you get a confusing “cannot find derive macro” error even though serde is installed. - Field names not matching the JSON. Serde matches by field name. If the API sends
displayNameand your field isdisplay_name, deserializing fails until you add#[serde(rename = ...)]. It bites because the error appears at runtime, not compile time. - Making a field required when the source omits it. A plain
Stringfield must be present in the JSON. If the source sometimes drops it, useOption<String>— otherwise every request with that field missing errors out. - Calling
.unwrap()onfrom_strin real code. Deserialization handles untrusted input; unwrapping turns a recoverable “bad JSON” into a crash. Handle theResultinstead. - Forgetting
&when serializing.serde_json::to_string(&value)takes a reference; passing the value by move works too but often you still need it afterward, so borrow it.
More examples
Saving app settings as a readable config file
to_string_pretty formats the JSON with indentation and newlines, which matters when the output is a config file a human might open and edit by hand.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct AppConfig {
theme: String,
max_connections: u32,
}
fn main() {
let config = AppConfig { theme: "dark".to_string(), max_connections: 100 };
let json = serde_json::to_string_pretty(&config).unwrap();
println!("{json}");
}
Parsing a JSON array from an API response
An API rarely returns just one object — deserializing straight into a Vec<Product> turns a whole JSON array into a ready-to-use list in one call.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Product {
id: u64,
name: String,
}
fn main() {
let response = r#"[{"id":1,"name":"Keyboard"},{"id":2,"name":"Mouse"}]"#;
let products: Vec<Product> = serde_json::from_str(response).unwrap();
for p in &products {
println!("{}: {}", p.id, p.name);
}
}
Reading a webhook payload with an unpredictable shape
serde_json::Value skips defining a struct entirely — useful for a webhook where different event types carry different fields and you just need to pull out a couple of keys.
use serde_json::Value;
fn main() {
let payload = r#"{"event":"payment.succeeded","amount":2599,"currency":"usd"}"#;
let event: Value = serde_json::from_str(payload).unwrap();
println!("event: {}", event["event"]);
println!("amount: {}", event["amount"]);
}
Nested structs for an order payload
An order isn’t flat — it has a list of line items — and serde walks nested structs and Vecs automatically, no manual recursion required.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct LineItem {
sku: String,
quantity: u32,
}
#[derive(Serialize, Deserialize, Debug)]
struct Order {
order_id: u64,
items: Vec<LineItem>,
}
fn main() {
let order = Order {
order_id: 9001,
items: vec![
LineItem { sku: "SKU-1".to_string(), quantity: 2 },
LineItem { sku: "SKU-2".to_string(), quantity: 1 },
],
};
let json = serde_json::to_string(&order).unwrap();
println!("{json}");
}
Your turn
This is a fill-in-the-blank, since serde can’t run on the Playground. This struct should
accept JSON where the key is "user_name" and the bio field may be missing entirely. Fix the two
blanks.
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Account {
// JSON sends "user_name", but we want Rust-style naming here:
name: String, // <-- needs an attribute
bio: String, // <-- bio is sometimes absent
}
}
Show solution
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Account {
#[serde(rename = "user_name")] // map JSON "user_name" <-> Rust `name`
name: String,
bio: Option<String>, // absent -> None instead of an error
}
}
Why:
#[serde(rename = "user_name")]tells Serde the field is calleduser_namein the JSON, so it stops looking for a key namednameand stops failing.Option<String>makesbiooptional: present JSON givesSome("..."), missing JSON givesNone. WithoutOption, any JSON lackingbiowould fail to deserialize.
Quick check
Remember this
- Serde = Serialize (Rust → text) + Deserialize (text → Rust).
#[derive(Serialize, Deserialize)]generates all the conversion code for you at compile time.- Add serde with the
derivefeature, plusserde_jsonfor JSON:cargo add serde --features derive. serde_json::to_string(&value)andserde_json::from_str(text)both returnResult— deserializing untrusted input can fail.- Use
#[serde(rename = ...)]/rename_allto match outside naming, andOption<T>for fields that may be missing.
Go deeper
- Serde docs — Canonical ecosystem docs.
- serde_json docs — JSON support on docs.rs.
Next: