Serde’s Serialize and Deserialize traits aren’t just for built-in types; they’re your escape hatch for truly custom data handling.

Let’s say you have a User struct, but for some bizarre reason, your API expects the user_id to be a string, and the is_active boolean to be an integer (0 or 1).

#[derive(Debug, Serialize, Deserialize)]
struct User {
    user_id: u32,
    username: String,
    is_active: bool,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let user = User {
        user_id: 12345,
        username: "alice".to_string(),
        is_active: true,
    };

    // We want to serialize this into a format where:
    // user_id is a string "12345"
    // is_active is an integer 1

    // This is where custom serialization comes in.
    // We'll define a new struct that mirrors the desired output format
    // and implement Serialize for it.

    #[derive(Serialize)]
    struct SerializedUser<'a> {
        user_id: String,
        username: &'a str,
        is_active: u8,
    }

    impl<'a> SerializedUser<'a> {
        fn new(user: &'a User) -> Self {
            SerializedUser {
                user_id: user.user_id.to_string(),
                username: &user.username,
                is_active: user.is_active as u8,
            }
        }
    }

    let serialized_user = SerializedUser::new(&user);
    let json_output = serde_json::to_string_pretty(&serialized_user)?;
    println!("Serialized JSON:\n{}", json_output);

    // Now for deserialization. Imagine we receive JSON like this:
    let json_input = r#"
    {
        "user_id": "67890",
        "username": "bob",
        "is_active": 0
    }
    "#;

    // We need a struct that matches the incoming JSON.
    #[derive(Deserialize, Debug)]
    struct IncomingUser {
        user_id: String,
        username: String,
        is_active: u8,
    }

    // And then we'll convert it back to our internal `User` struct.
    impl IncomingUser {
        fn to_internal_user(self) -> Result<User, std::num::ParseIntError> {
            Ok(User {
                user_id: self.user_id.parse()?,
                username: self.username,
                is_active: self.is_active != 0,
            })
        }
    }

    let incoming_user: IncomingUser = serde_json::from_str(json_input)?;
    let internal_user = incoming_user.to_internal_user()?;
    println!("Deserialized User:\n{:?}", internal_user);

    Ok(())
}

This example shows how you can create intermediate structs that represent the external format you’re dealing with. For serialization, you construct an instance of your external-format struct from your internal data. For deserialization, you parse the external format into its corresponding struct, and then convert that into your internal representation.

The key is that Serde doesn’t care about the source of the data for Serialize or the destination for Deserialize. It only cares that the target type implements the respective trait. By implementing Serialize for SerializedUser and Deserialize for IncomingUser, we delegate the transformation logic to those structs themselves.

Here’s the output of the main function:

Serialized JSON:
{
  "user_id": "12345",
  "username": "alice",
  "is_active": 1
}
Deserialized User:
User { user_id: 67890, username: "bob", is_active: false }

The magic happens when you realize that the Serialize and Deserialize traits are often implemented for you by #[derive(...)]. When you #[derive(Serialize)] on User, Serde generates code that knows how to turn a User into a sequence of key-value pairs (or other structures depending on the format). For serialization, it iterates over the fields of User and asks Serde to serialize each one. For deserialization, it expects to receive serialized representations of those fields and reconstructs the User struct. When you need to deviate from this default behavior, you manually implement Serialize or Deserialize for a different type that represents the desired format, and then bridge the gap between your internal type and this external type.

The most surprising thing about custom Serde implementations is how often you don’t need to write complex logic yourself. Instead, you define a new struct that perfectly mirrors the JSON (or TOML, YAML, etc.) you’re receiving or sending, and then implement the Serialize or Deserialize traits for that struct. The conversion between your internal domain model and this external representation struct is then just plain Rust code, which is far easier to manage and debug than deeply nested serde attributes or custom Visitor patterns.

The next hurdle is handling complex nested structures or deeply different schemas where a simple intermediate struct isn’t enough, often leading you to explore the serde::de::SeqAccess, serde::de::MapAccess, and serde::ser::SerializeSeq, serde::ser::SerializeMap traits for fine-grained control.

Want structured learning?

Take the full Rust course →