In the previous tutorial, we learned advanced error handling. Now we learn Serde — the serialization framework that powers most data handling in Rust.

Serde converts Rust structs and enums to and from formats like JSON, TOML, YAML, and more. It is not just a JSON library. Serde separates “what to serialize” from “what format to use.” You write #[derive(Serialize, Deserialize)] once, and your type works with every supported format.

Setting Up

Add Serde and the formats you need to Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"   # JSON format
toml = "0.8"        # TOML format

The "derive" feature enables #[derive(Serialize, Deserialize)]. Without it, the derive macros will not work.

Basic Serialize and Deserialize

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct User {
    name: String,
    email: String,
    age: u32,
}

Serialization — Rust to JSON

fn main() {
    let user = User {
        name: "Alex".to_string(),
        email: "alex@example.com".to_string(),
        age: 25,
    };

    // Compact JSON
    let json = serde_json::to_string(&user).unwrap();
    println!("{}", json);
    // {"name":"Alex","email":"alex@example.com","age":25}

    // Pretty-printed JSON
    let pretty = serde_json::to_string_pretty(&user).unwrap();
    println!("{}", pretty);
    // {
    //   "name": "Alex",
    //   "email": "alex@example.com",
    //   "age": 25
    // }
}

Deserialization — JSON to Rust

fn main() {
    let json = r#"{"name":"Alex","email":"alex@example.com","age":25}"#;
    let user: User = serde_json::from_str(json).unwrap();
    assert_eq!(user.name, "Alex");
    assert_eq!(user.age, 25);
}

Roundtrip Test

Always verify that your types survive a serialize-deserialize cycle:

fn main() {
    let original = User {
        name: "Alex".to_string(),
        email: "alex@example.com".to_string(),
        age: 25,
    };
    let json = serde_json::to_string(&original).unwrap();
    let parsed: User = serde_json::from_str(&json).unwrap();
    assert_eq!(original, parsed);
}

Serde Attributes

Attributes let you customize how fields are serialized and deserialized. They are the most powerful part of Serde.

rename — Change Field Names

When JSON uses different naming conventions than Rust:

#[derive(Serialize, Deserialize)]
struct ApiResponse {
    #[serde(rename = "statusCode")]
    status_code: u16,

    #[serde(rename = "responseBody")]
    response_body: String,
}

The Rust code uses snake_case, but the JSON uses camelCase.

rename_all — Rename All Fields at Once

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Config {
    database_url: String,       // → "databaseUrl"
    max_connections: u32,        // → "maxConnections"
    enable_logging: bool,        // → "enableLogging"
}

Common options:

  • "camelCase" — JavaScript/JSON style
  • "PascalCase" — C#/Go style
  • "snake_case" — Python/Rust style (default)
  • "SCREAMING_SNAKE_CASE" — constants
  • "kebab-case" — CSS/URL style

default — Default Values for Missing Fields

#[derive(Serialize, Deserialize)]
struct UserProfile {
    name: String,

    #[serde(default)]
    bio: String,  // Defaults to "" if missing

    #[serde(default = "default_role")]
    role: String,  // Defaults to "user" if missing
}

fn default_role() -> String {
    "user".to_string()
}

Now you can deserialize JSON that is missing optional fields:

fn main() {
    let json = r#"{"name":"Alex"}"#;
    let profile: UserProfile = serde_json::from_str(json).unwrap();
    assert_eq!(profile.bio, "");
    assert_eq!(profile.role, "user");
}

skip — Skip Fields

#[derive(Serialize, Deserialize)]
struct Account {
    name: String,

    #[serde(skip_serializing)]
    password_hash: String,  // Never included in JSON output

    #[serde(skip_serializing_if = "Option::is_none")]
    avatar_url: Option<String>,  // Skipped when None
}

skip_serializing is useful for sensitive data. skip_serializing_if removes fields when they have no meaningful value.

flatten — Merge Nested Structs

#[derive(Serialize, Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}

#[derive(Serialize, Deserialize)]
struct SearchRequest {
    query: String,
    #[serde(flatten)]
    pagination: Pagination,
}

Without flatten: {"query": "rust", "pagination": {"page": 1, "per_page": 20}}

With flatten: {"query": "rust", "page": 1, "per_page": 20}

The pagination fields are merged into the parent object.

Enum Serialization

Serde supports four enum representations. Choosing the right one matters for API design.

Externally Tagged (Default)

#[derive(Serialize, Deserialize)]
enum Message {
    Text(String),
    Number(i64),
}

Produces: {"Text":"hello"} or {"Number":42}

Internally Tagged

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

Produces: {"type":"Circle","radius":5.0}

This is the most common format for APIs. The "type" field tells you which variant it is.

Adjacently Tagged

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
    Click { x: i32, y: i32 },
    KeyPress(char),
}

Produces: {"type":"Click","data":{"x":10,"y":20}}

Untagged

#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum FlexibleValue {
    Integer(i64),
    Float(f64),
    Text(String),
}

Serde tries each variant in order until one works. Use this when the JSON has no type indicator.

Dynamic JSON with serde_json::Value

Sometimes you do not know the JSON structure at compile time. Use serde_json::Value:

