Rust JSON with serde: Serialize, Deserialize & serde_json Patterns

Last updated:

Rust JSON serialization uses serde with serde_json — add #[derive(Serialize, Deserialize)] to a struct and call serde_json::to_string(&value)? to serialize or serde_json::from_str::<MyStruct>(json_str)? to deserialize, with compile-time type safety and zero-copy parsing where possible. serde_json parses a 1 MB JSON file in ~2 ms into typed Rust structs; the json!() macro constructs JSON values at compile time with zero runtime allocation for literal values, making it ideal for test fixtures and API response builders. This guide covers deriving Serialize/Deserialize, serde field attributes (#[serde(rename)], #[serde(skip)], #[serde(default)]), handling Option and nested structs, serde_json::Value for dynamic JSON, streaming large JSON with serde_json::Deserializer, and using serde with Axum and Actix-web.

Deriving Serialize and Deserialize for Rust Structs

Adding #[derive(Serialize, Deserialize)] to a struct is the starting point for all serde-based JSON work. The derive macros generate Serialize and Deserialize trait implementations at compile time — no runtime reflection, no overhead beyond the actual JSON parsing. Add serde and serde_json to Cargo.toml with the derive feature enabled, then annotate structs with the macros and call to_string or from_str.

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

// ── Basic struct serialization ─────────────────────────────────
use serde::{Deserialize, Serialize};

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

fn main() -> Result<(), serde_json::Error> {
    let user = User {
        id:    1,
        name:  "Alice".to_string(),
        email: "alice@example.com".to_string(),
        age:   Some(30),
    };

    // Serialize to JSON string
    let json_str = serde_json::to_string(&user)?;
    // {"id":1,"name":"Alice","email":"alice@example.com","age":30}

    // Pretty-print with indentation
    let pretty = serde_json::to_string_pretty(&user)?;

    // Serialize to Vec<u8> (avoids UTF-8 validation on output)
    let bytes: Vec<u8> = serde_json::to_vec(&user)?;

    // Deserialize from JSON string
    let parsed: User = serde_json::from_str(&json_str)?;

    // Deserialize from bytes
    let from_bytes: User = serde_json::from_slice(&bytes)?;

    println!("{:?}", parsed);
    Ok(())
}

// ── Enum serialization ─────────────────────────────────────────
#[derive(Serialize, Deserialize, Debug)]
enum Status {
    Active,                    // unit variant → "Active"
    Suspended(String),         // tuple variant → {"Suspended": "reason"}
    Custom { code: u32 },      // struct variant → {"Custom": {"code": 42}}
}

// ── Error handling ─────────────────────────────────────────────
match serde_json::from_str::<User>(r#"{"id": "not-a-number"}"#) {
    Ok(u)  => println!("parsed: {:?}", u),
    Err(e) => {
        println!("line {}, col {}: {}", e.line(), e.column(), e);
        // line 1, col 7: invalid type: string "not-a-number", expected u64
    }
}

The serde_json::Error type provides .line(), .column(), and .classify() methods for diagnosing parse failures. The classify() method returns a Category enum — Io, Syntax, Data, or Eof — useful for distinguishing between malformed JSON (Syntax) and type mismatches (Data). For enums, serde's default representation wraps variants in an object; use #[serde(tag = "type")] for an adjacently tagged representation or #[serde(untagged)] for no wrapper.

serde Field Attributes: rename, skip, default, flatten

serde's attribute system provides precise control over how Rust field names map to JSON keys, which fields are included, and how missing fields are handled. These attributes are the primary tool for aligning Rust's snake_case naming conventions with JSON APIs that expect camelCase, handling legacy field names, and controlling serialization output shape.

use serde::{Deserialize, Serialize};

// ── rename_all: convert all fields at once ─────────────────────
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiUser {
    user_id:    u64,     // → "userId"
    first_name: String,  // → "firstName"
    last_name:  String,  // → "lastName"
    created_at: String,  // → "createdAt"
}

// ── rename: individual field override ──────────────────────────
#[derive(Serialize, Deserialize)]
struct LegacyUser {
    #[serde(rename = "user_id")]
    id: u64,

    // Different names for serialize vs deserialize
    #[serde(rename(serialize = "fullName", deserialize = "full_name"))]
    name: String,
}

