Customizing Serialization
Shape JSON and config output with serde field and container attributes - no manual impls required for most cases.
Search across all documentation pages
Shape JSON and config output with serde field and container attributes - no manual impls required for most cases.
Quick-reference recipe card - copy-paste ready.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiUser {
user_id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
#[serde(default)]
active: bool,
}When to reach for this: External APIs use different naming, optional fields, or nested layouts than your Rust structs.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct User {
user_id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
#[serde(default)]
role: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct Envelope {
#[serde(flatten)]
user: User,
version: u32,
}
fn main() {
let u = User { user_id: 1, display_name: None, role: "member".into() };
let json = serde_json::to_string(&u).unwrap();
assert!(json.contains("userId"));
assert!(!json.contains("displayName"));
let env = Envelope { user: u, version: 2 };
let flat = serde_json::to_string(&env).unwrap();
assert!(flat.contains(r#""version":2"#));
assert!(flat.contains(r#""userId":1"#));
}What this demonstrates:
rename_all maps Rust snake_case to camelCase JSON keys.skip_serializing_if omits None optional fields from output.default fills missing keys on deserialize.flatten merges nested struct fields into parent object.| Attribute | Effect |
|---|---|
rename / rename_all | Key naming |
skip / skip_serializing | Omit fields |
default | Missing key → Default::default() |
flatten | Inline nested struct keys |
alias | Accept alternate key names on deserialize |
with | Custom serialize fn for field type |
mod ts {
use serde::{Deserializer, Serializer};
pub fn serialize<S: Serializer>(dt: &time::OffsetDateTime, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&dt.format(&time::format_description::well_known::Rfc3339).unwrap())
}
}
#[serde(with = "ts")]
created_at: time::OffsetDateTime,default or Option.skip_serializing_if = "Vec::is_empty" only when API omits empty lists.with, serialize_with.| Alternative | Use When | Don't Use When |
|---|---|---|
| Separate DTO types | API and domain models diverge heavily | Small mapping overhead unacceptable |
serde_with crate | Common types like Duration | One-off with module suffices |
| Manual impl | Full control over wire shape | Attributes cover 90% of cases |
skip removes field both directions; skip_serializing only affects output.
Custom fn when inbound format differs from outbound.
Strict parsing - reject unexpected keys.
Newtype wraps inner serialization without extra key.
Relax generic bounds on derived impls for custom types.
See Enums & Tagging page for tag, content attributes.
Emit null vs omit key - control with skip_serializing_if.
alias = "oldName" accepts legacy keys when deserializing.
Supported with care - test round-trip thoroughly.
Generate from same structs with utoipa to avoid drift.
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.
Reviewed by Chris St. John·Last updated Jul 19, 2026