Skip to editor content
learningrust.orglesson 24 of 26

Serde and JSON

Almost every real-world application needs to exchange data — with a REST API, a config file, or a database. In Rust, the serde ecosystem is the standard answer. It provides a powerful framework for serializing and deserializing data structures with minimal boilerplate.

What Is Serde?

Serde is a framework, not a format. It separates the data model (your Rust structs and enums) from the data format (JSON, TOML, YAML, MessagePack, etc.). The same #[derive(Serialize, Deserialize)] annotation works across all formats.

For JSON specifically, you add two crates to your Cargo.toml:

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

The derive feature unlocks the procedural macros that generate serialization logic automatically.

Basic Serialization

Serialization converts a Rust value into another format — in this case, a JSON string. You derive Serialize on any struct or enum you want to convert:

use serde::Serialize;
use serde_json;

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

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

    // Serialize to a compact JSON string
    let json = serde_json::to_string(&user).unwrap();
    println!("{}", json);

    // Or pretty-print with indentation
    let pretty = serde_json::to_string_pretty(&user).unwrap();
    println!("{}", pretty);
}

The output from to_string_pretty will look exactly like the JSON you'd write by hand, with field names matching your struct fields.

Basic Deserialization

Deserialization is the reverse — parsing JSON text back into a typed Rust value. Derive Deserialize and call serde_json::from_str:

use serde::Deserialize;
use serde_json;

#[derive(Deserialize, Debug)]
struct Config {
    host: String,
    port: u16,
    max_connections: u32,
    debug: bool,
}

fn main() {
    let json = r#"
        {
            "host": "localhost",
            "port": 8080,
            "max_connections": 100,
            "debug": false
        }
    "#;

    let config: Config = serde_json::from_str(json).unwrap();

    println!("Connecting to {}:{}", config.host, config.port);
    println!("Max connections: {}", config.max_connections);
    println!("Debug mode: {}", config.debug);
}

Notice the r#"..."# raw string literal — it lets you embed quotes and backslashes without escaping them, which is convenient for inline JSON.

Controlling Field Names and Behaviour

Serde attributes let you customize how fields are mapped without changing your Rust naming conventions. The most common is #[serde(rename_all)] for switching between naming conventions, and #[serde(rename)] for individual fields.

use serde::{Deserialize, Serialize};
use serde_json;

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]  // snake_case in Rust, camelCase in JSON
struct ApiResponse {
    user_id: u32,
    first_name: String,
    last_name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    profile_picture: Option<String>,
    #[serde(default)]          // use Default::default() if field is missing
    follower_count: u64,
}

fn main() {
    // Deserialize from camelCase JSON (e.g. from a JavaScript API)
    let json = r#"{"userId":42,"firstName":"Bob","lastName":"Smith","followerCount":1500}"#;
    let resp: ApiResponse = serde_json::from_str(json).unwrap();
    println!("{:?}", resp);

    // Serialize back — profile_picture is None so it won't appear in output
    let out = serde_json::to_string(&resp).unwrap();
    println!("{}", out);
}

Key attributes used here:

  • #[serde(rename_all = "camelCase")] — applies a naming convention to all fields
  • #[serde(skip_serializing_if = "Option::is_none")] — omits null fields from output
  • #[serde(default)] — allows missing JSON keys by falling back to Default::default()

Handling Errors Properly

In production code you should propagate errors rather than calling unwrap. Serde's error type implements std::error::Error, so it composes naturally with ?:

use serde::{Deserialize, Serialize};
use serde_json;

#[derive(Serialize, Deserialize, Debug)]
struct Product {
    id: u32,
    name: String,
    price: f64,
}

fn parse_product(input: &str) -> Result<Product, serde_json::Error> {
    serde_json::from_str(input)
}

fn to_json(product: &Product) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(product)
}

fn main() {
    // Valid JSON
    let valid = r#"{"id": 7, "name": "Keyboard", "price": 129.99}"#;
    match parse_product(valid) {
        Ok(p) => println!("Parsed: {:?}", p),
        Err(e) => eprintln!("Parse error: {}", e),
    }

    // Invalid JSON — missing required field
    let invalid = r#"{"id": 8, "name": "Mouse"}"#;
    match parse_product(invalid) {
        Ok(p) => println!("Parsed: {:?}", p),
        Err(e) => eprintln!("Parse error: {}", e),
    }

    // Serialize with error handling
    let product = Product { id: 9, name: String::from("Monitor"), price: 399.00 };
    match to_json(&product) {
        Ok(json) => println!("{}", json),
        Err(e) => eprintln!("Serialize error: {}", e),
    }
}