// ── skip and skip_serializing_if ──────────────────────────────
#[derive(Serialize, Deserialize)]
struct Post {
    id:      u64,
    title:   String,

    // Omit null from serialized JSON (field still deserializes normally)
    #[serde(skip_serializing_if = "Option::is_none")]
    subtitle: Option<String>,

    // Completely skip this field in both directions
    #[serde(skip)]
    internal_cache: String,   // must implement Default

    // Only skip during serialization
    #[serde(skip_serializing)]
    password_hash: String,
}

// ── default: fill in missing fields ───────────────────────────
fn default_page_size() -> usize { 20 }

#[derive(Serialize, Deserialize)]
struct PaginationParams {
    page: usize,

    #[serde(default = "default_page_size")]
    page_size: usize,   // uses 20 if key is absent from JSON

    #[serde(default)]
    include_deleted: bool,  // uses false (bool::default()) if absent
}

// ── flatten: inline nested struct fields ──────────────────────
#[derive(Serialize, Deserialize)]
struct Timestamps {
    created_at: String,
    updated_at: String,
}

#[derive(Serialize, Deserialize)]
struct Article {
    id:    u64,
    title: String,

    // Timestamps fields appear at the top level in JSON
    // { "id": 1, "title": "...", "created_at": "...", "updated_at": "..." }
    #[serde(flatten)]
    timestamps: Timestamps,
}

// flatten also works with HashMap to capture extra fields
#[derive(Serialize, Deserialize)]
struct FlexibleStruct {
    id: u64,

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

The #[serde(flatten)] attribute is particularly useful for implementing partial-update patterns: a struct with a flattened HashMap<String, Value> captures any extra JSON fields for pass-through. However, flattening does not work with #[serde(deny_unknown_fields)] — the two attributes are mutually exclusive. When using rename_all and a field-level rename on the same struct, the field-level rename takes precedence.

Handling Option, Vec, and Nested Structs

Rust's type system maps cleanly onto JSON's type hierarchy: Option<T> covers nullable values, Vec<T> covers JSON arrays, nested structs cover JSON objects, and HashMap<String, T> covers JSON objects with dynamic keys. Understanding these mappings and the serde attributes that modify them is essential for working with real-world JSON APIs.

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

// ── Option<T>: nullable or absent JSON values ──────────────────
#[derive(Serialize, Deserialize)]
struct Profile {
    name: String,

    // Serializes to null when None, omits key with skip_serializing_if
    bio:    Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    avatar: Option<String>,  // key absent when None
}
// { "name": "Alice", "bio": null }          ← bio: None, avatar: None
// { "name": "Alice", "bio": "...", "avatar": "url" }  ← both Some

// ── Vec<T>: JSON arrays ────────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct Post {
    id:   u64,
    tags: Vec<String>,           // ["rust", "json"]
    comments: Vec<Comment>,      // [{...}, {...}]

    #[serde(skip_serializing_if = "Vec::is_empty")]
    attachments: Vec<String>,    // omit empty arrays from output
}

#[derive(Serialize, Deserialize)]
struct Comment {
    author:  String,
    content: String,
}

// ── Nested structs ─────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct Order {
    id:       u64,
    customer: Customer,     // nested object
    items:    Vec<Item>,    // array of objects
    address:  Option<Address>,
}

#[derive(Serialize, Deserialize)]
struct Customer { id: u64, name: String }

#[derive(Serialize, Deserialize)]
struct Item { sku: String, qty: u32, price: f64 }

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

// ── HashMap<String, T> for dynamic JSON objects ────────────────
#[derive(Serialize, Deserialize)]
struct Config {
    name:     String,
    settings: HashMap<String, String>,  // { "theme": "dark", "lang": "en" }
    metadata: HashMap<String, serde_json::Value>,  // mixed-type values
}

// ── Box<T> for recursive types ────────────────────────────────
#[derive(Serialize, Deserialize)]
struct TreeNode {
    value: i64,
    left:  Option<Box<TreeNode>>,   // must be Box to have known size
    right: Option<Box<TreeNode>>,
}

// ── Deserializing with borrowed data (zero-copy) ───────────────
#[derive(Deserialize)]
struct BorrowedUser<'a> {
    name:  &'a str,   // borrows directly from the JSON input string
    email: &'a str,   // valid only as long as the source string lives
}

