Rust: Processing JSON with serde

serde is Rust's de facto serialization framework. It separates the data model (your structs and enums) from the data format (JSON, YAML, TOML, bincode, ...). serde_json handles the JSON format layer. Together they give you typed, zero-cost JSON with minimal boilerplate.

Setup

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

The derive feature enables #[derive(Serialize, Deserialize)] — without it you'd have to implement these traits by hand.

Serialize & Deserialize

Define a struct, derive the traits, and use serde_json::from_str / serde_json::to_string:

use serde::{Deserialize, Serialize};

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

fn main() -> Result<(), serde_json::Error> {
    // Deserialize: JSON string → Rust struct
    let json = r#"{"name":"Alice","age":25,"email":"alice@example.com"}"#;
    let user: User = serde_json::from_str(json)?;
    println!("{:?}", user);
    // User { name: "Alice", age: 25, email: "alice@example.com" }

    // Serialize: Rust struct → JSON string
    let out = serde_json::to_string(&user)?;
    println!("{}", out);
    // {"name":"Alice","age":25,"email":"alice@example.com"}

    // Pretty-printed
    let pretty = serde_json::to_string_pretty(&user)?;
    println!("{}", pretty);

    Ok(())
}

That's the core loop. Everything else is controlling how the mapping works.

Field Attributes

Serde provides #[serde(...)] attributes to customize serialization without changing your struct.

rename / rename_all

When your Rust naming convention doesn't match the JSON keys:

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Person {
    first_name: String,  // → "firstName"
    last_name: String,   // → "lastName"
}

For a single field:

#[derive(Deserialize)]
struct Config {
    #[serde(rename = "type")]
    kind: String,  // JSON key is "type" (Rust reserved word)
}

rename_all supports: lowercase, UPPERCASE, PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE.

skip / skip_serializing

Exclude fields from serialization, deserialization, or both:

use std::collections::HashMap;

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

    #[serde(skip_serializing)]
    checksum: String,  // never written to JSON

    #[serde(skip_serializing_if = "HashMap::is_empty")]
    metadata: HashMap<String, String>,  // omitted when empty

    #[serde(skip)]
    cache: Vec<u8>,  // ignored both ways
}

skip_serializing_if takes a function pointer — the field is omitted from output when the function returns true. Deserialization still works normally.

default values

Handle missing fields in JSON by providing defaults:

#[derive(Deserialize, Debug)]
struct Request {
    #[serde(default)]
    timeout: u32,  // defaults to 0

    #[serde(default = "default_path")]
    path: String,  // defaults to "/"
}

fn default_path() -> String {
    "/".to_string()
}

fn main() {
    let json = r#"{}"#;
    let req: Request = serde_json::from_str(json)?;
    // Request { timeout: 0, path: "/" }
}

#[serde(default)] uses the type's Default impl. #[serde(default = "fn_name")] calls the named function.

Enum Representations

Serde supports four ways to map Rust enums to JSON. The default is externally tagged:

#[derive(Serialize, Deserialize)]
#[derive(Debug)]
enum Message {
    Request { id: String, method: String },
    Response { id: String, result: i32 },
}
{"Request": {"id": "1", "method": "ping"}}
{"Response": {"id": "1", "result": 42}}

Most real-world APIs use internally tagged instead:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
    Push { repo: String },
    PullRequest { repo: String, number: u64 },
}
{"type": "Push", "repo": "serde"}
{"type": "PullRequest", "repo": "serde", "number": 1234}

Other options: #[serde(tag = "t", content = "c")] for adjacently tagged, and #[serde(untagged)] for no tag at all (tries each variant in order).

Pick the representation that matches your JSON schema — don't transform the data to fit Rust.

Reading & Writing Files

For file I/O, use std::fs with serde's typed API:

use std::fs;

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

fn write_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
    let json = serde_json::to_string_pretty(config)?;
    fs::write(path, json)?;
    Ok(())
}

For large files, serde_json::from_reader() streams directly from a Read implementor without loading the whole string into memory first:

use std::fs::File;

fn read_large(path: &str) -> Result<Vec<User>, serde_json::Error> {
    let file = File::open(path)?;
    serde_json::from_reader(file)
}

Dynamic JSON with serde_json::Value

When the schema is unknown or variable, use serde_json::Value — a dynamic JSON tree:

use serde_json::Value;

fn main() -> Result<(), serde_json::Error> {
    let json = r#"{"name":"Alice","scores":[95,87,72]}"#;
    let v: Value = serde_json::from_str(json)?;

    println!("{}", v["name"]);            // "Alice"
    println!("{}", v["scores"][0]);       // 95

    // Iterate an array
    if let Some(scores) = v["scores"].as_array() {
        for s in scores {
            println!("score: {}", s);
        }
    }

    Ok(())
}

Value is an enum: Null, Bool(bool), Number(...), String(String), Array(Vec<Value>), Object(Map<String, Value>). Use it for exploratory parsing, then migrate to typed structs once the schema stabilizes.

Flatten Extra Fields

Capture arbitrary JSON keys you didn't define in the struct:

use std::collections::HashMap;
use serde_json::Value;

#[derive(Serialize, Deserialize)]
struct Webhook {
    event: String,
    timestamp: u64,

    #[serde(flatten)]
    extra: HashMap<String, Value>,
}

Any key other than event and timestamp lands in extra. This is useful for pass-through proxies or logging unknown payloads without failing.

Error Handling

serde_json::Error implements std::error::Error. It carries line/column info for deserialization failures:

fn main() {
    let bad = r#"{"name":}"#;

    match serde_json::from_str::<User>(bad) {
        Ok(user) => println!("{:?}", user),
        Err(e) => {
            // Error at line 1 column 10: ...
            eprintln!("parse failed: {}", e);
        }
    }
}

For APIs, wrap in your own error type using thiserror:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("invalid config: {0}")]
    Config(#[from] serde_json::Error),

    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
}

The ? operator converts serde_json::ErrorAppError::Config automatically via the From impl.


Serde's attribute system covers more than what's shown here — custom serializers with #[serde(serialize_with)], lifetime-based zero-copy deserialization with &'a str, and #[serde(borrow)] for avoiding allocations. See the serde documentation for the full reference.

·3 years ago
37 min read
RustEdit