use serde_json::json;

fn main() {
    let value = json!({
        "name": "Alex",
        "age": 25,
        "tags": ["rust", "programming"],
        "address": {
            "city": "Berlin",
            "country": "Germany"
        }
    });

    // Access fields
    let name = value["name"].as_str().unwrap_or("unknown");
    let age = value["age"].as_i64().unwrap_or(0);
    let city = value["address"]["city"].as_str();

    println!("Name: {}, Age: {}", name, age);
    println!("City: {:?}", city);
}

The json! macro creates serde_json::Value from JSON-like syntax.

Safe Navigation

Use .get() instead of direct indexing for safe access:

let name = value
    .get("name")
    .and_then(|v| v.as_str())
    .unwrap_or("unknown");

Direct indexing (value["key"]) returns Value::Null for missing keys. It does not panic. But .get() returns Option, which is more explicit.

Mixing Typed and Dynamic

Convert between typed structs and Value:

// Struct to Value
let user = User {
    name: "Alex".to_string(),
    email: "alex@example.com".to_string(),
    age: 25,
};
let value = serde_json::to_value(&user).unwrap();

// Value to struct
let parsed: User = serde_json::from_value(value).unwrap();

Working with TOML

TOML is common for configuration files. It works the same way as JSON:

#[derive(Serialize, Deserialize)]
struct AppConfig {
    title: String,
    debug: bool,
    database: DatabaseConfig,
}

#[derive(Serialize, Deserialize)]
struct DatabaseConfig {
    url: String,
    max_connections: u32,
}

Serialize to TOML

fn main() {
    let config = AppConfig {
        title: "My App".to_string(),
        debug: true,
        database: DatabaseConfig {
            url: "sqlite:app.db".to_string(),
            max_connections: 5,
        },
    };

    let toml_str = toml::to_string_pretty(&config).unwrap();
    println!("{}", toml_str);
}

Output:

title = "My App"
debug = true

[database]
url = "sqlite:app.db"
max_connections = 5

Parse from TOML

fn main() {
    let toml_str = r#"
        title = "My App"
        debug = false

        [database]
        url = "postgres://localhost/mydb"
        max_connections = 10
    "#;

    let config: AppConfig = toml::from_str(toml_str).unwrap();
    println!("Database: {}", config.database.url);
}

Converting Between Formats

Since Serde uses the same traits for all formats, conversion is simple:

fn json_to_toml(json_str: &str) -> Result<String, Box<dyn std::error::Error>> {
    let config: AppConfig = serde_json::from_str(json_str)?;
    let toml_str = toml::to_string_pretty(&config)?;
    Ok(toml_str)
}

Deserialize from one format, serialize to another.

Handling Errors

Serde returns descriptive errors when deserialization fails:

fn main() {
    // Missing required field
    let json = r#"{"name":"Alex"}"#;
    let result: Result<User, _> = serde_json::from_str(json);
    // Error: missing field `email` at line 1 column 15

    // Wrong type
    let json = r#"{"name":"Alex","email":"a@b.com","age":"not a number"}"#;
    let result: Result<User, _> = serde_json::from_str(json);
    // Error: invalid type: string "not a number", expected u32
}

To reject extra fields, use #[serde(deny_unknown_fields)]:

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictUser {
    name: String,
    age: u32,
}

Common Mistakes

Mistake 1: Forgetting the derive Feature

# BAD: derive macros will not work
serde = "1"

# GOOD: enables #[derive(Serialize, Deserialize)]
serde = { version = "1", features = ["derive"] }

Mistake 2: Using unwrap() in Production

// BAD: panics on invalid JSON
let user: User = serde_json::from_str(input).unwrap();

// GOOD: handle the error
let user: User = serde_json::from_str(input)
    .map_err(|e| format!("Invalid JSON: {}", e))?;

Mistake 3: Not Using skip_serializing_if for Option

// BAD: serializes as {"name":"Alex","avatar":null}
#[derive(Serialize)]
struct Profile {
    name: String,
    avatar: Option<String>,
}

// GOOD: omits the field when None → {"name":"Alex"}
#[derive(Serialize)]
struct Profile {
    name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    avatar: Option<String>,
}

Summary

ConceptPurpose
Serialize / DeserializeConvert Rust types to/from data formats
serde_json::to_stringRust struct to JSON string
serde_json::from_strJSON string to Rust struct
#[serde(rename)]Change field name in output
#[serde(rename_all)]Rename all fields (camelCase, etc.)
#[serde(default)]Use default value for missing fields
#[serde(skip_serializing)]Exclude field from output
#[serde(flatten)]Merge nested struct fields
#[serde(tag)]Choose enum representation
serde_json::ValueDynamic JSON without typed structs
json! macroCreate Value from JSON syntax

Source Code

Find the complete code on GitHub: tutorial-20-serde

What’s Next?

In this tutorial, we learned Serde — the most important data processing library in Rust. We covered derive macros, attributes, enum serialization, dynamic JSON, and TOML.

In the next tutorial, we will make HTTP requests with Reqwest — calling APIs, sending JSON, handling responses, and building an API client.

Next: Rust Tutorial #21: HTTP with Reqwest