Guide contents

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.

Terminal
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.

Rust
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(())
}
Output
{"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).

Rust
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(())
}
Output
{
  "id": 1,
  "title": "write docs",
  "done": false
}
loaded 2 tasks, 1 done

3. 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.

Rust
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}"),
        }
    }
}
Output
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 24

Note 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.

Rust
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(())
}
Output
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.

Rust
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(())
}
Output
{"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.

Rust
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(())
}
Output
{"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.

Rust
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}"),
    }
}
Output
Ok(Lenient { name: "widget" })
error: unknown field `colour`, expected `name` or `color` at line 1 column 27

8. 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).

Rust
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(())
}
Output
[{"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.

Rust
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(())
}
Output
{"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 26

10. 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.

Rust
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(())
}
Output
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.

Rust
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(())
}
Output
{"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.

Rust
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(())
}
Output
{"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.

Rust
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(())
}
Output
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 34

into = "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.

Rust
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(())
}
Output
{"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.

Rust
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());
}
Output
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 18

The 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.

Rust
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}");
    }
}
Output
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 u16

17. 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.

Rust
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(())
}
Output
name = "billing-service"
scopes = ["invoices:read", "invoices:write"]

[limits]
requests_per_minute = 600
burst = 50

round trip: true

18. 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.

Rust
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(())
}
Output
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.

Rust
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(())
}
Output
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=1

The 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.

Rust
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(())
}
Output
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 = 60

Summary

  • Derive Serialize and Deserialize; 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; Option and #[serde(default)] make fields optional.
  • Shape the output with rename_all, rename, skip_serializing_if, skip, and flatten. Unknown keys are ignored unless you add deny_unknown_fields.
  • Enums support four representations: external (default), tag = "...", tag + content, and untagged. Data-less enums are plain strings.
  • serde_json::Value and json! handle JSON without a struct.
  • transparent, try_from, and with = "module" customize individual types and fields; &'a str and Cow fields borrow from the input.

Next: Dates, Regex & Everyday Crates.