serde Best Practices
Rules for stable, versioned, forward-compatible serialization schemas in production Rust services.
How to Use This List
- Apply when designing public APIs, event buses, and persisted blobs.
- Add snapshot tests in CI for every schema change.
- Treat breaking wire changes like database migrations.
A - Schema Design
- Prefer explicit enum tags in public JSON. Avoid
untaggedfor external APIs. - Use
#[serde(default)]on new optional fields. Old clients can omit them. - Add
versionfield to persisted blobs. Migrate readers explicitly. - Separate API DTOs from domain models when shapes diverge. Do not leak internal names.
- Document null vs missing semantics.
Optionskips vs explicitnullaffects clients.
B - Compatibility
- Never remove or rename fields without a version bump. Use
aliasfor renames during transition. -
deny_unknown_fieldson inbound untrusted data. Reject surprise keys. - Snapshot JSON for every enum variant. Catch tagging regressions in CI.
- Test round-trip per wire format you ship. JSON != bincode layout.
- Keep
#[serde(other)]variant for unknown event types. Forward-compatible consumers.
C - Performance & Safety
- Cap request body size before deserialize. Tower body limit or manual check.
- Use borrowed
&stronly when buffer outlives value. Default toStringin handlers. - Avoid
serde_json::Valuefor hot paths. Typed structs or streaming. - Limit collection sizes in custom deserializers. Prevent allocation bombs.
- Do not
unwrapparse results on untrusted input. Map to typed errors.
D - Operations
- Log serde failures without full payload. Hash or truncate for GDPR.
- Pin serde and format crates in workspace. Reproducible builds.
- Feature-gate optional formats. Smaller binaries when only JSON needed.
- Align OpenAPI/Protobuf with serde types. Single source of truth where possible.
- Run
cargo fuzzon decode entry points. Find panics before attackers do.
FAQs
Biggest serde mistake?
Breaking wire compatibility without version field or consumer upgrade path.
DTO proliferation?
Acceptable at API boundaries - cheaper than breaking mobile clients.
camelCase policy?
rename_all = "camelCase" on all external JSON DTOs - consistent once chosen.
bincode in DB?
Only if Rust-only readers; version prefix mandatory.
Secret fields?
#[serde(skip_serializing)] on secrets; never deserialize inbound secrets from clients.
Timestamp format?
RFC 3339 strings via serde_with or custom with module - document timezone.
Large integers?
String-encode u64 IDs in JSON for JavaScript interop if needed.
Polymorphic lists?
Internally tagged enum array - snapshot each variant.
Config files?
TOML for human edit; strict deny_unknown_fields on load.
Testing minimum?
Round-trip + unknown field rejection + one legacy payload fixture.
Related
- serde Basics - Fundamentals
- Enums & Tagging - Wire shapes
- Error Handling & Validation - Untrusted input
- API Design Basics - HTTP contracts
- Web Backends Best Practices - Service layer
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+.