rust-impl-serde
The structured data interchange skill: how to derive Serialize / Deserialize, shape the wire format with container and field attributes, pick the right enum representation, write a custom impl when the derive is not enough, and choose between JSON, TOML, YAML, bincode, and MessagePack. Serde is a compile-time framework: no runtime reflection, zero overhead, the data-structure / data-format interaction "can be completely optimized away by the Rust compiler" (per serde.rs).
Cross-references: [[rust-impl-cargo-project]] (feature flags, the derive feature), [[rust-syntax-traits]] (Serialize / Deserialize as trait bounds, generic impls), [[rust-syntax-lifetimes]] (zero-copy deserialization with #[serde(borrow)] and 'de).
When to use this skill
- User adds
#[derive(Serialize, Deserialize)]and getserror: cannot find derive macro(forgot thederivefeature). - User asks how to rename fields, accept legacy names, or convert all field names to
camelCaseat once. - User has an enum and asks which JSON shape to emit: tagged, untagged, adjacently tagged.
- User wants to flatten a sub-struct into the parent shape, or pull arbitrary extra fields into a
HashMap. - User wants zero-copy deserialization with
&'a strand getserror: implementation of Deserialize is not general enough. - User asks whether to choose
serde_json,toml,serde_yaml,bincode, orrmp-serdefor a given task. - User asks how to write a manual
Serialize/Deserializeimpl when the derive cannot express the format. - User mixes
#[serde(rename = ...)]with#[serde(alias = ...)]for a schema migration.
Cargo.toml setup: enable the derive feature
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1" # or toml, serde_yaml, bincode, rmp-serde
ALWAYS enable features = ["derive"] on the serde dependency. Without it, #[derive(Serialize, Deserialize)] does not resolve because the proc-macro lives in a separate crate (serde_derive) that is gated behind that feature.
NEVER add serde_derive as a direct dependency. Always go through serde's derive feature so the versions stay in lockstep.
For no_std + alloc, use serde = { version = "1", default-features = false, features = ["derive", "alloc"] }. Internally / adjacently tagged and untagged enums require alloc for deserialization (see enum-representations section).
The two traits in one minute
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Point {
x: i32,
y: i32,
}
let p = Point { x: 1, y: 2 };
let json = serde_json::to_string(&p)?; // "{\"x\":1,\"y\":2}"
let back: Point = serde_json::from_str(&json)?;
Serialize: turn a Rust value into a sequence of calls on aSerializer(the format-specific encoder).Deserialize<'de>: build a Rust value from a sequence of calls into aDeserializer(the format-specific decoder). The'delifetime is the lifetime of the input buffer being borrowed.
ALWAYS derive both traits unless you only need one direction; the cost is one extra impl block at compile time, zero at runtime.
Container attributes: shape the whole struct or enum
Container attributes apply to the top-level struct or enum. Listed with the exact values accepted by serde (from serde.rs/container-attrs.html):
| Attribute | Accepted values / form | Effect |
|---|---|---|
rename = "..." |
string literal | Change the type or variant name on the wire. |
rename_all = "..." |
lowercase, UPPERCASE, PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE |
Rewrite all field / variant names in the chosen case. |
rename_all_fields = "..." |
same eight values | Same as rename_all, but applied to the fields of each struct variant in an enum. |
deny_unknown_fields |
flag (no value) | Error on extra fields during deserialization. |
tag = "..." |
string literal | Switch the enum to internally-tagged representation. |
tag = "..." , content = "..." |
two string literals | Adjacently-tagged representation. |
untagged |
flag | Untagged enum representation. |
default / default = "fn" |
flag or function name | Missing-field defaults from Default or a custom fn() -> T. |
transparent |
flag (newtype only) | Serialize / deserialize the single inner field directly, with no wrapper. |
from = "Type" / into = "Type" / try_from = "Type" |
type name | Route serialization through an intermediate type. |
bound = "..." |
where-clause string | Override the generated T: Serialize / T: Deserialize bounds for generic structs. |
remote = "..." |
path of a remote type | Derive impls for a type defined in another crate (orphan-rule workaround). |
expecting = "..." |
string literal | Customise the "expected ..." part of error messages. |
crate = "..." |
path | Override the path used to reference the serde crate (re-exports). |
rename_all is the most common one
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiRequest {
user_id: u64, // <- "userId" on the wire
access_token: String,// <- "accessToken"
}
ALWAYS use rename_all instead of #[serde(rename = "...")] on every individual field when the only goal is a case convention. NEVER mix the two for one struct: pick rename_all for the whole struct, and use per-field rename only for exceptions (legacy spelling, reserved word, etc.).
deny_unknown_fields is the strict mode
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
port: u16,
host: String,
}
ALWAYS apply deny_unknown_fields to config structs where unknown keys signal a user typo. NEVER apply it to API-response structs that may grow new fields over time; the strict mode will break clients the day the server adds a key.
transparent for newtypes
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);
Without transparent, a UserId(7) serializes to JSON as 7 only because newtype-struct support is built in. The transparent flag is required for named single-field structs (struct UserId { id: u64 }) to omit the wrapper object.
Field attributes: shape individual fields
Field attributes apply to a single field inside a struct or struct variant (from serde.rs/field-attrs.html):
| Attribute | Form | Effect |
|---|---|---|
rename = "..." |
string | Rename one field on the wire. |
alias = "..." |
string, repeatable | Accept additional names only on deserialize. The Rust name and any rename value remain valid; aliases stack. |
default / default = "fn" |
flag or function | Use Default::default() or fn() -> T if absent on deserialize. |
skip |
flag | Skip the field in both directions. Requires Default for deserialization. |
skip_serializing |
flag | Skip on serialize only. |
skip_deserializing |
flag | Skip on deserialize only. Requires Default (or default = "fn"). |
skip_serializing_if = "fn" |
predicate fn(&T) -> bool |
Skip only when predicate returns true. |
serialize_with = "fn" |
path | Override Serialize::serialize for this field only. |
deserialize_with = "fn" |
path | Override Deserialize::deserialize for this field only. |
with = "module" |
path to module | Sugar for serialize_with = "module::serialize" + deserialize_with = "module::deserialize". |
flatten |
flag | Inline this field's contents into the parent map. |
borrow / borrow = "'a + 'b" |
flag or lifetime list | Mark the field as borrowing from the input (zero-copy &'a str, Cow<'a, str>, &'a [u8]). |
bound = "..." |
where-clause | Per-field generic bound override. |
getter = "fn" |
path | For remote = ... types with private fields: how to read the field. |
alias vs rename: the schema-migration rule
#[derive(Deserialize)]
struct User {
#[serde(rename = "userId", alias = "user_id", alias = "id")]
user_id: u64,
}
renamecontrols the wire name in both directions.aliascontrols additional accepted names on deserialize only.
ALWAYS pair a rename (forward-compatible new name) with one or more alias entries for old names during migration. NEVER change rename from "user_id" to "userId" without adding #[serde(alias = "user_id")]; existing payloads will reject.
skip_serializing_if for Option fields
#[derive(Serialize)]
struct Patch {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
}
ALWAYS use skip_serializing_if = "Option::is_none" for Option fields in JSON-patch / partial-update payloads. NEVER serialize a literal "name": null when the caller meant "do not change name".
flatten: pull a sub-struct up
#[derive(Serialize, Deserialize)]
struct Page<T> {
items: Vec<T>,
#[serde(flatten)]
pagination: Pagination,
}
#[derive(Serialize, Deserialize)]
struct Pagination {
page: u32,
total: u32,
}
Wire shape: {"items":[...],"page":1,"total":42} (the pagination key is gone).
NEVER combine #[serde(flatten)] with #[serde(deny_unknown_fields)] on the same parent: flatten is internally implemented by capturing "extra" fields, which deny_unknown_fields rejects. The compiler will not warn; the runtime error is unknown field <flattened-field>, expected one of ....
borrow for zero-copy
#[derive(Deserialize)]
struct Slice<'a> {
#[serde(borrow)]
name: &'a str,
}
ALWAYS annotate &'a str, &'a [u8], and Cow<'a, str> fields with #[serde(borrow)] when the goal is zero-copy. Without it, serde tries to satisfy Deserialize<'static> and the compile error is implementation of Deserialize is not general enough. The lifetime 'a must outlive the deserializer's 'de.
Enum representations: pick exactly one
Four representations, in increasing JSON noisiness (verified against serde.rs/enum-representations.html):
| Representation | Attribute | JSON example for Message::Move { x: 1, y: 2 } |
Limitations |
|---|---|---|---|
| Externally tagged (default) | none | {"Move": {"x": 1, "y": 2}} |
None; works everywhere, including binary and no_std + no_alloc. |
| Internally tagged | #[serde(tag = "type")] |
{"type": "Move", "x": 1, "y": 2} |
NO tuple variants. Newtype variants must wrap a struct or map. Requires alloc for deserialize. |
| Adjacently tagged | #[serde(tag = "t", content = "c")] |
{"t": "Move", "c": {"x": 1, "y": 2}} |
Any variant type. Requires alloc for deserialize. |
| Untagged | #[serde(untagged)] |
{"x": 1, "y": 2} |
Variants must be structurally distinguishable. First successful match wins. Requires alloc. |
Decision tree
Does the wire format already exist?
YES -> match it exactly (no choice).
NO -> continue.
Is this a binary or no_alloc target?
YES -> externally tagged (default). DONE.
NO -> continue.
Do you want the variant tag inline with the data (e.g. `{"type": "..."}`)?
YES, and all variants are struct-shaped -> internally tagged.
YES, but some variants are tuples or primitives -> adjacently tagged.
NO, and the variants are structurally distinct -> untagged.
NO, and you want the cleanest fallback -> externally tagged.
NEVER pick untagged when two variants could match the same input. Serde tries variants in source order and returns the first one that deserializes successfully, which is a silent footgun.
When the derive is not enough: manual impl
The derive covers ~95% of cases. Write a manual Serialize / Deserialize impl when:
- You need a format that does not fit any combination of attributes (e.g. a date-time stored as both an integer and a string depending on a header).
- You are implementing serde for a type that contains a non-
Serializefield that you compute on the fly. - You need to read from the deserializer in a stateful way (peek, lookahead).
use serde::ser::{Serialize, Serializer, SerializeStruct};
impl Serialize for Color {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut s = serializer.serialize_struct("Color", 3)?;
s.serialize_field("r", &self.r)?;
s.serialize_field("g", &self.g)?;
s.serialize_field("b", &self.b)?;
s.end()
}
}
ALWAYS prefer with = "module" + serialize_with / deserialize_with when only one field needs a custom shape. Only write a full impl when the whole type needs custom logic.
See references/methods.md for the full Visitor pattern used in manual Deserialize.
Format crate matrix: choose the wire encoding
| Crate | Version (May 2026) | Wire format | When to choose |
|---|---|---|---|
serde_json |
1.x | JSON (UTF-8 text) | Web APIs, config files when humans edit, log lines. The default. |
toml |
0.8.x | TOML | Cargo.toml-style config, anything where comments matter. |
serde_yaml |
0.9.x | YAML | CI/CD configs, k8s manifests. Note: the crate is in maintenance mode; consider serde_yml as the active fork. |
bincode |
2.x | Compact binary | Internal IPC, caches, on-disk format owned by the same Rust program on both sides. NOT portable across languages. |
rmp-serde |
1.x | MessagePack | Cross-language compact binary, smaller and faster than JSON. |
ciborium |
0.2.x | CBOR (RFC 8949) | When the spec mandates CBOR (e.g. IoT, COSE, WebAuthn). |
serde_qs |
0.13.x | URL query strings | Parsing ?a=1&b=2&nested[x]=1-style query params. |
csv |
1.x | CSV | Tabular data; uses serde for row mapping. |
ALWAYS pick the simplest format that fits the actual constraint. NEVER reach for bincode to "make JSON faster" unless you have measured a serialization bottleneck; JSON is fast enough for almost all web APIs.
Quick recipes
Round-trip a struct through JSON
let v = Point { x: 1, y: 2 };
let s = serde_json::to_string(&v)?; // owned String
let s_pretty = serde_json::to_string_pretty(&v)?;
let back: Point = serde_json::from_str(&s)?;
Untyped JSON: serde_json::Value
let v: serde_json::Value = serde_json::from_str(r#"{"a":1}"#)?;
let a = v["a"].as_i64().ok_or("not an int")?;
Use Value for truly dynamic JSON (unknown shape until runtime). For known shape: always a typed struct.
Move a typed value through Value for inspection
let value: serde_json::Value = serde_json::to_value(&point)?;
let back: Point = serde_json::from_value(value)?;
Read a TOML file
let s = std::fs::read_to_string("Config.toml")?;
let cfg: Config = toml::from_str(&s)?;
Custom with = module for a chrono DateTime
mod ts_seconds {
use chrono::{DateTime, Utc, TimeZone};
use serde::{Deserializer, Serializer, Deserialize};
pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_i64(dt.timestamp())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
let secs = i64::deserialize(d)?;
Ok(Utc.timestamp_opt(secs, 0).single().expect("valid unix ts"))
}
}
#[derive(Serialize, Deserialize)]
struct Event {
#[serde(with = "ts_seconds")]
occurred_at: chrono::DateTime<chrono::Utc>,
}
(chrono::serde::ts_seconds ships this for free; the snippet shows the pattern.)
Catch-all extra fields with flatten + HashMap
#[derive(Serialize, Deserialize)]
struct Loose {
id: u64,
#[serde(flatten)]
extra: std::collections::HashMap<String, serde_json::Value>,
}
Any unknown JSON field falls into extra. Symmetric on serialize: the map's entries become top-level keys.
Validation checklist
Before merging any code that uses serde:
serde = { version = "1", features = ["derive"] }(NOTserde_derivedirectly).- Every
&'a str/&'a [u8]/Cow<'a, _>field on aDeserializestruct has#[serde(borrow)]. - No enum has both
#[serde(untagged)]and structurally-overlapping variants. - No field has both
#[serde(flatten)]and a parent#[serde(deny_unknown_fields)]. - Every
Optionin a JSON-patch / partial-update payload has#[serde(skip_serializing_if = "Option::is_none")]. - Schema migrations use
#[serde(alias = "old_name")], not a silent#[serde(rename = ...)]swap. - Config-file structs use
#[serde(deny_unknown_fields)]; API-response structs do not. serde_json::Valueappears only at trust boundaries; the rest of the code uses typed structs.bincodeis only used for Rust-to-Rust traffic; cross-language traffic uses JSON, MessagePack, or CBOR.
Reference links
- Serde overview: https://serde.rs/
- Container attributes: https://serde.rs/container-attrs.html
- Field attributes: https://serde.rs/field-attrs.html
- Variant attributes: https://serde.rs/variant-attrs.html
- Enum representations: https://serde.rs/enum-representations.html
- Data model: https://serde.rs/data-model.html
serde_jsondocs: https://docs.rs/serde_json/latest/serde_json/tomldocs: https://docs.rs/toml/latest/toml/bincodedocs: https://docs.rs/bincode/latest/bincode/
For deeper material: see references/methods.md (every container / field / variant attribute with exact syntax), references/examples.md (round-trip recipes), references/anti-patterns.md (footguns and fixes).