TechLead
Lesson 19 of 28
5 min read
Rust

Serialization with Serde

Master Rust serialization with serde derive macros, JSON/TOML/YAML support, custom serialization, field renaming, and API integration.

What is Serde?

Serde (Serialize + Deserialize) is Rust's framework for efficiently and generically serializing and deserializing data structures. It's the most downloaded crate on crates.io and supports JSON, TOML, YAML, MessagePack, and many more formats.

Basic Usage with Derive Macros

// Cargo.toml:
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"

use serde::{Serialize, Deserialize};

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

fn main() -> serde_json::Result<()> {
    // Serialize to JSON
    let user = User {
        id: 1,
        name: "Alice".into(),
        email: "alice@example.com".into(),
        active: true,
    };

    let json_string = serde_json::to_string(&user)?;
    println!("JSON: {json_string}");

    let pretty = serde_json::to_string_pretty(&user)?;
    println!("Pretty:\n{pretty}");

    // Deserialize from JSON
    let json = r#"{"id":2,"name":"Bob","email":"bob@test.com","active":false}"#;
    let bob: User = serde_json::from_str(json)?;
    println!("Deserialized: {:?}", bob);

    // Work with dynamic JSON
    let value: serde_json::Value = serde_json::from_str(json)?;
    println!("Name: {}", value["name"]);

    // Create JSON dynamically
    let dynamic = serde_json::json!({
        "message": "hello",
        "count": 42,
        "items": [1, 2, 3]
    });
    println!("{}", dynamic);

    Ok(())
}

Field Attributes

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] // field_name -> fieldName
struct ApiResponse {
    user_id: u64,
    display_name: String,

    #[serde(rename = "e_mail")] // Custom field name
    email: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    phone: Option,

    #[serde(default)] // Use Default::default() if missing
    verified: bool,

    #[serde(skip)] // Never serialize/deserialize
    internal_cache: String,

    #[serde(alias = "created")] // Accept alternative name
    created_at: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct Wrapper {
    version: String,
    #[serde(flatten)] // Flatten nested struct
    data: UserData,
}

#[derive(Debug, Serialize, Deserialize)]
struct UserData {
    name: String,
    age: u32,
}
// Serializes as: {"version":"1","name":"Alice","age":30}
// Instead of: {"version":"1","data":{"name":"Alice","age":30}}

Enum Representations

use serde::{Serialize, Deserialize};

// Externally tagged (default): {"Circle": {"radius": 5}}
#[derive(Serialize, Deserialize, Debug)]
enum ShapeDefault {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

// Internally tagged: {"type": "circle", "radius": 5}
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type", rename_all = "lowercase")]
enum ShapeInternal {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

// Adjacently tagged: {"t": "circle", "c": {"radius": 5}}
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "t", content = "c")]
enum ShapeAdjacent {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

// Untagged: {"radius": 5} (tries each variant)
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
enum ShapeUntagged {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

fn main() {
    let shape = ShapeInternal::Circle { radius: 5.0 };
    let json = serde_json::to_string(&shape).unwrap();
    println!("{json}"); // {"type":"circle","radius":5.0}
}

Working with APIs

use serde::{Serialize, Deserialize};

// Real-world API response handling
#[derive(Debug, Deserialize)]
struct GitHubRepo {
    name: String,
    full_name: String,
    description: Option,
    #[serde(rename = "stargazers_count")]
    stars: u64,
    language: Option,
    #[serde(rename = "html_url")]
    url: String,
}

#[derive(Debug, Deserialize)]
struct PaginatedResponse {
    data: Vec,
    total: u64,
    page: u32,
    per_page: u32,
}

// Custom deserialization for dates
#[derive(Debug, Serialize, Deserialize)]
struct Event {
    name: String,
    #[serde(with = "date_format")]
    date: chrono::NaiveDate,
}

mod date_format {
    use chrono::NaiveDate;
    use serde::{self, Deserialize, Deserializer, Serializer};

    const FORMAT: &str = "%Y-%m-%d";

    pub fn serialize(date: &NaiveDate, serializer: S) -> Result
    where S: Serializer {
        serializer.serialize_str(&date.format(FORMAT).to_string())
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result
    where D: Deserializer<'de> {
        let s = String::deserialize(deserializer)?;
        NaiveDate::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)
    }
}

// TOML example
// toml = "0.8"
// let config: Config = toml::from_str(&std::fs::read_to_string("Config.toml")?)?;

Key Takeaways

  • ✅ Serde derive macros make serialization effortless with #[derive(Serialize, Deserialize)]
  • ✅ Field attributes control renaming, skipping, defaults, and flattening
  • ✅ Enum representations (tagged, untagged, etc.) handle complex API schemas
  • serde_json::Value enables dynamic JSON when types are unknown
  • ✅ Serde works with JSON, TOML, YAML, and dozens of other formats

Continue Learning