When the shape doesn't match
You have a Color struct in your game engine. It stores red, green, and blue as three separate bytes. You want to save the game state to JSON. The default serialization dumps {"r": 255, "g": 0, "b": 0}. That works, but your save file format requires {"color": "#FF0000"} to stay compatible with an older version of the game. Or imagine a configuration file where a timeout is stored as 30s in the text, but your Rust code needs a std::time::Duration object. The memory layout and the file format disagree.
This is where custom serialization comes in. You need to tell the serializer how to translate between your Rust types and the external format without changing your data structures.
How serde bridges the gap
serde is the serialization framework. It defines traits like Serialize and Deserialize. Crates like serde_json or serde_yaml implement the actual encoding logic. Your types implement the traits.
Most of the time, you use #[derive(Serialize, Deserialize)]. This macro generates the trait implementations automatically. It assumes your struct fields map directly to the format fields. When that assumption breaks, you have three levels of customization. Attributes tweak the generated code. Helper functions inject custom logic at specific points. Manual trait implementations give you total control.
Start with attributes. They cover ninety percent of cases. Drop to manual implementation only when the macro cannot express what you need.
Minimal example: attributes and helpers
Attributes are the first line of defense. You can rename fields, skip options, and attach custom functions. The serialize_with and deserialize_with attributes let you plug in your own logic for a single field.
use serde::{Serialize, Deserialize};
/// A configuration struct with a custom duration format.
#[derive(Serialize, Deserialize)]
struct Config {
/// The timeout stored as seconds in JSON, but as Duration in Rust.
#[serde(
serialize_with = "serialize_duration_secs",
deserialize_with = "deserialize_duration_secs"
)]
timeout: std::time::Duration,
/// A standard field that needs no customization.
name: String,
}
/// Serialize a Duration as a u64 representing seconds.
/// The function signature is fixed by serde convention.
fn serialize_duration_secs<S>(duration: &std::time::Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Convert to seconds for human-readable output.
let seconds = duration.as_secs();
// Delegate to the serializer's u64 implementation.
seconds.serialize(serializer)
}
/// Deserialize a u64 representing seconds into a Duration.
fn deserialize_duration_secs<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
// Parse the seconds from the input.
let seconds = u64::deserialize(deserializer)?;
// Construct the Duration.
Ok(std::time::Duration::from_secs(seconds))
}
The helper functions must follow a strict signature. serialize_with expects a function that takes a reference to the value and a serializer, returning a Result. deserialize_with expects a function that takes a deserializer and returns a Result of the target type.
Convention aside: community code often groups these helpers in a private module or uses #[serde(with = "module_name")] when a module provides both serialization and deserialization for a type. This keeps the struct definition clean and signals that the logic is shared.
Attributes are the Swiss Army knife. If a function can do the job, don't write a trait impl.
Walkthrough: compile time and runtime
When you compile, the serde_derive macro expands. It scans your struct for attributes. When it finds serialize_with, it replaces the default field serialization code with a call to your function. The generated code looks roughly like this:
// Simplified expansion of the derive macro.
impl Serialize for Config {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: serde::Serializer {
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("Config", 2)?;
// The macro calls your function instead of serializing the field directly.
state.serialize_field("timeout", &serialize_duration_secs(&self.timeout))?;
state.serialize_field("name", &self.name)?;
state.end()
}
}
At runtime, serde_json calls serialize on your Config. The generated code runs. It calls serialize_duration_secs. Your function converts the duration to seconds and calls u64::serialize. The serializer writes the number to the JSON buffer. The result is {"timeout": 30, "name": "server"}.
Deserialization works in reverse. The deserializer reads the JSON, sees the timeout field, and calls your deserialize_duration_secs function. You parse the number and return the Duration. If parsing fails, you return an error, and serde propagates it up.
Trust the borrow checker here. The macro generates code that respects lifetimes. Your helper functions just need to match the signatures.
Realistic example: manual deserialization
Attributes and helpers handle transformations. They cannot change the structure. If your Rust type is a tuple struct Color(u8, u8, u8) but the JSON is a single string "#FF0000", you need manual implementation. The Serialize trait is straightforward. The Deserialize trait requires the Visitor pattern.
The Visitor pattern separates the deserialization logic from the type. serde calls methods on the visitor based on the input data. You implement the methods you accept. This allows serde to handle different input types efficiently.
use serde::{Deserialize, Deserializer, Serializer};
use serde::de::{Visitor, Error};
/// A color stored as RGB bytes, serialized as a hex string.
#[derive(Debug, PartialEq)]
struct Color(u8, u8, u8);
impl Serialize for Color {
/// Serialize the color as a hex string like "#FF0000".
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Format the bytes into a hex string.
let hex = format!("#{:02X}{:02X}{:02X}", self.0, self.1, self.2);
// Serialize the string.
serializer.serialize_str(&hex)
}
}
impl<'de> Deserialize<'de> for Color {
/// Deserialize a hex string into a Color.
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
// Create the visitor.
struct ColorVisitor;
impl<'de> Visitor<'de> for ColorVisitor {
type Value = Color;
/// Describe what the visitor expects for error messages.
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a hex color string like #RRGGBB")
}
/// Handle the case where the input is a string.
fn visit_str<E>(self, value: &str) -> Result<Color, E>
where
E: Error,
{
// Validate the format.
if value.len() != 7 || !value.starts_with('#') {
return Err(E::custom("expected 7-character hex string starting with #"));
}
// Parse the hex digits.
let r = u8::from_str_radix(&value[1..3], 16).map_err(E::custom)?;
let g = u8::from_str_radix(&value[3..5], 16).map_err(E::custom)?;
let b = u8::from_str_radix(&value[5..7], 16).map_err(E::custom)?;
Ok(Color(r, g, b))
}
}
// Tell serde to deserialize as a string and use the visitor.
deserializer.deserialize_str(ColorVisitor)
}
}
The Visitor trait requires you to implement methods for every input type you accept. Here, we only accept strings, so we implement visit_str. If the input is a number, serde returns an error automatically because we didn't implement visit_u64.
The expecting method is crucial. It generates the error message when the input type doesn't match. If you deserialize a number into this visitor, the error says expected a hex color string like #RRGGBB.
Convention aside: always implement expecting. It makes debugging deserialization failures much easier. The default implementation is generic and unhelpful.
The Visitor pattern is verbose. It's the price of zero-cost parsing.
Pitfalls and compiler errors
Custom serialization introduces new failure modes. The compiler will catch type mismatches, but runtime errors in deserialization require careful handling.
If you forget to implement Serialize on a nested type, the compiler rejects you with E0277 (the trait bound Serialize is not satisfied). This happens often when you add a new field to a struct and forget the derive attribute.
If your helper function returns the wrong type, you get E0308 (mismatched types). The serializer expects S::Ok, and the deserializer expects D::Error. Using ? in your helper functions works because serde provides From implementations for error types.
Infinite recursion is a silent killer. If type A serializes B, and B serializes A, the stack overflows. This usually happens when you use #[serde(with = "...")] on a module that re-exports the type. Check your dependency graph if serialization panics.
Deserialization panics are worse than compile errors. If your visit_str function assumes valid input and the input is malformed, it might panic. Always validate input in visitors. Use Error::custom to return descriptive errors instead of panicking.
Test your deserialization with invalid input. If it panics, you haven't handled errors.
Decision matrix
Use #[derive(Serialize, Deserialize)] when your struct fields map directly to the format fields and the types are standard.
Use #[serde(rename = "...")] when the external format uses different field names than your Rust code.
Use #[serde(serialize_with)] when you need a simple transformation, like formatting a number or encoding a string, without changing the structure.
Use #[serde(with = "...")] when a module provides both serialization and deserialization logic for a type, keeping the struct definition clean.
Use #[serde(skip_serializing_if)] when you want to omit optional fields that hold their default value, reducing file size.
Use manual trait implementation when the data format requires complex parsing, validation, or a completely different structure than your Rust type.
Use #[serde(default)] when you want to fill missing fields with default values during deserialization, making your structs resilient to schema changes.
Start with attributes. Only drop to manual impl when the macro refuses to cooperate.