fn parse_borrowed(json: &str) -> Result<(), serde_json::Error> {
    let user: BorrowedUser = serde_json::from_str(json)?;
    println!("{}", user.name);
    Ok(())
}

Zero-copy deserialization with &str fields avoids heap allocation for string data — serde_json borrows directly from the input string. This is only possible with from_str (not from_reader), and the parsed struct must not outlive the input. For performance-critical paths that parse many small JSON documents, zero-copy deserialization can reduce allocations by 50% or more.

serde_json::Value: Dynamic JSON Without a Schema

serde_json::Value is the escape hatch for JSON with unknown or dynamic structure. It represents any valid JSON value as a Rust enum — use it when the schema is unknown at compile time, when forwarding JSON between services, or when building JSON transformation pipelines. The json!() macro and index operator make Value ergonomic for ad-hoc JSON construction and traversal.

use serde_json::{json, Value};

// ── json!() macro: compile-time JSON construction ─────────────
let request_body = json!({
    "query": "SELECT * FROM users WHERE id = ?",
    "params": [42],
    "timeout": 30,
    "options": {
        "readonly": true,
        "cache": null
    }
});

// Macro variables are inserted at runtime
let user_id = 42u64;
let name    = "Alice";
let payload = json!({
    "userId": user_id,
    "name":   name,
    "active": true
});

