Rust Serde: Serialization and Deserialization

Serde is Rust's de facto serialization framework, providing a generic interface for converting data structures to and from various formats. The name combines 'serialization' and 'deserialization,'...

Key Insights

  • Serde provides zero-cost serialization through compile-time code generation via derive macros, making it as fast as hand-written serialization code without the maintenance burden
  • Field-level attributes like rename, skip_serializing_if, and flatten handle 90% of real-world serialization requirements without custom implementations
  • Choose enum tagging strategies based on your API constraints: externally tagged for Rust idioms, internally tagged for cleaner JSON, and untagged for polymorphic data

Introduction to Serde

Serde is Rust’s de facto serialization framework, providing a generic interface for converting data structures to and from various formats. The name combines “serialization” and “deserialization,” and it lives up to its purpose by supporting JSON, TOML, YAML, MessagePack, CBOR, and dozens of other formats through a unified API.

The framework solves a critical problem: how do you serialize data structures efficiently without writing boilerplate for every format? Serde’s answer is trait-based abstraction. Data structures implement Serialize and Deserialize traits once, and format-specific libraries handle the actual encoding. This separation means you can switch from JSON to MessagePack by changing a single function call.

Serde embodies Rust’s zero-cost abstraction philosophy. The derive macros generate specialized code at compile time, producing machine code as efficient as hand-written serialization logic. There’s no runtime reflection, no virtual dispatch overhead, and no garbage collection. You get developer productivity without sacrificing performance.

Add Serde to your project with these dependencies:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

The derive feature enables the procedural macros that make Serde practical for everyday use.

Basic Serialization and Deserialization

The foundation of Serde is two traits: Serialize for converting data to a format, and Deserialize for parsing data back into structures. In practice, you’ll rarely implement these manually. The derive macros handle the tedious work.

Here’s a basic example:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: u64,
    username: String,
    email: String,
    active: bool,
}

fn main() {
    let user = User {
        id: 1,
        username: "alice".to_string(),
        email: "alice@example.com".to_string(),
        active: true,
    };

    // Serialize to JSON string
    let json = serde_json::to_string(&user).unwrap();
    println!("Serialized: {}", json);
    // Output: {"id":1,"username":"alice","email":"alice@example.com","active":true}

    // Deserialize from JSON string
    let json_input = r#"{"id":2,"username":"bob","email":"bob@example.com","active":false}"#;
    let user: User = serde_json::from_str(json_input).unwrap();
    println!("Deserialized: {:?}", user);
}

The derive macros inspect your struct at compile time and generate implementations that serialize each field in order. For JSON, this produces an object with fields matching your struct’s field names. For formats like MessagePack or Bincode, you get compact binary representations.

Pretty-printing JSON is equally simple:

let json = serde_json::to_string_pretty(&user).unwrap();

This produces formatted JSON with indentation, useful for debugging or human-readable config files.

Customizing Serialization Behavior

Real-world APIs rarely match your internal data structures perfectly. Field names might use camelCase instead of snake_case, optional fields need defaults, and some data shouldn’t serialize at all. Serde’s field attributes solve these problems.

Rename fields for external APIs:

#[derive(Serialize, Deserialize)]
struct ApiResponse {
    #[serde(rename = "userId")]
    user_id: u64,
    #[serde(rename = "createdAt")]
    created_at: String,
}

Now user_id serializes as userId in JSON, while your Rust code maintains idiomatic naming.

Skip fields conditionally:

#[derive(Serialize, Deserialize)]
struct Config {
    host: String,
    port: u16,
    #[serde(skip_serializing_if = "Option::is_none")]
    api_key: Option<String>,
    #[serde(skip)]
    internal_state: String,
}

The skip_serializing_if attribute omits api_key from output when it’s None, producing cleaner JSON. The skip attribute excludes internal_state entirely from both serialization and deserialization.

Provide defaults for missing fields:

#[derive(Serialize, Deserialize)]
struct ServerConfig {
    host: String,
    #[serde(default = "default_port")]
    port: u16,
    #[serde(default)]
    debug_mode: bool,
}

fn default_port() -> u16 {
    8080
}

When deserializing JSON that lacks a port field, Serde calls default_port(). For debug_mode, the default attribute uses bool::default(), which returns false.

Flatten nested structures:

#[derive(Serialize, Deserialize)]
struct Address {
    street: String,
    city: String,
    country: String,
}

#[derive(Serialize, Deserialize)]
struct Person {
    name: String,
    #[serde(flatten)]
    address: Address,
}

Instead of nested JSON like {"name":"Alice","address":{"street":"...","city":"...","country":"..."}}, flattening produces {"name":"Alice","street":"...","city":"...","country":"..."}. This is invaluable when working with APIs that don’t nest related data.

Working with Enums

Enums present a unique serialization challenge: how do you represent variants and their associated data? Serde offers four tagging strategies, each suited to different use cases.

