Serde and JSON
Almost every real-world application needs to exchange data — with a REST API, a config file, or a database. In Rust, the serde ecosystem is the standard answer. It provides a powerful framework for serializing and deserializing data structures with minimal boilerplate.
What Is Serde?
Serde is a framework, not a format. It separates the data model (your Rust structs and enums) from the data format (JSON, TOML, YAML, MessagePack, etc.). The same #[derive(Serialize, Deserialize)] annotation works across all formats.
For JSON specifically, you add two crates to your Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
The derive feature unlocks the procedural macros that generate serialization logic automatically.
Before you look for a Run button. Serde is not part of the standard library, and neither is any JSON support — Rust ships no
std::json. Every serde example on this page is therefore shown as static code rather than a runnable snippet: the sandbox behind the Run button is a barerustcwith no Cargo and no dependency resolution, souse serde::Serialize;there fails withE0432: unresolved import. The code is correct and idiomatic — put those two lines in a realCargo.toml,cargo run, and it behaves exactly as described — and each example quotes its real output so you can see what it produces. The last section of the lesson then does the same job with nothing butstd, in snippets you really can run, which is worth doing for its own sake: the capstone CLI has to emit JSON, and hand-rolling it is worth doing once so you can see what serde does for you.
Basic Serialization
Serialization converts a Rust value into another format — in this case, a JSON string. You derive Serialize on any struct or enum you want to convert:
use serde::Serialize;
use serde_json;
#[derive(Serialize)]
struct User {
id: u32,
name: String,
email: String,
active: bool,
}
fn main() {
let user = User {
id: 1,
name: String::from("Alice"),
email: String::from("alice@example.com"),
active: true,
};
// Serialize to a compact JSON string
let json = serde_json::to_string(&user).unwrap();
println!("{}", json);
// Or pretty-print with indentation
let pretty = serde_json::to_string_pretty(&user).unwrap();
println!("{}", pretty);
}
In a Cargo project with those two dependencies, that prints the compact form first and the indented form second:
{"id":1,"name":"Alice","email":"alice@example.com","active":true}
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"active": true
}
The output from to_string_pretty looks exactly like the JSON you'd write by hand, with field names matching your struct fields and two-space indentation. Note that the field order follows your struct's declaration order — serde emits fields in the order you wrote them, which makes a serialized struct diff-friendly.
There is one thing serde does for a number that is easy to assume the standard library does too. A JSON number for a f64 field must round-trip back as a number, and serde writes 399.0 for a price of 399.00 — you will see exactly that a few sections down. The snippet below writes the same f64 into the same JSON slot twice, once with {} and once with {:?}, using nothing but format!. Commit to whether the two strings come out identical before you run it.
Predict
Both lines build the same JSON object from the same f64 price of 399.00 — one interpolates it with a plain {} placeholder and one with {:?}. Write down the two strings and the value of equal before you run this.
struct Product {
name: String,
price: f64,
}
fn main() {
let p = Product { name: String::from("Monitor"), price: 399.00 };
// Two ways to put the same f64 into a JSON number position.
let with_display = format!("{{\"name\":\"{}\",\"price\":{}}}", p.name, p.price);
let with_debug = format!("{{\"name\":\"{}\",\"price\":{:?}}}", p.name, p.price);
println!("display = {}", with_display);
println!("debug = {}", with_debug);
println!("equal = {}", with_display == with_debug);
}The two strings differ: {} produces "price":399 and {:?} produces "price":399.0, so equal is false. Display for an f64 prints the shortest text that parses back to the same value, which drops a trailing .0 entirely; Debug keeps it so a float never looks like an integer. Both are valid JSON numbers — JSON has a single number type — but serde_json writes the 399.0 form, which is why the price in the error-handling section below appears as 399.0, while the hand-rolled writer at the end of this lesson, built on format!("{}"), will write -3 for a celsius of -3.0. Keep that asymmetry in mind; it is a real diff between serde's output and your own.
Basic Deserialization
Deserialization is the reverse — parsing JSON text back into a typed Rust value. Derive Deserialize and call serde_json::from_str:
use serde::Deserialize;
use serde_json;
#[derive(Deserialize, Debug)]
struct Config {
host: String,
port: u16,
max_connections: u32,
debug: bool,
}
fn main() {
let json = r#"
{
"host": "localhost",
"port": 8080,
"max_connections": 100,
"debug": false
}
"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("Connecting to {}:{}", config.host, config.port);
println!("Max connections: {}", config.max_connections);
println!("Debug mode: {}", config.debug);
}
In a Cargo project this prints:
Connecting to localhost:8080
Max connections: 100
Debug mode: false
Notice the r#"..."# raw string literal — it lets you embed quotes and backslashes without escaping them, which is convenient for inline JSON. That is not a serde feature but a Rust one, and it works without any crate at all; you will use it again in the std-only section below, where hand-written JSON strings appear constantly.
Controlling Field Names and Behaviour
Serde attributes let you customize how fields are mapped without changing your Rust naming conventions. The most common is #[serde(rename_all)] for switching between naming conventions, and #[serde(rename)] for individual fields.
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")] // snake_case in Rust, camelCase in JSON
struct ApiResponse {
user_id: u32,
first_name: String,
last_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
profile_picture: Option<String>,
#[serde(default)] // use Default::default() if field is missing
follower_count: u64,
}
fn main() {
// Deserialize from camelCase JSON (e.g. from a JavaScript API)
let json = r#"{"userId":42,"firstName":"Bob","lastName":"Smith","followerCount":1500}"#;
let resp: ApiResponse = serde_json::from_str(json).unwrap();
println!("{:?}", resp);
// Serialize back — profile_picture is None so it won't appear in output
let out = serde_json::to_string(&resp).unwrap();
println!("{}", out);
}
In a Cargo project this prints:
ApiResponse { user_id: 42, first_name: "Bob", last_name: "Smith", profile_picture: None, follower_count: 1500 }
{"userId":42,"firstName":"Bob","lastName":"Smith","followerCount":1500}
Two things are worth reading off that output. The Debug line shows Rust snake_case field names, because that is what the struct declares; the JSON line shows camelCase, because that is what rename_all asked for on the way out. And profilePicture is absent from the JSON entirely rather than present as null — that is skip_serializing_if doing its job, which matters because "field missing" and "field present and null" are genuinely different things to many APIs.
Key attributes used here:
#[serde(rename_all = "camelCase")]— applies a naming convention to all fields#[serde(skip_serializing_if = "Option::is_none")]— omitsnullfields from output#[serde(default)]— allows missing JSON keys by falling back toDefault::default()
rename_all = "camelCase" is doing a small string transformation on every field name, and writing that transformation by hand is the fastest way to find out whether you actually know what camelCase is. The program below is a std-only version of exactly that rule, applied to the same four field names. It compiles, it runs, nothing panics — and every key it produces is wrong in the same way. Say out loud which rule it is breaking before you change a character:
Debug
This is the rename_all = 'camelCase' rule written out in std: split a snake_case field name on underscores and rejoin it. It runs clean, and every key it produces is wrong in the same way — 'UserId' where serde would have produced 'userId'. camelCase is two rules, not one; name the one this loop never applies, then fix it so every key matches what serde emits.
/// Turns a Rust snake_case field name into the camelCase key that
/// #[serde(rename_all = "camelCase")] would emit.
fn to_camel_case(field: &str) -> String {
let mut out = String::new();
for part in field.split('_') {
let mut chars = part.chars();
if let Some(first) = chars.next() {
out.push(first.to_ascii_uppercase());
out.push_str(chars.as_str());
}
}
out
}
fn main() {
let fields = ["user_id", "first_name", "profile_picture", "id"];
let keys: Vec<String> = fields.iter().map(|f| to_camel_case(f)).collect();
let pairs: Vec<String> = keys.iter().map(|k| format!("\"{}\":null", k)).collect();
for (field, key) in fields.iter().zip(keys.iter()) {
println!("{} -> {}", field, key);
}
println!("{{{}}}", pairs.join(","));
}Expected output: user_id -> userId
first_name -> firstName
profile_picture -> profilePicture
id -> id
{"userId":null,"firstName":null,"profilePicture":null,"id":null}
The function uppercases the first letter of every segment, which is PascalCase; camelCase leaves the first segment alone. The single-segment field id makes it obvious — it comes back as Id when serde would emit id. The fix is .enumerate() over the split so the loop knows which segment it is on, pushing the first character unchanged when i == 0 and uppercased otherwise. Nothing here is a Rust error: it is a correct implementation of the wrong specification, which is precisely why rename_all is worth reaching for — you name the convention once and no hand-written transformation gets to disagree with itself between two structs.
Handling Errors Properly
In production code you should propagate errors rather than calling unwrap. Serde's error type implements std::error::Error, so it composes naturally with ?:
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
struct Product {
id: u32,
name: String,
price: f64,
}
fn parse_product(input: &str) -> Result<Product, serde_json::Error> {
serde_json::from_str(input)
}
fn to_json(product: &Product) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(product)
}
fn main() {
// Valid JSON
let valid = r#"{"id": 7, "name": "Keyboard", "price": 129.99}"#;
match parse_product(valid) {
Ok(p) => println!("Parsed: {:?}", p),
Err(e) => eprintln!("Parse error: {}", e),
}
// Invalid JSON — missing required field
let invalid = r#"{"id": 8, "name": "Mouse"}"#;
match parse_product(invalid) {
Ok(p) => println!("Parsed: {:?}", p),
Err(e) => eprintln!("Parse error: {}", e),
}
// Serialize with error handling
let product = Product { id: 9, name: String::from("Monitor"), price: 399.00 };
match to_json(&product) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Serialize error: {}", e),
}
}
In a Cargo project this prints:
Parsed: Product { id: 7, name: "Keyboard", price: 129.99 }
Parse error: missing field `price` at line 1 column 26
{
"id": 9,
"name": "Monitor",
"price": 399.0
}
The error message for the invalid case tells you exactly which field is missing and at which line and column — far more useful than a panic, and the reason the second line goes to stderr via eprintln! while the successes go to stdout. Note the last value too: 399.00 was written in the source but serialized as 399.0, because an f64 has no memory of how many trailing zeros you typed — the {:?}-shaped rendering the Predict block above pinned down.
Both functions above return Result<_, serde_json::Error>, and the claim was that this composes with ? "naturally". That is a claim about a trait, and you met the machinery behind it several lessons ago. Answer this from memory, without scrolling up:
Recall
Without scrolling up: in Error Handling you learned the difference between a recoverable error and an unrecoverable one, and what ? requires of a function's return type. parse_product above returns Result<Product, serde_json::Error> and is handed JSON with a required field missing. What does serde_json do, and what would ? need in order to propagate that out of a function returning Result<Product, Box<dyn Error>>?
serde_json returns an Err — malformed input is recoverable by definition, since it arrives from outside the program — and every panic in the earlier examples was the caller's choice, made by writing .unwrap() on that Err. And ? is not a bare early return: it runs the error through From on the way out, which is what lets a single function returning Box<dyn Error> propagate an io::Error and a serde_json::Error side by side. serde_json::Error implements std::error::Error, so that conversion already exists and there is nothing to write.
Nested Structures and Enums
Serde handles nested structs and enums automatically. This makes it straightforward to model complex JSON payloads:
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum OrderStatus {
Pending,
Processing,
Shipped,
Delivered,
}
#[derive(Serialize, Deserialize, Debug)]
struct Address {
street: String,
city: String,
country: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct Order {
id: u32,
status: OrderStatus,
items: Vec<String>,
shipping_address: Address,
}
fn main() {
let order = Order {
id: 101,
status: OrderStatus::Shipped,
items: vec![String::from("Widget"), String::from("Gadget")],
shipping_address: Address {
street: String::from("123 Rust Lane"),
city: String::from("Ferris City"),
country: String::from("Crabland"),
},
};
let json = serde_json::to_string_pretty(&order).unwrap();
println!("{}", json);
// Round-trip: deserialize what we just serialized
let restored: Order = serde_json::from_str(&json).unwrap();
println!("\nStatus after round-trip: {:?}", restored.status);
println!("City: {}", restored.shipping_address.city);
}
In a Cargo project this prints:
{
"id": 101,
"status": "shipped",
"items": [
"Widget",
"Gadget"
],
"shipping_address": {
"street": "123 Rust Lane",
"city": "Ferris City",
"country": "Crabland"
}
}
Status after round-trip: Shipped
City: Ferris City
Enums serialize to their variant name as a string by default. The rename_all attribute converts Shipped to "shipped" in JSON, and — this is the part that makes round-trips work — the same attribute is consulted on the way back in, so "shipped" deserializes to Shipped again. Nesting needs no special handling at all: Address derives the same two traits, so serde recurses into it.
Try It Yourself
Build a small task manager that serializes a list of tasks to JSON and deserializes it back. Add at least one optional field and one enum field:
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum Priority {
Low,
Medium,
High,
}
#[derive(Serialize, Deserialize, Debug)]
struct Task {
id: u32,
title: String,
priority: Priority,
completed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
due_date: Option<String>,
}
fn main() {
let tasks = vec![
Task {
id: 1,
title: String::from("Learn serde"),
priority: Priority::High,
completed: true,
due_date: Some(String::from("2024-02-01")),
},
Task {
id: 2,
title: String::from("Write unit tests"),
priority: Priority::Medium,
completed: false,
due_date: None,
},
];
// Serialize the task list
let json = serde_json::to_string_pretty(&tasks).unwrap();
println!("Serialized:\n{}\n", json);
// Deserialize it back
let restored: Vec<Task> = serde_json::from_str(&json).unwrap();
let pending: Vec<&Task> = restored.iter().filter(|t| !t.completed).collect();
println!("Pending tasks:");
for task in pending {
println!(" - [{}] {:?} priority", task.title, task.priority);
}
}
In a Cargo project this prints:
Serialized:
[
{
"id": 1,
"title": "Learn serde",
"priority": "high",
"completed": true,
"due_date": "2024-02-01"
},
{
"id": 2,
"title": "Write unit tests",
"priority": "medium",
"completed": false
}
]
Pending tasks:
- [Write unit tests] Medium priority
Look at the second task: it has no due_date key at all, because skip_serializing_if dropped the None. On the way back in, Option<String> is happy to be absent — a missing key deserializes to None without any #[serde(default)] needed. Try extending this by adding a tags: Vec<String> field with #[serde(default)] so existing JSON without that field still deserializes correctly, since Vec does not get the same free pass Option does.
Writing JSON With Only std
Serde is the right answer for a real project, and it is also a dependency, a build-time cost and a set of derive macros to learn. For output only — which is the common case for a CLI that has to report its results in a machine-readable form — the standard library is enough, and it is worth writing once so you understand what serde is doing on your behalf.
JSON output is string building with three rules: strings go in double quotes and must be escaped, numbers and booleans are written bare, and values are joined with commas inside {} or []. The escaping is the part that actually matters, because getting it wrong produces a document that looks fine until a value contains a quote:
/// Escapes a string so it is safe between JSON double quotes.
fn escape_json(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
struct Task {
id: u32,
title: String,
done: bool,
}
impl Task {
fn to_json(&self) -> String {
format!(
"{{\"id\":{},\"title\":\"{}\",\"done\":{}}}",
self.id,
escape_json(&self.title),
self.done
)
}
}
fn tasks_to_json(tasks: &[Task]) -> String {
let entries: Vec<String> = tasks.iter().map(|t| t.to_json()).collect();
format!("[{}]", entries.join(","))
}
fn main() {
let tasks = vec![
Task { id: 1, title: String::from("Learn \"serde\""), done: true },
Task { id: 2, title: String::from("Write\ttests"), done: false },
];
println!("{}", tasks_to_json(&tasks));
println!("raw title = {}", tasks[0].title);
println!("escaped = {}", escape_json(&tasks[0].title));
}
That prints:
[{"id":1,"title":"Learn \"serde\"","done":true},{"id":2,"title":"Write\ttests","done":false}]
raw title = Learn "serde"
escaped = Learn \"serde\"
Three details are doing the work. In a format! string a literal brace is written {{ or }}, which is why the object template looks so noisy. entries.join(",") is the whole array construction — build a Vec<String> and let join place the separators, rather than tracking "is this the first element" with a flag. And escape_json is not optional: without it, the title Learn "serde" would end its JSON string at the first embedded quote and every parser on earth would reject the document. That is the entire class of bug serde exists to make impossible, and now you know precisely which one it is.
Now write it yourself. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — an escaper, an object formatter, and an array formatter — and a battery of assert_eq! calls in fn main checks each against exact JSON strings. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
Everything here is std, so you can genuinely run every attempt. The expected strings are written as r#"…"# raw literals so the JSON in the test reads the way it will appear on disk.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right JSON. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
#[derive(Debug, PartialEq)]
struct Reading {
sensor: String,
celsius: f64,
ok: bool,
}
// TODO 1: return s with every JSON-significant character escaped:
// a double quote becomes \" , a backslash becomes \\ , a newline becomes \n.
// Everything else passes through unchanged. Build the String character by
// character with a match inside a for loop over s.chars().
fn escape_json(s: &str) -> String {
s.to_string()
}
// TODO 2: format one Reading as a JSON object, in field order sensor,
// celsius, ok. Strings go in double quotes and MUST go through escape_json;
// numbers and booleans are written bare. In a format! string a literal
// brace is written {{ or }}.
// -> {"sensor":"roof","celsius":21.5,"ok":true}
fn to_json(r: &Reading) -> String {
let _ = r;
String::new()
}
// TODO 3: format a whole slice as a JSON array: every object from to_json,
// comma-separated, inside square brackets. An empty slice gives [].
fn array_to_json(readings: &[Reading]) -> String {
let _ = readings;
String::new()
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
assert_eq!(escape_json("plain"), "plain", "escape_json should leave ordinary text alone");
assert_eq!(
escape_json("say \"hi\""),
"say \\\"hi\\\"",
"escape_json should backslash-escape every double quote"
);
assert_eq!(
escape_json("a\\b"),
"a\\\\b",
"escape_json should escape a backslash as two backslashes"
);
let one = Reading { sensor: String::from("roof"), celsius: 21.5, ok: true };
assert_eq!(
to_json(&one),
r#"{"sensor":"roof","celsius":21.5,"ok":true}"#,
"to_json should quote strings, leave numbers and booleans bare"
);
let quoted = Reading { sensor: String::from("the \"north\" wall"), celsius: -3.0, ok: false };
assert_eq!(
to_json("ed),
r#"{"sensor":"the \"north\" wall","celsius":-3,"ok":false}"#,
"to_json should route the sensor name through escape_json"
);
assert_eq!(array_to_json(&[]), "[]", "an empty slice is an empty JSON array");
assert_eq!(
array_to_json(&[one, quoted]),
r#"[{"sensor":"roof","celsius":21.5,"ok":true},{"sensor":"the \"north\" wall","celsius":-3,"ok":false}]"#,
"array_to_json should comma-join the objects inside square brackets"
);
println!("All checks passed.");
let sample = Reading { sensor: String::from("roof"), celsius: 21.5, ok: true };
println!("{}", array_to_json(&[sample]));
}Expected output: All checks passed.
[{"sensor":"roof","celsius":21.5,"ok":true}]
Once it passes, try two variations and predict each before running:
- Remove the escaping. In
to_json, interpolater.sensordirectly instead ofescape_json(&r.sensor). Predict which check fails and what the string looks like before running. Thequotedcheck fails, and the produced string is{"sensor":"the "north" wall",…}— the value ends at the first embedded quote and the rest is garbage that no JSON parser will accept. This is the single most common hand-rolled-JSON bug, and it only shows up when real data contains a quote. - Format the number as a string. Change
celsiusto be quoted:\"celsius\":\"{}\". Decide what fails before running. Theonecheck fails, producing"celsius":"21.5"where the expectation is"celsius":21.5. The document stays valid JSON but the type changes, and a consumer doing arithmetic on the field now gets a string. Quoting is not cosmetic — it is the type declaration.
Key Takeaways
- Serde is a framework: derive
SerializeandDeserializeon your types; plug in any format crate (serde_json,toml,serde_yaml) to_string/from_strare the core serde_json functions;to_string_prettyadds indentation- Attributes control mapping:
rename_all,rename,skip_serializing_if,default, and many more give fine-grained control without changing your struct - Errors are descriptive: serde's errors include field names and a line and column — always propagate them with
?instead ofunwrapin production - Enums serialize cleanly: by default to their variant name as a string;
#[serde(tag = "type")]enables tagged union representations - Round-trips are reliable: serialize then deserialize produces an identical value — a property worth verifying in tests
Pro Tip: Use
serde_json::Valuewhen you need to handle arbitrary or dynamic JSON whose shape you don't know at compile time. It's an enum (Null,Bool,Number,String,Array,Object) that you can pattern-match against. This is an escape hatch for truly dynamic data, but prefer typed structs whenever the schema is known — the compiler will catch mismatches before they reach production.
Next Steps
With serde in your toolbox, you can handle virtually any data format Rust encounters. Next, we'll build command-line tools — reading arguments, validating input, and reporting errors — so your programs can talk to the outside world.
Next lesson
CLI and Args
Build command-line tools in Rust by parsing arguments, handling flags, and structuring real-world CLI applications
25 min