When the default format doesn't match the wire
You're building a config file. The user writes NAME = "alice". Your code expects name to be lowercase. But the file format demands uppercase for some legacy reason. Or you're talking to an API that sends dates as strings like "2023-10-27" but you want i64 timestamps internally. You can't just derive Serialize and Deserialize on the struct. The default behavior won't match the wire format. You need a custom adapter for just that one field, without rewriting the whole serialization logic.
Don't rewrite the derive. Delegate the field.
The concept: a module as a bridge
#[serde(with = "module_path")] is a delegation tag. You tell Serde: "For this field, don't use the standard rules. Go ask this module how to handle it." The module acts as a bridge between your Rust type and the serialization stream.
Think of it like a translator at a border. The passport (data) has a specific format. The customs officer (Serde) doesn't speak the language. The translator (the with module) takes the passport, converts it to the officer's language, and hands it over. On the way back, the translator converts the officer's stamp back to the passport format.
The module must export two functions: serialize and deserialize. These functions have strict signatures. Serde calls serialize when writing, passing the Rust value. It calls deserialize when reading, passing the raw data stream. The functions transform the data and hand it to the serializer or deserializer.
Treat the module as a contract. Serde calls the functions; you provide the transformation.
Minimal example
Here is the smallest working setup. The module transforms a string to uppercase on write and lowercase on read.
use serde::{Serialize, Deserialize};
// The adapter module.
// Serde looks for `serialize` and `deserialize` functions here.
mod uppercase_adapter {
use serde::{Serializer, Deserializer};
/// Serialize the string by converting to uppercase.
pub fn serialize<S>(value: &String, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Transform the value before handing it to the serializer.
serializer.serialize_str(&value.to_uppercase())
}
/// Deserialize the string by converting to lowercase.
pub fn deserialize<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
// Ask the deserializer for a String, then transform the result.
let s = String::deserialize(deserializer)?;
Ok(s.to_lowercase())
}
}
#[derive(Serialize, Deserialize)]
struct Config {
// Delegate this field to the adapter module.
#[serde(with = "uppercase_adapter")]
name: String,
}
fn main() {
let config = Config { name: "Alice".to_string() };
let json = serde_json::to_string(&config).unwrap();
// Output: {"name":"ALICE"}
println!("{}", json);
let restored: Config = serde_json::from_str(r#"{"name":"BOB"}"#).unwrap();
// restored.name is "bob"
println!("{}", restored.name);
}
Match the signature exactly. The compiler will reject any deviation.
What happens under the hood
When you derive Serialize, Serde generates an implementation. For the name field, it doesn't write serializer.serialize_str(&self.name). Instead, it writes uppercase_adapter::serialize(&self.name, serializer). The compiler checks that uppercase_adapter has a serialize function with the right signature. If the signature is wrong, you get a type error.
The generic parameters S and D are the key to flexibility. S: Serializer means your function works with any serializer: JSON, YAML, Bincode, or a custom format. The adapter doesn't care about the output format. It just calls methods on S. When you call serializer.serialize_str, the JSON serializer writes a quoted string. The YAML serializer writes a plain string. The binary serializer writes the length followed by bytes. Your adapter code stays identical.
Write the adapter once. Use it for JSON, YAML, and Bincode without touching the code.
At runtime, the function runs. It transforms the data and hands it to the serializer. The serializer doesn't know about the transformation. It just sees a string being serialized. The same logic applies to deserialization. deserialize takes D: Deserializer. It calls String::deserialize(deserializer). This asks the deserializer to parse a string. The deserializer handles the parsing. Your function just gets the result. If the data is missing or malformed, the deserializer returns an error. You propagate it with ?.
Realistic example: hex colors
Adapters shine when the Rust type and the wire format are structurally different. Here is a Theme struct where the primary color is stored as an RGB tuple but serialized as a hex string.
use serde::{Serialize, Deserialize};
mod hex_color {
use serde::{Serializer, Deserializer};
use serde::de::Error;
/// Serialize an RGB tuple as a hex string like "#ff00aa".
pub fn serialize<S>((r, g, b): &(u8, u8, u8), serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Format the tuple as a hex string.
let hex = format!("#{:02x}{:02x}{:02x}", r, g, b);
serializer.serialize_str(&hex)
}
/// Deserialize a hex string back into an RGB tuple.
pub fn deserialize<'de, D>(deserializer: D) -> Result<(u8, u8, u8), D::Error>
where
D: Deserializer<'de>,
{
let hex = String::deserialize(deserializer)?;
// Validate the format.
if !hex.starts_with('#') || hex.len() != 7 {
return Err(D::Error::custom("Invalid hex color format"));
}
// Parse each component.
let r = u8::from_str_radix(&hex[1..3], 16).map_err(D::Error::custom)?;
let g = u8::from_str_radix(&hex[3..5], 16).map_err(D::Error::custom)?;
let b = u8::from_str_radix(&hex[5..7], 16).map_err(D::Error::custom)?;
Ok((r, g, b))
}
}
#[derive(Serialize, Deserialize)]
struct Theme {
#[serde(with = "hex_color")]
primary: (u8, u8, u8),
}
Keep adapters small. If the logic grows, move it to a helper function inside the module.
Pitfalls and compiler errors
The with attribute expects a path to a module. If you write #[serde(with = "my_func")], Serde looks for a module named my_func. It does not look for a function. This trips up beginners who try to point directly to a function. You must wrap your functions in a module, or use serialize_with/deserialize_with to point to functions directly. The module approach is preferred for pairs of functions.
Signature mismatches are common. The serialize function must take &T and S. The deserialize function must take D and return T. If you swap arguments or change the reference, the compiler rejects the code. You'll see an error like E0277 (trait bound not satisfied) or a message about mismatched types. Check the function signatures against the Serde documentation.
Error handling in deserialize requires care. You can't just return a String error. You must use D::Error::custom("message") to lift the error into the deserializer's error type. The ? operator works because D::Error implements From for common error types. If you parse a number and get a ParseIntError, you can map it to D::Error::custom. The deserializer will format the error correctly for the output format.
Use D::Error::custom. It's the only way to report errors from a deserializer function.
Decision: when to use what
Use #[serde(with = "module")] when you need to transform a field's representation on both read and write, and the logic belongs together.
Use #[serde(serialize_with = "module::func")] when you only need to change how the field is written, leaving deserialization to the default behavior.
Use #[serde(deserialize_with = "module::func")] when you only need to handle incoming data differently, such as accepting multiple formats, while writing remains standard.
Use #[serde(rename = "field_name")] when the wire format uses a different key name but the data type is identical.
Implement Serialize and Deserialize on the type itself when the transformation applies to every instance of the type across the codebase, not just one field.
If every instance needs the transform, put it on the type. with is for field-specific quirks.