Lesson 1 of 6
Serialization with serde
serde (SERialize/DEserialize) is Rust's serialization framework, and almost every Rust service uses it. Unlike reflection-based serializers, which inspect types at runtime, serde's derive macros generate the conversion code at compile time. A type that can't be serialized is a compile error, and the generated code is as fast as hand-written code.
serde itself only defines the Serialize and Deserialize traits. Formats are separate crates: serde_json for JSON, toml for TOML, and others for YAML, CSV, MessagePack, and more. The same derived type works with all of them. Crates, Packages & Workspaces showed the first example; this lesson covers what you need for real APIs and config files.
cargo add serde --features derive
cargo add serde_json toml| Task | serde |
|---|---|
| Value to JSON string | serde_json::to_string(&x)? |
| JSON string to value | serde_json::from_str::<T>(json)? |
| Use a different key for one field | #[serde(rename = "type")] |
| camelCase keys for every field | #[serde(rename_all = "camelCase")] |
| Leave out empty optional fields | #[serde(skip_serializing_if = "Option::is_none")] |
| Never read or write a field | #[serde(skip)] |
| Reject unknown keys (off by default) | #[serde(deny_unknown_fields)] |
Enum with a "type" field |
#[serde(tag = "type")] |
| Inline a nested struct or collect extra keys | #[serde(flatten)] |
| Untyped JSON | serde_json::Value |
| Custom format for one field | #[serde(with = "module")] |
Part 1: The basics
1. Deriving Serialize and Deserialize
Derive both traits, then convert with serde_json::to_string and serde_json::from_str. Field names become JSON keys, Vecs become arrays, and nested structs become nested objects.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Address {
city: String,
zip: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct Customer {
id: u64,
name: String,
vip: bool,
tags: Vec<String>,
address: Address,
}
fn main() -> Result<(), serde_json::Error> {
let customer = Customer {
id: 42,
name: String::from("Ana Lima"),
vip: true,
tags: vec![String::from("beta")],
address: Address { city: String::from("Lisbon"), zip: String::from("1100-148") },
};
let json = serde_json::to_string(&customer)?;
println!("{json}");
let back: Customer = serde_json::from_str(&json)?;
println!("{back:?}");
Ok(())
}{"id":42,"name":"Ana Lima","vip":true,"tags":["beta"],"address":{"city":"Lisbon","zip":"1100-148"}}
Customer { id: 42, name: "Ana Lima", vip: true, tags: ["beta"], address: Address { city: "Lisbon", zip: "1100-148" } }2. Pretty output, and files
to_string_pretty indents the output. to_writer and from_reader work with any Write and Read, so JSON can go straight to and from files (Reading & Writing Files).
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{BufReader, BufWriter, Write};
#[derive(Debug, Serialize, Deserialize)]
struct Task {
id: u32,
title: String,
done: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let tasks = vec![Task { id: 1, title: String::from("write docs"), done: false }, Task { id: 2, title: String::from("ship"), done: true }];
println!("{}", serde_json::to_string_pretty(&tasks[0])?);
let mut out = BufWriter::new(File::create("tasks.json")?);
serde_json::to_writer_pretty(&mut out, &tasks)?;
out.flush()?;
let loaded: Vec<Task> = serde_json::from_reader(BufReader::new(File::open("tasks.json")?))?;
println!("loaded {} tasks, {} done", loaded.len(), loaded.iter().filter(|t| t.done).count());
Ok(())
}{
"id": 1,
"title": "write docs",
"done": false
}
loaded 2 tasks, 1 done3. When the input doesn't fit
A missing field, a wrong type, or broken syntax is an error with a precise message, including the line and column. There's no half-filled object and no null where a value was expected.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct User {
name: String,
age: u8,
}
fn main() {
let inputs = [
r#"{"name": "Ana", "age": 34}"#,
r#"{"name": "Ben"}"#,
r#"{"name": "Cy", "age": "thirty"}"#,
r#"{"name": "Di", "age": 300}"#,
r#"{"name": "Ed", "age": 20"#,
];
for input in inputs {
match serde_json::from_str::<User>(input) {
Ok(user) => println!("ok: {user:?}"),
Err(e) => println!("error: {e}"),
}
}
}ok: User { name: "Ana", age: 34 }
error: missing field `age` at line 1 column 15
error: invalid type: string "thirty", expected u8 at line 1 column 30
error: invalid value: integer `300`, expected u8 at line 1 column 25
error: EOF while parsing an object at line 1 column 24Note the 300: the field is a u8, so the value must fit in 0 to 255. The Rust type is the validation.
Part 2: Shaping the JSON
4. Optional fields and defaults
An Option field accepts a missing key (or null) as None. #[serde(default)] fills in a missing field with its type's default, and #[serde(default = "function")] with a value of your choosing.
use serde::Deserialize;
fn default_port() -> u16 {
8080
}
#[derive(Debug, Deserialize)]
struct ServerConfig {
host: String,
#[serde(default = "default_port")]
port: u16,
#[serde(default)]
workers: usize, // 0 when missing
#[serde(default)]
allowed_origins: Vec<String>, // empty when missing
tls_cert: Option<String>, // None when missing or null
}
fn main() -> Result<(), serde_json::Error> {
let minimal: ServerConfig = serde_json::from_str(r#"{"host": "0.0.0.0"}"#)?;
println!("{minimal:?}");
let full: ServerConfig = serde_json::from_str(r#"{"host": "api.example.com", "port": 443, "workers": 8, "allowed_origins": ["https://example.com"], "tls_cert": "/etc/cert.pem"}"#)?;
println!("{full:?}");
Ok(())
}ServerConfig { host: "0.0.0.0", port: 8080, workers: 0, allowed_origins: [], tls_cert: None }
ServerConfig { host: "api.example.com", port: 443, workers: 8, allowed_origins: ["https://example.com"], tls_cert: Some("/etc/cert.pem") }5. Renaming fields
Rust fields are snake_case, while most JSON APIs use camelCase. rename_all converts every field at once; rename handles single fields, such as a key that's a Rust keyword. It works in both directions.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payment {
payment_id: String,
amount_cents: u64,
created_at: String,
#[serde(rename = "type")]
kind: String, // `type` is a keyword in Rust
}
fn main() -> Result<(), serde_json::Error> {
let p = Payment { payment_id: String::from("pay_81"), amount_cents: 1_999, created_at: String::from("2026-09-27T10:00:00Z"), kind: String::from("card") };
println!("{}", serde_json::to_string(&p)?);
let back: Payment = serde_json::from_str(r#"{"paymentId":"pay_82","amountCents":500,"createdAt":"2026-09-28T09:30:00Z","type":"sepa"}"#)?;
println!("{back:?}");
Ok(())
}{"paymentId":"pay_81","amountCents":1999,"createdAt":"2026-09-27T10:00:00Z","type":"card"}
Payment { payment_id: "pay_82", amount_cents: 500, created_at: "2026-09-28T09:30:00Z", kind: "sepa" }Other styles are "snake_case", "kebab-case", "PascalCase", "SCREAMING_SNAKE_CASE", and "lowercase".
6. Skipping fields
skip_serializing_if leaves a field out of the output when a condition holds, typically "empty" or "None". skip leaves a field out entirely, in both directions; it must then have a default for deserializing.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Profile {
username: String,
#[serde(skip_serializing_if = "Option::is_none")]
bio: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
links: Vec<String>,
#[serde(skip)]
session_token: String, // never written, never read
}
fn main() -> Result<(), serde_json::Error> {
let sparse = Profile { username: String::from("ana"), bio: None, links: vec![], session_token: String::from("secret") };
let rich = Profile { username: String::from("ben"), bio: Some(String::from("Rustacean")), links: vec![String::from("https://ben.dev")], session_token: String::new() };
println!("{}", serde_json::to_string(&sparse)?);
println!("{}", serde_json::to_string(&rich)?);
let read: Profile = serde_json::from_str(r#"{"username": "cy", "session_token": "ignored"}"#)?;
println!("{read:?}");
Ok(())
}{"username":"ana"}
{"username":"ben","bio":"Rustacean","links":["https://ben.dev"]}
Profile { username: "cy", bio: None, links: [], session_token: "" }7. Unknown fields
By default serde ignores keys it doesn't know, unlike serializers that fail on unknown properties. That's usually what you want when reading other people's APIs, which add fields over time. For config files, where an unknown key is probably a typo, deny_unknown_fields turns it into an error.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Lenient {
name: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Strict {
name: String,
color: Option<String>,
}
fn main() {
let input = r#"{"name": "widget", "colour": "red"}"#;
println!("{:?}", serde_json::from_str::<Lenient>(input));
match serde_json::from_str::<Strict>(input) {
Ok(s) => println!("{s:?}"),
Err(e) => println!("error: {e}"),
}
}Ok(Lenient { name: "widget" })
error: unknown field `colour`, expected `name` or `color` at line 1 column 278. Enums, four ways
Enums are where serde shines. By default a variant is written as {"VariantName": {...}} ("externally tagged"). The other representations match what APIs commonly send: a "type" field next to the data ("internally tagged"), the type and the data side by side ("adjacently tagged"), or no tag at all ("untagged", where serde tries each variant in order).
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
enum External {
Card { last4: String },
Cash,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Internal {
Card { last4: String },
BankTransfer { iban: String },
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
enum Adjacent {
Card { last4: String },
Voucher(u32),
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Untagged {
Id(u64),
Name(String),
Coordinates { lat: f64, lon: f64 },
}
fn main() -> Result<(), serde_json::Error> {
println!("{}", serde_json::to_string(&[External::Card { last4: String::from("4242") }, External::Cash])?);
println!("{}", serde_json::to_string(&Internal::Card { last4: String::from("4242") })?);
println!("{}", serde_json::to_string(&Internal::BankTransfer { iban: String::from("DE89 3704") })?);
println!("{}", serde_json::to_string(&[Adjacent::Card { last4: String::from("4242") }, Adjacent::Voucher(77)])?);
let mixed: Vec<Untagged> = serde_json::from_str(r#"[17, "Lisbon", {"lat": 38.7, "lon": -9.1}]"#)?;
println!("{mixed:?}");
Ok(())
}[{"Card":{"last4":"4242"}},"Cash"]
{"type":"card","last4":"4242"}
{"type":"bank_transfer","iban":"DE89 3704"}
[{"kind":"Card","data":{"last4":"4242"}},{"kind":"Voucher","data":77}]
[Id(17), Name("Lisbon"), Coordinates { lat: 38.7, lon: -9.1 }]Deserializing an internally tagged enum reads the "type" field and then the fields of the matching variant, which is exactly how you model an API that sends different event shapes on one endpoint.
9. Enums as plain strings
An enum whose variants carry no data serializes to just the variant name, a string. rename_all adjusts the spelling, and an unexpected value is an error that lists the valid ones.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum OrderStatus {
Pending,
InTransit,
Delivered,
}
#[derive(Debug, Serialize, Deserialize)]
struct Order {
id: u32,
status: OrderStatus,
}
fn main() -> Result<(), serde_json::Error> {
println!("{}", serde_json::to_string(&Order { id: 1, status: OrderStatus::InTransit })?);
let order: Order = serde_json::from_str(r#"{"id": 2, "status": "DELIVERED"}"#)?;
println!("{order:?}, delivered: {}", order.status == OrderStatus::Delivered);
println!("{}", serde_json::from_str::<Order>(r#"{"id": 3, "status": "LOST"}"#).unwrap_err());
Ok(())
}{"id":1,"status":"IN_TRANSIT"}
Order { id: 2, status: Delivered }, delivered: true
unknown variant `LOST`, expected one of `PENDING`, `IN_TRANSIT`, `DELIVERED` at line 1 column 2610. Flattening shared fields and extra keys
#[serde(flatten)] merges a nested struct's fields into the parent object, so shared fields can live in one struct. Flattening a map collects every key that no other field claimed.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Serialize, Deserialize)]
struct Audit {
created_by: String,
version: u32,
}
#[derive(Debug, Serialize, Deserialize)]
struct Document {
id: String,
title: String,
#[serde(flatten)]
audit: Audit, // its fields appear at the top level
#[serde(flatten)]
extra: BTreeMap<String, Value>, // everything else
}
fn main() -> Result<(), serde_json::Error> {
let input = r#"{"id": "doc-7", "title": "Plan", "created_by": "ana", "version": 3, "color": "blue", "pinned": true}"#;
let doc: Document = serde_json::from_str(input)?;
println!("{doc:#?}");
println!("{}", serde_json::to_string(&doc)?);
Ok(())
}Document {
id: "doc-7",
title: "Plan",
audit: Audit {
created_by: "ana",
version: 3,
},
extra: {
"color": String("blue"),
"pinned": Bool(true),
},
}
{"id":"doc-7","title":"Plan","created_by":"ana","version":3,"color":"blue","pinned":true}11. Untyped JSON: Value and json!
Sometimes you don't have, or don't want, a struct: exploring an unfamiliar API, reading a few fields from a big document, or building a quick request body. serde_json::Value represents any JSON document, and the json! macro writes JSON literally, with Rust values mixed in.
use serde_json::{Value, json};
fn main() -> Result<(), serde_json::Error> {
let city = "Lisbon";
let body = json!({
"query": { "city": city, "max_price": 250 },
"sort": ["price", "-rating"],
"debug": false
});
println!("{body}");
let response: Value = serde_json::from_str(r#"{"results": [{"name": "Casa Azul", "price": 180, "rating": 4.7}, {"name": "Hotel Sol", "price": 240}]}"#)?;
for hotel in response["results"].as_array().into_iter().flatten() {
let rating = hotel["rating"].as_f64().map_or(String::from("no rating"), |r| format!("rated {r}"));
println!("{} for {}, {rating}", hotel["name"].as_str().unwrap_or("?"), hotel["price"]);
}
println!("missing keys are Null: {}", response["nope"]["deeper"]);
println!("pointer: {:?}", response.pointer("/results/1/name"));
Ok(())
}{"debug":false,"query":{"city":"Lisbon","max_price":250},"sort":["price","-rating"]}
Casa Azul for 180, rated 4.7
Hotel Sol for 240, no rating
missing keys are Null: null
pointer: Some(String("Hotel Sol"))Indexing a Value with a key or index that doesn't exist returns Value::Null instead of panicking. That's convenient for exploring, but prefer structs in real code: a typo in a key silently gives Null, while a typo in a field name doesn't compile.
12. Maps and ordering
A HashMap or BTreeMap with string keys becomes a JSON object. HashMap has no order, so its JSON output order can change from run to run; use a BTreeMap when you want stable, sorted output, for example in files kept under version control.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Serialize, Deserialize)]
struct Inventory {
warehouse: String,
stock: BTreeMap<String, u32>, // sorted keys: stable output
locations: Vec<(String, f64, f64)>, // tuples become arrays
}
fn main() -> Result<(), serde_json::Error> {
let mut stock = BTreeMap::new();
stock.insert(String::from("widgets"), 120);
stock.insert(String::from("bolts"), 5_000);
stock.insert(String::from("gears"), 42);
let inv = Inventory { warehouse: String::from("north"), stock, locations: vec![(String::from("dock A"), 52.52, 13.40)] };
println!("{}", serde_json::to_string(&inv)?);
let counts: BTreeMap<String, Vec<u32>> = serde_json::from_str(r#"{"mon": [3, 5], "tue": [], "sun": [1]}"#)?;
println!("{counts:?}");
Ok(())
}{"warehouse":"north","stock":{"bolts":5000,"gears":42,"widgets":120},"locations":[["dock A",52.52,13.4]]}
{"mon": [3, 5], "sun": [1], "tue": []}Part 3: Custom behavior
13. Newtypes, transparent, and validation with try_from
A newtype such as Email(String) should appear in JSON as just the string. #[serde(transparent)] does that. To also validate while deserializing, #[serde(try_from = "String")] runs your TryFrom implementation, so an invalid email can't even be deserialized.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
struct Email(String);
impl TryFrom<String> for Email {
type Error = String;
fn try_from(s: String) -> Result<Email, String> {
if s.contains('@') { Ok(Email(s.to_lowercase())) } else { Err(format!("{s:?} is not an email address")) }
}
}
impl From<Email> for String {
fn from(e: Email) -> String {
e.0
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Signup {
id: UserId,
email: Email,
}
fn main() -> Result<(), serde_json::Error> {
let s: Signup = serde_json::from_str(r#"{"id": 7, "email": "Ana@Example.com"}"#)?;
println!("{s:?}");
println!("{}", serde_json::to_string(&s)?);
println!("{}", serde_json::from_str::<Signup>(r#"{"id": 8, "email": "not-an-email"}"#).unwrap_err());
Ok(())
}Signup { id: UserId(7), email: Email("ana@example.com") }
{"id":7,"email":"ana@example.com"}
"not-an-email" is not an email address at line 1 column 34into = "String" goes the other way: serializing converts a clone of the value into a String, which is why Email derives Clone.
14. A custom format for one field
When a field's JSON form differs from its Rust type, #[serde(with = "module")] points serde to a module with a serialize and a deserialize function. Here a Duration is written as a number of seconds.
use serde::{Deserialize, Serialize};
use std::time::Duration;
mod seconds {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(d.as_secs())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
Ok(Duration::from_secs(u64::deserialize(d)?))
}
}
#[derive(Debug, Serialize, Deserialize)]
struct RetryPolicy {
attempts: u32,
#[serde(with = "seconds")]
backoff: Duration,
#[serde(with = "seconds")]
timeout: Duration,
}
fn main() -> Result<(), serde_json::Error> {
let policy = RetryPolicy { attempts: 3, backoff: Duration::from_secs(2), timeout: Duration::from_secs(30) };
println!("{}", serde_json::to_string(&policy)?);
let back: RetryPolicy = serde_json::from_str(r#"{"attempts": 5, "backoff": 1, "timeout": 10}"#)?;
println!("{back:?}");
Ok(())
}{"attempts":3,"backoff":2,"timeout":30}
RetryPolicy { attempts: 5, backoff: 1s, timeout: 10s }For dates and times, chrono and other date crates provide serde support themselves, as the next lesson shows.
15. Zero-copy: borrowing from the input
A deserialized struct can hold &str fields that point into the input text instead of copying it. This matters when parsing large volumes of JSON. The catch: a JSON string containing escape sequences like \n can't be borrowed as-is, because the text in the input isn't the text of the value.
use serde::Deserialize;
use std::borrow::Cow;
#[derive(Debug, Deserialize)]
struct LogLine<'a> {
level: &'a str, // borrowed from the input
#[serde(borrow)]
message: Cow<'a, str>, // borrowed when possible, owned when it must be
}
fn main() {
let input = String::from(r#"{"level": "warn", "message": "disk at 91%"}"#);
let line: LogLine = serde_json::from_str(&input).unwrap();
println!("{line:?}, message borrowed: {}", matches!(line.message, Cow::Borrowed(_)));
let escaped = r#"{"level": "error", "message": "line one\nline two"}"#;
let line: LogLine = serde_json::from_str(escaped).unwrap();
println!("{line:?}, message borrowed: {}", matches!(line.message, Cow::Borrowed(_)));
println!("{}", serde_json::from_str::<LogLine>(r#"{"level": "in\tfo", "message": "x"}"#).unwrap_err());
}LogLine { level: "warn", message: "disk at 91%" }, message borrowed: true
LogLine { level: "error", message: "line one\nline two" }, message borrowed: false
invalid type: string "in\tfo", expected a borrowed string at line 1 column 18The borrowed struct can't outlive the input string, a guarantee the compiler checks with the lifetime 'a (Lifetimes in Structs). Use String fields unless profiling shows copying matters.
Part 4: Other formats and realistic examples
16. TOML configuration files
TOML is the format of Cargo.toml, and a common choice for application config, alongside YAML and environment variables. The toml crate uses the same derived types: tables become nested structs.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Config {
service_name: String,
#[serde(default)]
debug: bool,
server: Server,
database: Database,
#[serde(default)]
features: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Server {
host: String,
port: u16,
}
#[derive(Debug, Deserialize)]
struct Database {
url: String,
#[serde(default = "default_pool_size")]
pool_size: u32,
}
fn default_pool_size() -> u32 {
10
}
fn main() {
let text = r#"
service_name = "orders"
features = ["new-checkout", "audit-log"]
[server]
host = "0.0.0.0"
port = 8080
[database]
url = "postgres://db.internal/orders"
"#;
match toml::from_str::<Config>(text) {
Ok(config) => println!("{config:#?}"),
Err(e) => println!("invalid config: {e}"),
}
let typo = "service_name = \"orders\"\n[server]\nhost = \"0.0.0.0\"\nport = \"eighty\"\n[database]\nurl = \"x\"\n";
if let Err(e) = toml::from_str::<Config>(typo) {
println!("{e}");
}
}Config {
service_name: "orders",
debug: false,
server: Server {
host: "0.0.0.0",
port: 8080,
},
database: Database {
url: "postgres://db.internal/orders",
pool_size: 10,
},
features: [
"new-checkout",
"audit-log",
],
}
TOML parse error at line 4, column 8
|
4 | port = "eighty"
| ^^^^^^^^
invalid type: string "eighty", expected u1617. One type, several formats
Because the derived code only talks to serde's traits, the same struct serializes to every format. Converting a config file from JSON to TOML takes two lines.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Limits {
requests_per_minute: u32,
burst: u32,
}
#[derive(Debug, Serialize, Deserialize)]
struct ApiKey {
name: String,
scopes: Vec<String>,
limits: Limits,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"name": "billing-service", "scopes": ["invoices:read", "invoices:write"], "limits": {"requests_per_minute": 600, "burst": 50}}"#;
let key: ApiKey = serde_json::from_str(json)?;
let as_toml = toml::to_string(&key)?;
println!("{as_toml}");
let back: ApiKey = toml::from_str(&as_toml)?;
println!("round trip: {}", serde_json::to_string(&back)? == serde_json::to_string(&key)?);
Ok(())
}name = "billing-service"
scopes = ["invoices:read", "invoices:write"]
[limits]
requests_per_minute = 600
burst = 50
round trip: true18. Realistic example: reading a paginated API response
A typical REST response: metadata, a page of results with optional fields, and a cursor for the next page. The structs describe only the fields we need; the rest of the response is ignored.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Page<T> {
data: Vec<T>,
next_cursor: Option<String>,
total_count: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Repo {
full_name: String,
stars: u32,
language: Option<String>,
#[serde(default)]
archived: bool,
}
fn main() -> Result<(), serde_json::Error> {
let body = r#"{
"data": [
{"fullName": "rust-lang/rust", "stars": 100000, "language": "Rust", "url": "https://example.com/1"},
{"fullName": "tokio-rs/tokio", "stars": 30000, "language": "Rust"},
{"fullName": "old/tool", "stars": 12, "language": null, "archived": true}
],
"nextCursor": "eyJwYWdlIjoyfQ",
"totalCount": 3,
"requestId": "a1b2"
}"#;
let page: Page<Repo> = serde_json::from_str(body)?;
let active: Vec<&Repo> = page.data.iter().filter(|r| !r.archived).collect();
for repo in &active {
println!("{:<16} {:>6} stars {}", repo.full_name, repo.stars, repo.language.as_deref().unwrap_or("-"));
}
println!("{} of {} active; next page: {:?}", active.len(), page.total_count, page.next_cursor);
Ok(())
}rust-lang/rust 100000 stars Rust
tokio-rs/tokio 30000 stars Rust
2 of 3 active; next page: Some("eyJwYWdlIjoyfQ")Page<T> is generic, so one struct describes the envelope for every endpoint that uses this pagination scheme.
19. Realistic example: a JSON Lines event stream
Log pipelines and data exports often use JSON Lines: one JSON value per line. Parsing line by line means the input never has to fit in memory, and one bad line can be reported and skipped without losing the rest. An internally tagged enum handles the different event types.
use serde::Deserialize;
use std::collections::BTreeMap;
use std::io::{BufRead, Cursor};
#[derive(Debug, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum Event {
PageView { path: String },
Purchase { order_id: u32, cents: u64 },
Signup { plan: String },
}
fn main() -> std::io::Result<()> {
let stream = Cursor::new(
r#"{"event": "page_view", "path": "/pricing", "ts": 1}
{"event": "signup", "plan": "pro", "ts": 2}
{"event": "purchase", "order_id": 1001, "cents": 4900, "ts": 3}
{"event": "page_view", "path": "/docs", "ts": 4}
{"event": "refund", "order_id": 1001, "ts": 5}
{"event": "purchase", "order_id": 1002, "cents": 1500, "ts": 6}
"#,
); // in a real program: BufReader::new(File::open("events.jsonl")?)
let (mut views, mut revenue, mut plans, mut errors) = (0, 0, BTreeMap::new(), 0);
for (i, line) in stream.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Event>(&line) {
Ok(Event::PageView { .. }) => views += 1,
Ok(Event::Purchase { cents, .. }) => revenue += cents,
Ok(Event::Signup { plan }) => *plans.entry(plan).or_insert(0) += 1,
Err(e) => {
errors += 1;
println!("line {}: {e}", i + 1);
}
}
}
println!("views={views} revenue={revenue} signups={plans:?} errors={errors}");
Ok(())
}line 5: unknown variant `refund`, expected one of `page_view`, `purchase`, `signup` at line 1 column 18
views=2 revenue=6400 signups={"pro": 1} errors=1The extra ts field is ignored because no variant asks for it. For JSON values that follow each other without newlines, serde_json::Deserializer::from_reader(reader).into_iter::<T>() reads them one by one, but it stops at the first error.
20. Realistic example: a settings file with defaults
An application's settings: loaded from a TOML file if it exists, with defaults for anything missing, and saved back after a change. #[serde(default)] on the whole struct takes each missing field from the struct's Default implementation.
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::ErrorKind;
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
struct Settings {
theme: String,
font_size: u8,
recent_files: Vec<String>,
autosave_secs: u32,
}
impl Default for Settings {
fn default() -> Self {
Settings { theme: String::from("light"), font_size: 14, recent_files: Vec::new(), autosave_secs: 60 }
}
}
fn load(path: &str) -> Result<Settings, Box<dyn std::error::Error>> {
match fs::read_to_string(path) {
Ok(text) => Ok(toml::from_str(&text)?),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Settings::default()),
Err(e) => Err(e.into()),
}
}
fn save(path: &str, settings: &Settings) -> Result<(), Box<dyn std::error::Error>> {
fs::write(path, toml::to_string_pretty(settings)?)?;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = fs::remove_file("editor.toml");
println!("first start: {:?}", load("editor.toml")?);
fs::write("editor.toml", "theme = \"dark\"\n")?; // the user edited one setting by hand
let mut settings = load("editor.toml")?;
println!("after edit: {settings:?}");
settings.recent_files.push(String::from("notes.md"));
save("editor.toml", &settings)?;
print!("{}", fs::read_to_string("editor.toml")?);
Ok(())
}first start: Settings { theme: "light", font_size: 14, recent_files: [], autosave_secs: 60 }
after edit: Settings { theme: "dark", font_size: 14, recent_files: [], autosave_secs: 60 }
theme = "dark"
font_size = 14
recent_files = ["notes.md"]
autosave_secs = 60Summary
- Derive
SerializeandDeserialize; the formats (serde_json,toml, and others) turn those types into text and back, from strings, readers, and writers. - The Rust type is the schema: missing fields, wrong types, and out-of-range numbers are errors with line and column;
Optionand#[serde(default)]make fields optional. - Shape the output with
rename_all,rename,skip_serializing_if,skip, andflatten. Unknown keys are ignored unless you adddeny_unknown_fields. - Enums support four representations: external (default),
tag = "...",tag+content, anduntagged. Data-less enums are plain strings. serde_json::Valueandjson!handle JSON without a struct.transparent,try_from, andwith = "module"customize individual types and fields;&'a strandCowfields borrow from the input.