The error message for the invalid case will tell you exactly which field is missing and at which byte position — far more useful than a panic.

Nested Structures and Enums

Serde handles nested structs and enums automatically. This makes it straightforward to model complex JSON payloads:

use serde::{Deserialize, Serialize};
use serde_json;

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum OrderStatus {
    Pending,
    Processing,
    Shipped,
    Delivered,
}

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

#[derive(Serialize, Deserialize, Debug)]
struct Order {
    id: u32,
    status: OrderStatus,
    items: Vec<String>,
    shipping_address: Address,
}

fn main() {
    let order = Order {
        id: 101,
        status: OrderStatus::Shipped,
        items: vec![String::from("Widget"), String::from("Gadget")],
        shipping_address: Address {
            street: String::from("123 Rust Lane"),
            city: String::from("Ferris City"),
            country: String::from("Crabland"),
        },
    };

    let json = serde_json::to_string_pretty(&order).unwrap();
    println!("{}", json);

    // Round-trip: deserialize what we just serialized
    let restored: Order = serde_json::from_str(&json).unwrap();
    println!("\nStatus after round-trip: {:?}", restored.status);
    println!("City: {}", restored.shipping_address.city);
}

Enums serialize to their variant name as a string by default. The rename_all attribute converts Shipped to "shipped" in JSON.

Try It Yourself

Build a small task manager that serializes a list of tasks to JSON and deserializes it back. Add at least one optional field and one enum field:

use serde::{Deserialize, Serialize};
use serde_json;

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum Priority {
    Low,
    Medium,
    High,
}

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    id: u32,
    title: String,
    priority: Priority,
    completed: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    due_date: Option<String>,
}

fn main() {
    let tasks = vec![
        Task {
            id: 1,
            title: String::from("Learn serde"),
            priority: Priority::High,
            completed: true,
            due_date: Some(String::from("2024-02-01")),
        },
        Task {
            id: 2,
            title: String::from("Write unit tests"),
            priority: Priority::Medium,
            completed: false,
            due_date: None,
        },
    ];

    // Serialize the task list
    let json = serde_json::to_string_pretty(&tasks).unwrap();
    println!("Serialized:\n{}\n", json);

    // Deserialize it back
    let restored: Vec<Task> = serde_json::from_str(&json).unwrap();
    let pending: Vec<&Task> = restored.iter().filter(|t| !t.completed).collect();

    println!("Pending tasks:");
    for task in pending {
        println!("  - [{}] {:?} priority", task.title, task.priority);
    }
}

Try extending this by adding a tags: Vec<String> field with #[serde(default)] so existing JSON without that field still deserializes correctly.

Key Takeaways

  • Serde is a framework: derive Serialize and Deserialize on your types; plug in any format crate (serde_json, toml, serde_yaml)
  • to_string / from_str are the core serde_json functions; to_string_pretty adds indentation
  • Attributes control mapping: rename_all, rename, skip_serializing_if, default, and many more give fine-grained control without changing your struct
  • Errors are descriptive: serde's errors include field names and byte offsets — always propagate them with ? instead of unwrap in production
  • Enums serialize cleanly: by default to their variant name as a string; #[serde(tag = "type")] enables tagged union representations
  • Round-trips are reliable: serialize then deserialize produces an identical value — a property worth verifying in tests

Pro Tip: Use serde_json::Value when you need to handle arbitrary or dynamic JSON whose shape you don't know at compile time. It's an enum (Null, Bool, Number, String, Array, Object) that you can pattern-match against. This is an escape hatch for truly dynamic data, but prefer typed structs whenever the schema is known — the compiler will catch mismatches before they reach production.

Next Steps

With serde in your toolbox, you can handle virtually any data format Rust encounters. Next, we'll build command-line tools — reading arguments, validating input, and reporting errors — so your programs can talk to the outside world.

Next lesson

CLI and Args

Build command-line tools in Rust by parsing arguments, handling flags, and structuring real-world CLI applications

25 min