Serde and JSON in Practice
Derive Macros and Common Traits covered #[derive(Debug, Clone, ...)] for std traits. #[derive(Serialize, Deserialize)] from serde works the same mechanical way, generating an implementation field by field, except its attributes change how fields map onto JSON: renaming, making fields optional, picking custom formats, and representing enums as discriminated unions. Those attributes are where the real day-to-day friction with external JSON lives.
The Baseline
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
id: u64,
name: String,
}
let user = User { id: 1, name: "Ferris".to_string() };
let json = serde_json::to_string(&user)?; // {"id":1,"name":"Ferris"}
let parsed: User = serde_json::from_str(&json)?; // back to a User
By default, every field maps to a JSON key with the exact same name, and is required: missing it during deserialization is an error. Everything below is about loosening or adjusting that default.
Renaming Fields
Rust convention is snake_case; a lot of JSON APIs use camelCase. #[serde(rename_all = "camelCase")] on the struct converts every field at once, and #[serde(rename = "...")] on an individual field overrides just that one (useful when a JSON key isn't valid Rust syntax, or doesn't follow the API's own naming convention):
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Order {
order_id: u64, // serializes as "orderId"
#[serde(rename = "type")]
order_type: String, // serializes as "type" (a Rust keyword, can't be the field name)
}
This is the standard fix any time you're consuming or producing JSON from a source that doesn't follow Rust's own naming convention, you keep idiomatic Rust field names and let the attribute handle the translation.
Option<T> Fields Are Already Optional
A common point of confusion: does a missing JSON key fail deserialization, or quietly become None? Serde special-cases Option<T> fields specifically for this, a field typed Option<T> is treated as optional automatically, no extra attribute required. Both an absent key and an explicit null deserialize to None:
#[derive(Serialize, Deserialize, Debug)]
struct Profile {
name: String,
bio: Option<String>,
}
// both of these deserialize successfully, `bio` ends up `None` either way:
// {"name": "Ferris"}
// {"name": "Ferris", "bio": null}
For a field that isn't Option<T> but should still have a fallback when missing, use #[serde(default)] explicitly:
#[derive(Serialize, Deserialize)]
struct Settings {
#[serde(default)]
verbose: bool, // missing key becomes `false` (bool's Default), not an error
}
#[serde(default)] uses Default::default() for the field's type; #[serde(default = "some_fn")] uses a named function instead, for a fallback that isn't simply the type's default.
Omitting None from Serialized Output
The reverse direction has its own wrinkle: serializing a Profile { bio: None } from above produces {"name":"Ferris","bio":null}, the key is still present with an explicit null. To omit it from the output entirely instead, add skip_serializing_if:
#[derive(Serialize, Deserialize)]
struct Profile {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
bio: Option<String>,
}
// Profile { name: "Ferris", bio: None } now serializes as: {"name":"Ferris"}
This matters for APIs that distinguish "field present but null" from "field absent", or simply for keeping payloads smaller by not sending nulls for every unset optional field.
Custom (De)serialization for Types Serde Doesn't Know
Serde doesn't have a built-in representation for every type, a common example is wanting to serialize a Duration as a plain number of seconds instead of its default {"secs": ..., "nanos": ...} shape. #[serde(with = "module")] delegates a single field to a pair of free functions:
mod duration_secs {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(d.as_secs())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let secs = u64::deserialize(d)?;
Ok(Duration::from_secs(secs))
}
}
#[derive(Serialize, Deserialize)]
struct Job {
#[serde(with = "duration_secs")]
timeout: std::time::Duration,
}
This is the same escape hatch crates like chrono and uuid rely on internally to plug their types into serde without serde needing to know about them ahead of time, it's how you bridge any type, yours or a dependency's, to a JSON shape that doesn't match its default Serialize/Deserialize impl (if it has one at all).
Representing Enums as Discriminated Unions
Serde's default enum representation wraps the variant name around the data: Shape::Circle { radius: 5.0 } becomes {"Circle":{"radius":5.0}}. A lot of real-world JSON APIs instead use a "tag" field alongside the data at the same level, {"type":"circle","radius":5.0}, the shape #[serde(tag = "...")] produces:
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
// Shape::Circle { radius: 5.0 } serializes as:
// {"type":"circle","radius":5.0}
This internally-tagged representation is the one that matches how most hand-written JSON APIs encode "one of several variant shapes," matching it exactly with one attribute beats writing a custom Deserialize impl to inspect a tag field manually and dispatch on it yourself.
Key Takeaways
#[derive(Serialize, Deserialize)]is a mechanical, field-by-field derive, same as thestdderive macros, but its attributes (rename,default,skip_serializing_if,with,tag) control how each field maps to JSON.#[serde(rename_all = "camelCase")]bridges Rust'ssnake_caseconvention with JSON APIs that use a different one, without giving up idiomatic Rust field names.Option<T>fields are automatically optional on deserialize, missing ornull, both becomeNone. Use#[serde(default)]to give a non-Optionfield a fallback instead of erroring on a missing key.#[serde(skip_serializing_if = "Option::is_none")]omits aNonefield from the JSON output entirely, instead of serializing it as an explicitnull.#[serde(with = "module")]delegates a field to custom serialize/deserialize functions, the standard way to bridge a type serde doesn't natively support to whatever JSON shape you actually need.#[serde(tag = "...")]represents an enum as an internally-tagged discriminated union, matching how most real-world JSON APIs encode "one of several shapes," instead of serde's default variant-name-as-wrapper representation.