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"
Source: serde: Using derive
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(())
}
Source: serde: Using derive
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 every field: |
|
use a different name in the data |
|
also accept this name when deserializing |
|
use |
|
omit the field when the predicate is true |
|
exclude a field |
|
inline the fields of a nested struct or a map of extra keys |
|
reject unknown keys instead of ignoring them |
|
choose how enums are represented (below) |
|
use custom functions for one field |
|
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(())
}
Source: serde: Attributes
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(())
}
Source: serde: Enum representations
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(())
}
Source: serde: Custom serialization
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(())
}
Source: serde_json documentation
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(())
}
Source: serde: Data formats
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
Optionor 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_fieldson 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(())
}
Source: serde: Attributes
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(())
}
Source: serde_json::from_reader
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
-
serde.rs — overview, derive, attributes, enum representations, custom serialization, lifetimes, data formats
-
Rust by Example (custom types and traits used with serde)