Serialization with serde

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0, serde 1.0.229, serde_json 1.0.151, toml 0.9 and rmp-serde 1.3, checked on 2026-10-06. serde is the de-facto serialization framework for Rust. It separates data structures (which implement Serialize and Deserialize, almost always by derive) from data formats (JSON, TOML, YAML, MessagePack, CBOR, …​, each a separate crate), so any type works with any format. The axum section uses serde for every JSON request and response and links here instead of re-explaining it.

[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"

Deriving Serialize and Deserialize

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Book {
    isbn: String,
    title: String,
    year: u16,
    tags: Vec<String>,
    rating: Option<f32>,          // JSON null or missing -> None
}

fn main() -> Result<(), serde_json::Error> {
    let dune = Book {
        isbn: "9780441013593".into(),
        title: "Dune".into(),
        year: 1965,
        tags: vec!["sci-fi".into()],
        rating: Some(4.5),
    };
    let json = serde_json::to_string(&dune)?;
    println!("{json}");
    // {"isbn":"9780441013593","title":"Dune","year":1965,"tags":["sci-fi"],"rating":4.5}

    let parsed: Book = serde_json::from_str(r#"{"isbn":"9780141439518","title":"Emma","year":1815,"tags":[]}"#)?;
    println!("{parsed:?}");                          // rating: None
    println!("{}", serde_json::to_string_pretty(&parsed)?);
    Ok(())
}

Deserialization errors carry the line and column and a description ("missing field `year`", "invalid type: string \"x\", expected u16"). Unknown fields are ignored by default.

Attributes

serde’s behaviour is customised with #[serde(...)] attributes on the container (struct or enum), on fields and on variants:

Attribute Effect

rename_all = "camelCase" (container)

rename every field: camelCase, snake_case, kebab-case, SCREAMING_SNAKE_CASE, …​

rename = "isbn13" (field/variant)

use a different name in the data

alias = "ISBN" (field)

also accept this name when deserializing

default (field or container)

use Default::default() (or default = "path::to::fn") when missing

skip_serializing_if = "Option::is_none"

omit the field when the predicate is true

skip / skip_serializing / skip_deserializing

exclude a field

flatten (field)

inline the fields of a nested struct or a map of extra keys

deny_unknown_fields (container)

reject unknown keys instead of ignoring them

tag = "type", content = "data", untagged (enum)

choose how enums are represented (below)

with, serialize_with, deserialize_with (field)

use custom functions for one field

try_from = "String", into = "String" (container)

serialize through a conversion, validating on the way in

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Loan {
    #[serde(alias = "isbn13")]
    isbn: String,
    member_id: u32,                                        // memberId in JSON
    #[serde(default = "default_days")]
    loan_days: u16,
    #[serde(skip_serializing_if = "Option::is_none")]
    note: Option<String>,
    #[serde(flatten)]
    extra: BTreeMap<String, serde_json::Value>,            // every key no other field claimed
}

fn default_days() -> u16 {
    21
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LoanRequest {
    isbn: String,
    #[serde(flatten)]
    paging: Paging,                                        // fields inlined into the parent
}

#[derive(Debug, Serialize, Deserialize)]
struct Paging {
    page: u32,
    per_page: u32,
}

fn main() -> Result<(), serde_json::Error> {
    let r: LoanRequest = serde_json::from_str(r#"{"isbn":"978","page":2,"per_page":20}"#)?;
    println!("{r:?}");
    let loan: Loan = serde_json::from_str(r#"{"isbn13":"978","memberId":7,"shelf":"A1"}"#)?;
    println!("{loan:?}");                     // alias accepted, loan_days = 21, extra = {"shelf": "A1"}
    println!("{}", serde_json::to_string(&loan)?); // note omitted; extra keys written back inline
    Ok(())
}

Two attributes do not combine: flatten relies on seeing the keys that no other field claimed, and serde documents deny_unknown_fields as unsupported together with it — unknown keys are then rejected and a flattened map never receives anything. Choose one. flatten also has a performance cost (it buffers the input) and does not work with every format.

Enum representations

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
enum Externally { Lend { isbn: String }, Return(String) }          // default: {"Lend":{"isbn":"978"}}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event { BookLent { isbn: String, member: u32 }, BookReturned { isbn: String } }
// internally tagged: {"type":"book_lent","isbn":"978","member":7}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "payload")]
enum Message { Ping, Search(String) }                              // adjacently tagged

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum IdOrIsbn { Id(u64), Isbn(String) }                            // tries each variant in order

fn main() -> Result<(), serde_json::Error> {
    println!("{}", serde_json::to_string(&Externally::Lend { isbn: "978".into() })?);
    println!("{}", serde_json::to_string(&Externally::Return("978".into()))?);
    println!("{}", serde_json::to_string(&Event::BookLent { isbn: "978".into(), member: 7 })?);
    println!("{}", serde_json::to_string(&Message::Search("dune".into()))?);
    let ids: Vec<IdOrIsbn> = serde_json::from_str(r#"[42, "9780441013593"]"#)?;
    println!("{ids:?}");
    Ok(())
}

Internally tagged enums are the usual choice for API events and messages; untagged is convenient but produces poor error messages ("data did not match any variant") and tries variants in order.

Custom serialization

For a single field, write a function and point to it with serialize_with/deserialize_with (or a module with both functions and with). For a type that must always serialize in a particular way, implement Serialize/Deserialize by hand, or — simpler — convert through another type with try_from/into. shelf-core serializes its validated Isbn newtype as a plain string and validates it when deserializing:

use serde::{Deserialize, Deserializer, Serialize, Serializer};
use shelf_core::Isbn;

// shelf-core declares: #[serde(try_from = "String", into = "String")] on Isbn,
// so an invalid ISBN in the input is a deserialization error.
#[derive(Debug, Serialize, Deserialize)]
struct CatalogEntry {
    isbn: Isbn,
    #[serde(serialize_with = "cents_as_price", deserialize_with = "price_as_cents")]
    price: u32,                                           // stored as cents, shown as "12.50"
}

fn cents_as_price<S: Serializer>(cents: &u32, s: S) -> Result<S::Ok, S::Error> {
    s.serialize_str(&format!("{}.{:02}", cents / 100, cents % 100))
}

fn price_as_cents<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
    let text = String::deserialize(d)?;
    let (euros, cents) = text.split_once('.').unwrap_or((&text, "0"));
    let parse = |s: &str| s.parse::<u32>().map_err(serde::de::Error::custom);
    Ok(parse(euros)? * 100 + parse(cents)?)
}

fn main() -> Result<(), serde_json::Error> {
    let e: CatalogEntry = serde_json::from_str(r#"{"isbn":"978-0-441-01359-3","price":"12.50"}"#)?;
    println!("{e:?} -> {}", serde_json::to_string(&e)?);
    let bad = serde_json::from_str::<CatalogEntry>(r#"{"isbn":"978-0-441-01359-4","price":"1.00"}"#);
    println!("{}", bad.unwrap_err()); // the check digit does not match at line 1 column ...
    Ok(())
}

serde_json::Value and json!

When the shape of the data is not known in advance, deserialize into serde_json::Value, an enum of Null, Bool, Number, String, Array and Object. The json! macro builds values with JSON syntax:

use serde_json::{json, Value};

fn main() -> Result<(), serde_json::Error> {
    let payload: Value = serde_json::from_str(r#"{"book":{"title":"Dune","ratings":[5,4]}}"#)?;
    println!("{}", payload["book"]["title"]);                  // "Dune"
    println!("{:?}", payload["book"]["ratings"][0].as_u64());  // Some(5)
    println!("{}", payload["missing"]["key"].is_null());       // indexing never panics: Null
    println!("{:?}", payload.pointer("/book/ratings/1"));      // JSON Pointer: Some(Number(4))

    let reply = json!({ "status": "ok", "count": 2, "titles": ["Dune", "Emma"] });
    println!("{reply}");
    Ok(())
}

Prefer typed structs wherever possible: they document the format, validate it, and are faster.

Zero-copy deserialization

Deserialize<'de> has a lifetime: the data being deserialized lives for 'de, and a struct may borrow strings from it instead of allocating. Use &'de str (or Cow<'de, str> with #[serde(borrow)], which borrows when possible) for fields of large inputs processed in place:

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct BookRef<'a> {
    title: &'a str,     // points into the input buffer: no allocation
    year: u16,
}

fn main() -> Result<(), serde_json::Error> {
    let input = String::from(r#"[{"title":"Dune","year":1965},{"title":"Emma","year":1815}]"#);
    let books: Vec<BookRef<'_>> = serde_json::from_str(&input)?;
    println!("{books:?}");
    Ok(())
}

Borrowing fails if the input contains escape sequences that must be decoded ("Du\\u006ee"), in which case &str fields return an error and Cow fields fall back to owning. Functions that must not borrow from their input take a T: DeserializeOwned bound (shorthand for for<'de> Deserialize<'de>).

Other formats

The same derives work with every serde format crate:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Settings {
    library_name: String,
    max_loans: u32,
    branches: Vec<String>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let s = Settings { library_name: "Shelf".into(), max_loans: 5, branches: vec!["north".into(), "south".into()] };

    let toml_text = toml::to_string(&s)?;                       // configuration files
    println!("{toml_text}");
    let from_toml: Settings = toml::from_str(&toml_text)?;

    let yaml = serde_yaml_ng::to_string(&s)?;                   // YAML (serde_yaml is unmaintained; use a fork)
    let from_yaml: Settings = serde_yaml_ng::from_str(&yaml)?;

    let bytes = rmp_serde::to_vec_named(&s)?;                   // MessagePack: compact binary
    let from_msgpack: Settings = rmp_serde::from_slice(&bytes)?;

    assert_eq!(from_toml, s);
    assert_eq!(from_yaml, s);
    assert_eq!(from_msgpack, s);
    println!("msgpack: {} bytes vs json: {} bytes", bytes.len(), serde_json::to_vec(&s)?.len());
    Ok(())
}

The original serde_yaml crate is archived and unmaintained; maintained forks such as serde_yaml_ng and serde_norway keep its API. For configuration, the config and figment crates layer TOML, YAML, JSON and environment variables on top of serde (see Axum: Configuration).

Precise error paths with serde_path_to_error

serde_json reports where in the text an error occurred, but not which field. serde_path_to_error wraps any deserializer and reports the path to the failing value — useful for user-facing error messages and validation responses (axum’s Json extractor uses it internally):

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Import {
    books: Vec<Row>,
}

#[derive(Debug, Deserialize)]
struct Row {
    title: String,
    year: u16,
}

fn main() {
    let json = r#"{"books":[{"title":"Dune","year":1965},{"title":"Emma","year":"1815"}]}"#;
    let de = &mut serde_json::Deserializer::from_str(json);
    match serde_path_to_error::deserialize::<_, Import>(de) {
        Ok(import) => println!("{} books", import.books.len()),
        Err(e) => println!("{}: {}", e.path(), e.inner()), // books[1].year: invalid type: string "1815", expected u16 ...
    }
}

Versioning payloads

Stored data and APIs outlive the code that wrote them. Rules that keep old payloads readable:

  • adding a field is safe if it is an Option or has #[serde(default)];

  • renaming a field: rename it and keep the old name with #[serde(alias = "old")];

  • removing a field is safe while unknown fields are ignored (do not use deny_unknown_fields on stored data);

  • for incompatible changes, add an explicit version and an internally tagged enum per version, and convert old versions to the current type after deserializing.

use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(tag = "version")]
enum StoredLoan {
    #[serde(rename = "1")]
    V1 { isbn: String, member: String },               // member was a name in v1
    #[serde(rename = "2")]
    V2 { isbn: String, member_id: u32, days: u16 },
}

#[derive(Debug)]
struct Loan {
    isbn: String,
    member_id: u32,
    days: u16,
}

impl From<StoredLoan> for Loan {
    fn from(s: StoredLoan) -> Self {
        match s {
            StoredLoan::V1 { isbn, member } => Loan { isbn, member_id: member.len() as u32, days: 21 }, // placeholder lookup
            StoredLoan::V2 { isbn, member_id, days } => Loan { isbn, member_id, days },
        }
    }
}

fn main() -> Result<(), serde_json::Error> {
    for raw in [r#"{"version":"1","isbn":"978","member":"alice"}"#, r#"{"version":"2","isbn":"978","member_id":7,"days":14}"#] {
        let loan: Loan = serde_json::from_str::<StoredLoan>(raw)?.into();
        println!("{loan:?}");
    }
    Ok(())
}

The shelf JSON import and export

The shelf CLI exports the catalogue as JSON and imports it back, using the serde support behind shelf-core's optional serde feature (see Cargo & Workspaces):

use std::fs::File;
use std::io::{BufReader, BufWriter};

use shelf_core::{Author, Book, Catalog, InMemoryCatalog, Isbn};

fn export(catalog: &InMemoryCatalog, path: &std::path::Path) -> anyhow::Result<()> {
    let mut books: Vec<&Book> = catalog.books().collect();
    books.sort_by(|a, b| a.title.cmp(&b.title));
    serde_json::to_writer_pretty(BufWriter::new(File::create(path)?), &books)?;
    Ok(())
}

fn import(path: &std::path::Path) -> anyhow::Result<InMemoryCatalog> {
    let books: Vec<Book> = serde_json::from_reader(BufReader::new(File::open(path)?))?;
    let mut catalog = InMemoryCatalog::new();
    for book in books {
        catalog.add(book)?;
    }
    Ok(catalog)
}

fn main() -> anyhow::Result<()> {
    let mut c = InMemoryCatalog::new();
    c.add(Book::new(Isbn::parse("978-0-441-01359-3")?, "Dune", vec![Author::new("Frank Herbert")], 1965))?;
    let path = std::env::temp_dir().join("shelf-export.json");
    export(&c, &path)?;
    let back = import(&path)?;
    println!("round trip: {} book(s)\n{}", back.len(), std::fs::read_to_string(&path)?);
    Ok(())
}

from_reader is convenient but slower than reading the whole file into a String and calling from_str, because the reader path cannot borrow and processes the input byte by byte; for large files, read first.

References