Externally tagged (default):

#[derive(Serialize, Deserialize)]
enum Message {
    Text(String),
    Image { url: String, width: u32, height: u32 },
    Video(String),
}

Serializes as {"Text":"Hello"} or {"Image":{"url":"...","width":800,"height":600}}. The variant name wraps the data. This is Rust-idiomatic but verbose.

Internally tagged:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
    Text { content: String },
    Image { url: String, width: u32, height: u32 },
}

Produces {"type":"Text","content":"Hello"} or {"type":"Image","url":"...","width":800,"height":600}. The tag field identifies the variant while keeping the structure flat. This works well with APIs that use a type discriminator field.

Adjacently tagged:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Message {
    Text(String),
    Image { url: String, width: u32, height: u32 },
}

Generates {"type":"Text","data":"Hello"} or {"type":"Image","data":{"url":"...","width":800,"height":600}}. This separates the tag from the payload, useful when integrating with systems that expect this structure.

Untagged:

#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Value {
    Number(f64),
    Text(String),
    Boolean(bool),
}

Serializes as bare values: 42, "hello", or true. Deserialization tries each variant in order until one succeeds. This is powerful for polymorphic data but error-prone since the first matching variant wins.

Advanced Patterns

Sometimes derive macros aren’t enough. Custom serializers handle edge cases like serializing timestamps as Unix epochs or parsing legacy formats.

Custom serialization with serialize_with:

use serde::{Serialize, Serializer};

fn serialize_as_string<S>(value: &u64, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(&value.to_string())
}

#[derive(Serialize)]
struct Data {
    #[serde(serialize_with = "serialize_as_string")]
    id: u64,
    name: String,
}

This serializes id as a JSON string instead of a number, useful for JavaScript compatibility with large integers.

Working with dynamic data using serde_json::Value:

use serde_json::Value;

let json_str = r#"{"name":"Alice","age":30,"tags":["rust","serde"]}"#;
let v: Value = serde_json::from_str(json_str).unwrap();

if let Some(name) = v["name"].as_str() {
    println!("Name: {}", name);
}

if let Some(tags) = v["tags"].as_array() {
    for tag in tags {
        println!("Tag: {}", tag.as_str().unwrap());
    }
}

Value is an enum representing any JSON value. It’s perfect for exploratory parsing or when the schema varies at runtime. However, it sacrifices type safety and performance compared to strongly-typed deserialization.

Custom deserializers using the visitor pattern:

use serde::de::{self, Deserialize, Deserializer, Visitor};
use std::fmt;

struct DurationSeconds(u64);

impl<'de> Deserialize<'de> for DurationSeconds {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct DurationVisitor;

        impl<'de> Visitor<'de> for DurationVisitor {
            type Value = DurationSeconds;

            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                formatter.write_str("a duration in seconds")
            }

            fn visit_u64<E>(self, value: u64) -> Result<DurationSeconds, E>
            where
                E: de::Error,
            {
                Ok(DurationSeconds(value))
            }

            fn visit_str<E>(self, value: &str) -> Result<DurationSeconds, E>
            where
                E: de::Error,
            {
                value.parse::<u64>()
                    .map(DurationSeconds)
                    .map_err(de::Error::custom)
            }
        }

        deserializer.deserialize_any(DurationVisitor)
    }
}

This deserializer accepts both numbers and strings, parsing them into a DurationSeconds type. The visitor pattern gives fine-grained control over the deserialization process.

Error Handling and Best Practices

Serde operations return Result types. In production code, handle errors explicitly:

use serde_json::Error;

fn process_user_data(json: &str) -> Result<User, Error> {
    let user: User = serde_json::from_str(json)?;
    // Validate user data here
    Ok(user)
}

match process_user_data(json_input) {
    Ok(user) => println!("Processed user: {:?}", user),
    Err(e) => eprintln!("Failed to parse user data: {}", e),
}

For performance-critical code, prefer from_reader over from_str:

use std::fs::File;
use std::io::BufReader;

let file = File::open("data.json")?;
let reader = BufReader::new(file);
let user: User = serde_json::from_reader(reader)?;

This avoids loading the entire file into memory as a string, reducing allocations and improving throughput for large files.

Use #[serde(deny_unknown_fields)] on structs when you want strict validation:

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
    host: String,
    port: u16,
}

This fails deserialization if the input contains unexpected fields, catching configuration errors early.

For debugging, enable readable error messages by using serde_path_to_error:

[dependencies]
serde_path_to_error = "0.1"
let jd = &mut serde_json::Deserializer::from_str(json_str);
let result: Result<User, _> = serde_path_to_error::deserialize(jd);

This pinpoints exactly which field caused deserialization to fail, invaluable when debugging complex nested structures.

Serde’s combination of ergonomics and performance makes it indispensable for Rust applications. Master these patterns, and you’ll handle any serialization challenge with confidence.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.