Serde JSON Patterns, Tagged Enums and the Tricks That Save Time
TL;DR — Serde handles JSON via
Serialize/Deserializederives. Tagged enums for polymorphic payloads.rename_all = "camelCase"for matching JS conventions.#[serde(flatten)]for embedding. Custom (de)serializers for the 5% serde can’t infer. Knowing these few patterns covers 95% of real-world API code.
After Axum and sqlx, the third piece of the backend trio is JSON serialization. serde is the de facto crate. It’s been stable for years, supports a dozen formats, and the derive macro handles most cases without code.
This post is the specific serde patterns I lean on in real backend code — not the fundamentals (well-documented), but the ones nobody warns you about that you’ll discover three days into your first real API.
Setup
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
The basics:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct User {
id: u64,
email: String,
created_at: chrono::DateTime<chrono::Utc>,
}
// Serialize
let json = serde_json::to_string(&user)?;
// Deserialize
let user: User = serde_json::from_str(&json)?;
That’s the entire core API. The patterns below are the variations you’ll need.
Pattern 1 — rename_all for camelCase JSON
Rust uses snake_case. JavaScript APIs use camelCase. Bridge with one attribute:
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct User {
id: u64,
email_address: String, // serializes as "emailAddress"
created_at: DateTime<Utc>, // serializes as "createdAt"
}
Other options: kebab-case, snake_case, SCREAMING_SNAKE_CASE, PascalCase, etc. Apply at struct level for all fields, or per-field with #[serde(rename = "myField")].
Pattern 2 — Tagged enums for polymorphic payloads
The single most useful serde feature for real APIs. Three forms:
Externally tagged (default):
#[derive(Serialize, Deserialize)]
enum Event {
UserCreated { user_id: u64 },
UserDeleted { user_id: u64 },
}
Serializes as:
{"UserCreated": {"user_id": 42}}
Ugly but unambiguous.
Internally tagged (most common):
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
UserCreated { user_id: u64 },
UserDeleted { user_id: u64 },
}
Serializes as:
{"type": "UserCreated", "user_id": 42}
The type field discriminates. Clean for webhook payloads, message queues, etc.
Adjacently tagged:
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
UserCreated { user_id: u64 },
}
Serializes as:
{"type": "UserCreated", "data": {"user_id": 42}}
Useful when payloads are structured and you want a clean split.
For webhook handlers, internally tagged with serde(rename_all = "snake_case") on the enum:
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WebhookEvent {
UserCreated { user: User },
SubscriptionRenewed { subscription: Subscription },
PaymentFailed { invoice_id: String, reason: String },
}
async fn webhook(Json(event): Json<WebhookEvent>) -> StatusCode {
match event {
WebhookEvent::UserCreated { user } => { /* ... */ }
WebhookEvent::SubscriptionRenewed { subscription } => { /* ... */ }
WebhookEvent::PaymentFailed { invoice_id, reason } => { /* ... */ }
}
StatusCode::OK
}
Type-safe webhook dispatch in five lines.
Pattern 3 — flatten for embedded fields
When a JSON object embeds another object’s fields inline:
#[derive(Deserialize)]
struct Pagination {
page: u32,
per_page: u32,
}
#[derive(Deserialize)]
struct ListUsersRequest {
filter: String,
#[serde(flatten)]
pagination: Pagination,
}
Accepts:
{"filter": "active", "page": 1, "per_page": 25}
Useful for reusing a Pagination struct across many request types without nesting.
Pattern 4 — Default values for missing fields
#[derive(Deserialize)]
struct Config {
name: String,
#[serde(default)]
enabled: bool, // false if missing
#[serde(default = "default_timeout")]
timeout_ms: u64,
}
fn default_timeout() -> u64 { 5000 }
#[serde(default)] calls Default::default() for the field’s type. #[serde(default = "fn")] calls a custom function.
For deserialization where missing = absent vs missing = explicit null:
#[derive(Deserialize)]
struct User {
email: Option<String>, // missing or null → None
#[serde(default, deserialize_with = "double_option")]
nickname: Option<Option<String>>, // missing → None, null → Some(None), value → Some(Some(v))
}
The double-option pattern catches “I want to explicitly set null vs leave alone” semantics — common for PATCH endpoints.
Pattern 5 — Custom serializers for specific fields
When derive isn’t enough — e.g., serializing a timestamp as ISO 8601 string:
use chrono::{DateTime, Utc};
use serde::{Serializer, Deserializer, Deserialize};
fn serialize_iso<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&dt.to_rfc3339())
}
fn deserialize_iso<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
let s = String::deserialize(d)?;
DateTime::parse_from_rfc3339(&s)
.map_err(serde::de::Error::custom)
.map(|dt| dt.with_timezone(&Utc))
}
#[derive(Serialize, Deserialize)]
struct Event {
#[serde(serialize_with = "serialize_iso", deserialize_with = "deserialize_iso")]
timestamp: DateTime<Utc>,
}
For the common case (RFC 3339 timestamps), the chrono crate’s default Display/FromStr is already RFC 3339-compatible if you enable the serde feature on chrono — usually you don’t need the custom impl.
Pattern 6 — Skip serialization for null/empty
#[derive(Serialize)]
struct User {
id: u64,
email: String,
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
}
Output:
{"id": 1, "email": "a@b.com"}
Cleaner API responses. Without skip, you’d get "nickname": null, "tags": [] cluttering things.
Pattern 7 — JSON values for unknown / dynamic shape
For data you genuinely don’t know the shape of (webhook bodies you’ll inspect at runtime, debug logs), use serde_json::Value:
#[derive(Deserialize)]
struct Webhook {
event_type: String,
data: serde_json::Value,
}
if webhook.event_type == "payment.created" {
let amount = webhook.data["amount"].as_i64().unwrap_or(0);
}
Use sparingly. Reach for typed enums first; Value is the escape hatch for the inherently dynamic.
Pattern 8 — Validation at deserialize time
#[derive(Deserialize)]
#[serde(try_from = "RawUser")]
struct User {
email: String,
age: u32,
}
#[derive(Deserialize)]
struct RawUser {
email: String,
age: u32,
}
impl TryFrom<RawUser> for User {
type Error = String;
fn try_from(raw: RawUser) -> Result<Self, Self::Error> {
if !raw.email.contains('@') {
return Err("invalid email".into());
}
if raw.age > 130 {
return Err("invalid age".into());
}
Ok(User { email: raw.email, age: raw.age })
}
}
The try_from attribute runs validation during deserialization. Invalid JSON returns a deserialization error before your handler sees it.
For richer validation (multiple fields, async checks), do it in the handler after deserializing. Don’t over-engineer the serde layer.
Common Pitfalls
Defaulting to Option<T> for every field. Makes the type system useless. Use Option only when “missing” or “null” is meaningfully different from “present.”
Forgetting serde feature flag on chrono / uuid / etc. A struct with a chrono::DateTime field won’t derive Serialize/Deserialize unless chrono’s serde feature is on. Same for uuid. Error messages are unhelpful.
Internally tagged enums with non-struct variants. enum E { A, B { x: i32 } } doesn’t work as internally tagged because A has no place to put a tag. Use externally tagged, or restructure.
Serializing big trees as String first. serde_json::to_string then writing the string is fine for small payloads. For big ones, use serde_json::to_writer to stream directly into the response/file.
Not using Cow<'a, str> for borrowed deserialization. When deserializing JSON that you don’t need to outlive the input, Cow<str> avoids unnecessary allocations. Optimization for hot paths only.
Mixing rename_all and explicit rename. Per-field #[serde(rename = "...")] overrides rename_all. Easy to forget and end up with one camelCase field amid snake_case ones.
Wrapping Up
serde plus eight patterns covers basically every real-world JSON situation in backend Rust. The compile-time guarantees are similar to what sqlx gives for SQL — once the code compiles, the shapes are right. Wednesday: structured logging with tracing — the observability layer that ties Axum + sqlx + serde together with request-scoped context.