// ── Index access — returns &Value (never panics, missing = Null) ─
let data: Value = serde_json::from_str(r#"
    {"user": {"name": "Alice", "tags": ["admin", "editor"]}}
"#).unwrap();

let name  = &data["user"]["name"];        // Value::String("Alice")
let tag0  = &data["user"]["tags"][0];     // Value::String("admin")
let missing = &data["user"]["missing"];   // Value::Null (not a panic)

// Type extraction with as_* methods
if let Some(name_str) = data["user"]["name"].as_str() {
    println!("name: {}", name_str);  // "Alice"
}
if let Some(tags) = data["user"]["tags"].as_array() {
    println!("tag count: {}", tags.len());  // 2
}

// ── Value::pointer for JSONPath-like access ────────────────────
// Uses "/" as path separator, "~0" for "~", "~1" for "/"
let tag = data.pointer("/user/tags/0");  // Some(&Value::String("admin"))
let nope = data.pointer("/user/missing/deep");  // None

// ── Merging JSON objects ──────────────────────────────────────
fn merge(base: &mut Value, patch: Value) {
    if let (Value::Object(base_map), Value::Object(patch_map)) = (base, patch) {
        for (k, v) in patch_map {
            base_map.insert(k, v);
        }
    }
}

let mut config = json!({"host": "localhost", "port": 5432});
let overrides  = json!({"port": 5433, "database": "prod"});
merge(&mut config, overrides);
// config = {"host": "localhost", "port": 5433, "database": "prod"}

// ── Converting between Value and typed structs ─────────────────
use serde::{Deserialize, Serialize};

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

// Typed struct → Value (no allocation for primitives)
let user = User { id: 1, name: "Bob".into() };
let val: Value = serde_json::to_value(&user).unwrap();

// Value → typed struct
let back: User = serde_json::from_value(val).unwrap();

The to_value / from_value pair converts between typed structs and Value — useful in middleware that needs to inspect or modify JSON before forwarding it. The Value::pointer method is the idiomatic serde_json equivalent of JSON Pointer (RFC 6901) — use it instead of chained index access when paths may contain slashes or when the depth is variable.

Streaming Large JSON with serde_json::Deserializer

serde_json::Deserializer::from_reader combined with into_iter provides memory-constant streaming parsing — the entire file is never loaded into memory. This is the correct approach for processing large JSON arrays or NDJSON files that exceed available RAM. Memory usage stays at roughly the size of one record plus the parser buffer (a few kilobytes), regardless of file size.

use serde::Deserialize;
use serde_json::Deserializer;
use std::{fs::File, io::BufReader};

#[derive(Deserialize, Debug)]
struct LogEntry {
    timestamp: String,
    level:     String,
    message:   String,
    #[serde(default)]
    context:   serde_json::Value,
}

// ── Stream a JSON array from a file ───────────────────────────
// File content: [{"timestamp":"...","level":"INFO","message":"..."},...]
fn process_json_array(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let file   = File::open(path)?;
    let reader = BufReader::new(file);  // buffered I/O is critical for performance

    // Deserializer::from_reader streams elements without loading the full array
    let stream = Deserializer::from_reader(reader).into_iter::<LogEntry>();

    let mut count  = 0usize;
    let mut errors = 0usize;

    for result in stream {
        match result {
            Ok(entry) => {
                // Process entry — stays in scope only for this iteration
                count += 1;
                if entry.level == "ERROR" {
                    eprintln!("error: {}", entry.message);
                }
            }
            Err(e) => {
                // Per-record error recovery — continue on malformed lines
                eprintln!("parse error at byte {}: {}", e.byte_offset(), e);
                errors += 1;
            }
        }
    }

    println!("processed {} records, {} errors", count, errors);
    Ok(())
}

// ── NDJSON streaming (one JSON object per line) ────────────────
// File content:
// {"event":"click","user":"u1"}
// {"event":"view","user":"u2"}
// {"event":"purchase","user":"u1","amount":49.99}
fn process_ndjson(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let file   = File::open(path)?;
    let reader = BufReader::new(file);

    // Same StreamDeserializer approach — works for both arrays and NDJSON
    let stream = Deserializer::from_reader(reader).into_iter::<serde_json::Value>();

    for result in stream {
        let record = result?;
        if let Some(event) = record["event"].as_str() {
            println!("event: {}", event);
        }
    }
    Ok(())
}

// ── Write streaming output (serialize one record at a time) ───
use std::io::{self, Write};

fn stream_write_ndjson<I, T>(items: I, writer: &mut impl Write) -> Result<(), serde_json::Error>
where
    I: Iterator<Item = T>,
    T: serde::Serialize,
{
    for item in items {
        serde_json::to_writer(&mut *writer, &item)?;
        writer.write_all(b"\n").unwrap();
    }
    Ok(())
}

The byte_offset() method on serde_json::Error returns the byte position in the input where parsing failed — essential for debugging malformed records in large files. For write streaming, serde_json::to_writer writes directly to any impl Write (file, TCP socket, HTTP response body) without allocating an intermediate String, which is critical for high-throughput streaming APIs. Wrap the underlying writer in BufWriter to batch small writes into large OS calls.

Custom Serializers and Deserializers

When derive macros and attributes are insufficient — custom encoding formats, external types that do not implement serde traits, or complex transformation logic — implement the Serialize and Deserializetraits manually. The Visitor pattern is the core of serde's manual deserialization API.

use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::de::{self, Visitor};
use std::fmt;

// ── #[serde(with = "...")] for reusable custom modules ─────────
// chrono DateTime as Unix timestamp integer
mod ts_seconds_option {
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S>(ts: &Option<i64>, s: S) -> Result<S::Ok, S::Error>
    where S: Serializer {
        match ts {
            Some(v) => s.serialize_i64(*v),
            None    => s.serialize_none(),
        }
    }

    pub fn deserialize<'de, D>(d: D) -> Result<Option<i64>, D::Error>
    where D: Deserializer<'de> {
        Ok(Option::<i64>::deserialize(d)?)
    }
}

#[derive(Serialize, Deserialize)]
struct Event {
    name: String,
    #[serde(with = "ts_seconds_option")]
    occurred_at: Option<i64>,  // serialized as integer seconds, not ISO string
}

// ── Manual Serialize implementation ───────────────────────────
struct Color(u8, u8, u8);  // RGB color stored as tuple

impl Serialize for Color {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        // Serialize as hex string "#rrggbb" instead of [r, g, b]
        s.serialize_str(&format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2))
    }
}

// ── Manual Deserialize with Visitor ──────────────────────────
struct ColorVisitor;

impl<'de> Visitor<'de> for ColorVisitor {
    type Value = Color;

    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "a hex color string like #rrggbb")
    }

    fn visit_str<E: de::Error>(self, v: &str) -> Result<Color, E> {
        if v.len() != 7 || !v.starts_with('#') {
            return Err(E::custom(format!("invalid color: {}", v)));
        }
        let r = u8::from_str_radix(&v[1..3], 16).map_err(E::custom)?;
        let g = u8::from_str_radix(&v[3..5], 16).map_err(E::custom)?;
        let b = u8::from_str_radix(&v[5..7], 16).map_err(E::custom)?;
        Ok(Color(r, g, b))
    }
}

