Skip to content
Codeloom
Rust

Rust Serde Serialization and Deserialization Guide

Master Serde in Rust with practical patterns for JSON, custom serialization, field renaming, enums, and error handling in real-world projects.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How Serde derive macros work
  • Field-level attributes for renaming and defaults
  • Enum serialization strategies
  • Custom serializers and deserializers

Prerequisites

  • Basic Rust knowledge
  • Familiarity with structs and enums

Serde is Rust’s serialization framework. It sits between your Rust types and data formats like JSON, TOML, YAML, and MessagePack. You describe how your types map to data, and Serde handles the rest. This guide covers the patterns you will use most in production code.

Getting Started

Add Serde and a format crate to your Cargo.toml:

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

The derive feature gives you #[derive(Serialize, Deserialize)], which handles most types automatically:

use serde::{Serialize, Deserialize};

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

fn main() {
    let user = User {
        name: "Alice".into(),
        email: "alice@example.com".into(),
        age: 30,
    };

    // Serialize to JSON
    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("{json}");

    // Deserialize from JSON
    let parsed: User = serde_json::from_str(&json).unwrap();
    println!("{:?}", parsed);
}

Field Attributes

Serde’s field attributes let you control how each field maps to the serialized format.

Renaming Fields

Rust uses snake_case, but APIs often use camelCase or PascalCase:

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
    status_code: u16,
    error_message: Option<String>,
    request_id: String,
}

fn main() {
    let resp = ApiResponse {
        status_code: 200,
        error_message: None,
        request_id: "abc-123".into(),
    };

    let json = serde_json::to_string(&resp).unwrap();
    // {"statusCode":200,"errorMessage":null,"requestId":"abc-123"}
    println!("{json}");
}

You can also rename individual fields:

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct Config {
    #[serde(rename = "type")]
    kind: String, // "type" is a Rust keyword

    #[serde(rename = "max-retries")]
    max_retries: u32,
}

Default Values

Handle missing fields gracefully with defaults:

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct Settings {
    host: String,

    #[serde(default = "default_port")]
    port: u16,

    #[serde(default)]
    debug: bool, // defaults to false

    #[serde(default)]
    tags: Vec<String>, // defaults to empty vec
}

fn default_port() -> u16 {
    8080
}

fn main() {
    let json = r#"{"host": "localhost"}"#;
    let settings: Settings = serde_json::from_str(json).unwrap();
    println!("{:?}", settings);
    // Settings { host: "localhost", port: 8080, debug: false, tags: [] }
}

Skipping and Flattening

use serde::{Serialize, Deserialize};
use std::collections::HashMap;

#[derive(Debug, Serialize, Deserialize)]
struct Event {
    name: String,
    timestamp: u64,

    #[serde(skip_serializing_if = "Option::is_none")]
    description: Option<String>,

    #[serde(skip)]
    internal_id: u64, // never serialized or deserialized

    #[serde(flatten)]
    extra: HashMap<String, serde_json::Value>, // catch-all for unknown fields
}

fn main() {
    let json = r#"{
        "name": "deploy",
        "timestamp": 1719800000,
        "region": "us-east-1",
        "version": "2.1.0"
    }"#;

    let event: Event = serde_json::from_str(json).unwrap();
    println!("Extra fields: {:?}", event.extra);
    // Extra fields: {"region": "us-east-1", "version": "2.1.0"}
}

Enum Serialization

Serde supports four enum representations. Choosing the right one depends on your API design.

use serde::{Serialize, Deserialize};

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

// Internally tagged: {"type": "circle", "radius": 5.0}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
    UserCreated { user_id: u64, name: String },
    UserDeleted { user_id: u64 },
}

// Adjacently tagged: {"t": "circle", "c": {"radius": 5.0}}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum Message {
    Text(String),
    Image { url: String, width: u32 },
}

// Untagged: tries each variant in order
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Value {
    Integer(i64),
    Float(f64),
    Text(String),
}

fn main() {
    // Internally tagged
    let event = Event::UserCreated { user_id: 1, name: "Alice".into() };
    let json = serde_json::to_string(&event).unwrap();
    println!("{json}");
    // {"type":"user_created","user_id":1,"name":"Alice"}

    // Untagged
    let val: Value = serde_json::from_str("42").unwrap();
    println!("{:?}", val); // Integer(42)
}

Custom Serialization

Sometimes the derive macro is not flexible enough. You can write custom serialize/deserialize functions for individual fields:

use serde::{Serialize, Deserialize, Serializer, Deserializer};

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

    #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")]
    color: u32,
}

fn serialize_hex<S: Serializer>(value: &u32, serializer: S) -> Result<S::Ok, S::Error> {
    serializer.serialize_str(&format!("#{:06x}", value))
}

fn deserialize_hex<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
    let s: &str = Deserialize::deserialize(deserializer)?;
    u32::from_str_radix(s.trim_start_matches('#'), 16)
        .map_err(serde::de::Error::custom)
}

fn main() {
    let record = Record {
        name: "sky".into(),
        color: 0x87CEEB,
    };

    let json = serde_json::to_string(&record).unwrap();
    println!("{json}"); // {"name":"sky","color":"#87ceeb"}

    let parsed: Record = serde_json::from_str(&json).unwrap();
    println!("{:?}", parsed);
}

Handling Errors Gracefully

In production, use proper error handling instead of unwrap():

use serde::Deserialize;
use std::fs;

#[derive(Debug, Deserialize)]
struct AppConfig {
    database_url: String,
    port: u16,
}

fn load_config(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?;
    let config: AppConfig = serde_json::from_str(&content)?;
    Ok(config)
}

fn main() {
    match load_config("config.json") {
        Ok(config) => println!("Loaded: {:?}", config),
        Err(e) => eprintln!("Failed to load config: {e}"),
    }
}

Serde error messages are detailed. A missing field error looks like: missing field 'database_url' at line 3 column 1. This makes debugging straightforward.

Working with Multiple Formats

Because Serde is format-agnostic, switching formats is trivial:

use serde::{Serialize, Deserialize};

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

fn main() {
    let config = Config {
        host: "localhost".into(),
        port: 3000,
    };

    // JSON
    let json = serde_json::to_string(&config).unwrap();
    println!("JSON: {json}");

    // The same struct works with TOML, YAML, MessagePack, etc.
    // Just swap the crate: toml::to_string, serde_yaml::to_string
}

Wrapping Up

Serde handles the vast majority of serialization needs through derive macros and field attributes. Use rename_all for casing conventions, default for optional fields, skip_serializing_if to omit nulls, and flatten for catch-all fields. Choose the right enum representation for your API. When derive is not enough, write custom serializers for individual fields rather than implementing the traits from scratch. Serde’s format-agnostic design means your type definitions work unchanged across JSON, TOML, YAML, and dozens of other formats.