impl<'de> Deserialize<'de> for Color {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Color, D::Error> {
        d.deserialize_str(ColorVisitor)
    }
}

// Usage
let c = Color(255, 128, 0);
let s = serde_json::to_string(&c).unwrap();    // "#ff8000"
let parsed: Color = serde_json::from_str(&s).unwrap();

The Visitor pattern is serde's mechanism for type-driven deserialization: the expecting method provides the error message when the wrong JSON type is encountered, and each visit_* method handles a specific JSON type. Implementing multiple visit_* methods (e.g., visit_str and visit_u64) allows accepting multiple JSON representations for the same Rust type — useful for APIs that sometimes send numbers and sometimes strings for the same field.

serde with Axum and Actix-web JSON Handlers

Both Axum and Actix-web integrate serde_json natively through their extractor and responder systems. Axum's Json<T> extractor and Actix-web's web::Json<T> handle deserialization of request bodies and serialization of response values — your handler functions work with typed Rust structs, and the framework manages the JSON encoding boundary.

// ── Axum JSON handlers ─────────────────────────────────────────
use axum::{
    extract::Json,
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Deserialize)]
struct CreateUserRequest {
    #[serde(rename_all = "camelCase")]  // accept camelCase JSON keys
    first_name: String,
    last_name:  String,
    email:      String,
}

#[derive(Serialize)]
struct UserResponse {
    id:    u64,
    name:  String,
    email: String,
}

// Handler: deserialize request body, return typed response
async fn create_user(
    Json(payload): Json<CreateUserRequest>,  // 422 if JSON is invalid
) -> impl IntoResponse {
    let user = UserResponse {
        id:    42,
        name:  format!("{} {}", payload.first_name, payload.last_name),
        email: payload.email,
    };
    (StatusCode::CREATED, Json(user))
}

// ── Custom error type implementing IntoResponse ────────────────
#[derive(Debug)]
enum AppError {
    NotFound(String),
    ValidationError(String),
    Internal(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        let (status, message) = match &self {
            AppError::NotFound(msg)        => (StatusCode::NOT_FOUND,            msg.as_str()),
            AppError::ValidationError(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.as_str()),
            AppError::Internal(msg)        => (StatusCode::INTERNAL_SERVER_ERROR, msg.as_str()),
        };
        (status, Json(json!({"error": message}))).into_response()
    }
}

async fn get_user(/* Path(id): Path<u64> */) -> Result<Json<UserResponse>, AppError> {
    // Return Err(AppError::NotFound(...)) to get a JSON error response
    Ok(Json(UserResponse { id: 1, name: "Alice".into(), email: "a@b.com".into() }))
}

// ── Actix-web JSON handlers ────────────────────────────────────
// use actix_web::{web, App, HttpResponse, HttpServer, Responder};
//
// async fn create_user_actix(
//     body: web::Json<CreateUserRequest>,  // 400 if JSON is invalid
// ) -> impl Responder {
//     let user = UserResponse { id: 42, name: body.first_name.clone(), email: body.email.clone() };
//     HttpResponse::Created().json(user)   // serde_json::to_vec internally
// }

// ── OpenAPI with utoipa ────────────────────────────────────────
// #[derive(Deserialize, utoipa::ToSchema)]
// struct CreateUserRequest { ... }
//
// #[utoipa::path(post, path = "/users", request_body = CreateUserRequest, ...)]
// async fn create_user(...) { ... }

Axum returns HTTP 422 Unprocessable Entity automatically when the Json<T> extractor fails to deserialize the request body — the error message includes the serde_json error. To customize this response (e.g., to return a structured JSON error body), implement a custom rejection handler by extracting Json<T> via FromRequest manually or using axum::extract::rejection::JsonRejection. For OpenAPI documentation, utoipa integrates with serde's derive macros — structs annotated with both #[derive(Serialize, Deserialize)] and #[derive(utoipa::ToSchema)] generate JSON Schema automatically. See the JSON API design guide for REST API request/response patterns.

Key Terms

serde
A Rust framework for serialization and deserialization. The name is a portmanteau of serialization and deserialization. serde defines the Serialize and Deserialize traits in the serde crate, and provides derive macros that generate implementations at compile time. The framework is format-agnostic: the same trait implementations work with any serde-compatible format crate (serde_json, serde_yaml, toml, bincode, etc.). serde does not perform any I/O itself — it provides the abstraction layer between Rust types and data formats, with individual format crates providing the concrete implementation.
Deserializer
A trait in serde that drives the deserialization process from a data format into Rust types. serde_json::Deserializer is the concrete implementation for JSON. The Deserializer type can be constructed from a string (from_str), byte slice (from_slice), or reader (from_reader). The into_iter<T> method returns a StreamDeserializer that lazily deserializes one element at a time from a JSON array or sequence of JSON documents — the key primitive for memory-efficient large-file processing. The Deserializer trait itself is what format crates implement; types implementing Deserialize are driven by a Deserializer to reconstruct their values.
serde_json::Value
An enum in the serde_json crate that represents any valid JSON value dynamically. The six variants are: Value::Null, Value::Bool(bool), Value::Number(Number), Value::String(String), Value::Array(Vec<Value>), and Value::Object(Map<String, Value>). Value implements both Serialize and Deserialize, so it can be used anywhere a typed struct would be used. The json!() macro provides ergonomic construction of Value trees at the call site. Index access (value["key"]) returns a reference to a nested Value and never panics — a missing key returns &Value::Null. The pointer method supports RFC 6901 JSON Pointer path syntax for deep access.
flatten
A serde attribute (#[serde(flatten)]) that inlines the fields of a nested struct (or the key-value pairs of a HashMap) into the parent JSON object, removing the nesting level. A struct with a flattened nested struct serializes as if all fields from both structs were at the same level. This is useful for composing structs from reusable components (e.g., a Timestamps struct flattened into many entity structs) and for implementing catch-all extra-field capture with a flattened HashMap<String, Value>. Flatten is incompatible with #[serde(deny_unknown_fields)] and with non-self-describing formats like bincode that require knowing field counts ahead of time.
StreamDeserializer
A struct in serde_json that implements Iterator<Item = Result<T, Error>>, lazily deserializing one JSON value at a time from a continuous byte stream. Obtained by calling Deserializer::from_reader(reader).into_iter<T>(). The stream processes both JSON arrays (reading each array element on demand) and NDJSON (newline-delimited JSON, reading each line as a separate document). Memory usage is bounded by the size of one deserialized record, not the total input size. Each iteration step parses the next JSON value from the stream and returns Ok(T) on success or Err(serde_json::Error) on a parse failure, allowing per-record error recovery by continuing iteration after an error.
Visitor pattern
serde's mechanism for manual deserialization. A type implementing Deserialize creates a Visitor struct (implementing the Visitor trait) and passes it to Deserializer::deserialize_*. The Deserializer calls the appropriate visit_* method on the Visitor based on what it encounters in the input — visit_str for strings, visit_u64 for unsigned integers, visit_map for objects, visit_seq for arrays. The expecting method on Visitor provides the error message when the input does not match the expected type. Implementing multiple visit_* methods allows accepting multiple JSON representations. The Visitor pattern enables zero-copy deserialization: visit_borrowed_str receives a &'de str borrowed directly from the input.

FAQ

How do I serialize a Rust struct to JSON with serde?

Add serde = { version = "1", features = ["derive"] } and serde_json = "1" to Cargo.toml. Annotate your struct with #[derive(Serialize)] from the serde crate, then call serde_json::to_string(&value)? to get a compact JSON String, serde_json::to_string_pretty(&value)? for human-readable output with indentation, or serde_json::to_vec(&value)? for a Vec<u8>. All struct fields must implement Serialize — primitive types, String, Vec<T>, Option<T>, and HashMap<String, V> all implement it automatically. For enums, unit variants serialize to strings, tuple variants to arrays, and struct variants to objects. Field names in the JSON output match Rust field names exactly unless you use #[serde(rename)] or #[serde(rename_all = "camelCase")].

How do I deserialize JSON into a Rust struct?

Annotate the struct with #[derive(Deserialize)] and call serde_json::from_str::<MyStruct>(json_str) for a &str input, serde_json::from_slice::<MyStruct>(bytes) for a &[u8], or serde_json::from_reader::<_, MyStruct>(reader) for file or network stream input. These return Result<MyStruct, serde_json::Error>. Extra JSON fields not present in the struct are ignored by default — add #[serde(deny_unknown_fields)] to reject them. Missing fields cause a deserialization error unless the field is Option<T> (which defaults to None) or annotated with #[serde(default)]. The serde_json::Error type exposes .line(), .column(), and .classify() for detailed error context.

How do I rename JSON fields in Rust with serde?

Use #[serde(rename = "fieldName")] on an individual field to specify a different JSON key. To rename all fields in a struct, use #[serde(rename_all = "camelCase")] at the struct level — this converts all snake_case field names to camelCase automatically. Other rename_all options include "PascalCase", "SCREAMING_SNAKE_CASE", "kebab-case", "lowercase", and "UPPERCASE". To apply different names for serialization and deserialization, use #[serde(rename(serialize = "userId", deserialize = "user_id"))]. Field-level rename overrides struct-level rename_all. Renaming applies to both Serialize and Deserialize by default — the field is written and read under the renamed key.

How do I handle optional JSON fields in Rust with serde?

Declare the field as Option<T>: it serializes to null when None and to the unwrapped value when Some(value). To omit the field entirely from the output when it is None, add #[serde(skip_serializing_if = "Option::is_none")] — the most common pattern for optional API fields. During deserialization, a missing JSON key automatically deserializes to None for Option<T> fields. For non-Option fields with a default value, use #[serde(default)] (calls the type's Default::default()) or #[serde(default = "fn_name")] with a function returning the desired default. To skip a field entirely in both directions, use #[serde(skip)] — the field must implement Default.

What is serde_json::Value and when should I use it?

serde_json::Value is a Rust enum representing any valid JSON value: Null, Bool(bool), Number(Number), String(String), Array(Vec<Value>), or Object(Map<String, Value>). Use it when the JSON schema is unknown at compile time — forwarding JSON between services, building JSON transformation pipelines, or handling APIs with dynamic field sets. Access nested values with value["key"]["nested"] (returns &Value::Null for missing keys, never panics) or value.pointer("/key/nested") for JSON Pointer access. Construct values with the json!() macro. For APIs with known schemas, prefer typed structs — they are faster, provide compile-time guarantees, and generate better error messages on invalid input. Reserve Value for dynamic JSON manipulation and testing fixtures.

How do I stream large JSON files in Rust?

Use serde_json::Deserializer::from_reader(reader).into_iter::<T>() to get a StreamDeserializer that yields one deserialized record at a time. Open the file with std::fs::File::open(path)?, wrap it in std::io::BufReader for buffered reads, then call into_iter. Memory usage is bounded by the size of one record — a 10 GB file uses the same memory as a 10 MB file. For NDJSON (newline-delimited JSON), the same approach works: the deserializer processes each line as a separate JSON document. Handle parse errors per-record with a match inside the loop to allow continuing after a malformed record. For writing streaming output, use serde_json::to_writer(&mut writer, &item)? followed by a newline byte to generate NDJSON without intermediate allocation.

How do I handle custom date formats when serializing JSON in Rust?

Use #[serde(with = "module_name")] on the field, where the module exports pub fn serialize and pub fn deserialize functions with the exact signatures serde expects. For chrono::DateTime<Utc>, the chrono crate's feature flag serde provides ready-made modules: chrono::serde::ts_seconds (Unix seconds as integer), chrono::serde::ts_milliseconds, and chrono::serde::ts_nanoseconds. Use them directly: #[serde(with = "chrono::serde::ts_seconds")]. For custom string formats, write a module with a serialize function calling serializer.serialize_str(&dt.format("%Y-%m-%d").to_string()) and a deserialize function using a Visitor that parses the string. Use #[serde(serialize_with = "...")] and #[serde(deserialize_with = "...")] to apply serialize and deserialize independently.

How do I use serde JSON with Axum web framework?

Add Json<T> as a handler argument to extract and deserialize the request body: async fn handler(Json(payload): Json<MyRequest>) { ... }. Axum returns HTTP 422 automatically if deserialization fails. Derive Deserialize on the request type and Serialize on the response type. Return Json(value) from the handler to serialize the response — Axum sets Content-Type: application/json automatically. For error responses, implement IntoResponse on a custom error enum and return Result<Json<Response>, AppError> from handlers. The error implementation uses Json(json!({"error": message})) and a matching StatusCode to produce a structured JSON error body. Use serde attributes like #[serde(rename_all = "camelCase")] on request/response structs to match the JSON conventions your API clients expect.

Further reading and primary sources