Rust Serde Guide 2026: Attributes, Enums, and serde_json

Max WellsMax WellsFounder of Rustify
Serde Guide 2026

TL;DR: Serde is Rust's universal serialization framework. It separates the data model from the format; you derive Serialize/Deserialize on your types once, then use any format crate (JSON, TOML, YAML, MessagePack, Bincode) without changing your structs.

  • 700M+ downloads on crates.io; it's the #1 most-used Rust crate by a wide margin
  • Zero-cost: no boxing, no dynamic dispatch in generated code; as fast as hand-written parsers
  • Derive macros: #[derive(Serialize, Deserialize)] handles 95% of cases
  • Field attributes: rename, skip, default, flatten, alias; fine-grained control per field
  • Multiple formats: same structs work with JSON, TOML, YAML, Bincode, and 30+ other formats

Who Should Read This?

This guide is for Rust developers building APIs, CLI tools, or data pipelines who need to serialize and deserialize structured data reliably and efficiently. If you are coming from Python (where json.dumps just works on dicts) or TypeScript (where JSON is first-class), Serde's derive-based approach will feel different at first but far more powerful in practice. Backend engineers at US companies using Rust for web services work with Serde daily. Understanding its attributes, enum representations, and performance characteristics separates engineers who copy boilerplate from those who write it correctly the first time.


What Is Serde and Why Does Every Rust Project Use It?

Serde is a serialization/deserialization framework that separates the data model from the encoding format; write your types once, serialize to any format without code changes.

Most languages bundle serialization into a specific library for each format (a JSON library, a YAML library, etc.). Serde takes a different approach: it defines a common data model that format crates implement. Your types learn to describe themselves via Serialize and Deserialize traits: format crates then handle the actual encoding.

use serde::{Deserialize, Serialize};
 
#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: u64,
    username: String,
    email: String,
    active: bool,
}
 
fn main() {
    let user = User {
        id: 1,
        username: "alice".to_string(),
        email: "[email protected]".to_string(),
        active: true,
    };
 
    // JSON: requires serde_json
    let json = serde_json::to_string(&user).unwrap();
    println!("{}", json);
    // {"id":1,"username":"alice","email":"[email protected]","active":true}
 
    // Deserialize back: same derive, different direction
    let parsed: User = serde_json::from_str(&json).unwrap();
    println!("{:?}", parsed);
}

The performance characteristic that made Serde dominant: the #[derive] macros generate specialized code for each type at compile time. There is no runtime reflection, no boxing, no dynamic dispatch in the hot path; Serde-generated code benchmarks at the same speed as hand-written parsers.

Bottom line: Serde is not just the most popular Rust crate; at 700M+ downloads it's one of the most downloaded libraries in any language. Adding #[derive(Serialize, Deserialize)] gives you compile-time-verified JSON handling that is typically several times faster than reflection-based serializers in Java or Python, because all type analysis happens at compile time rather than per call.


How Do You Add Serde to Your Project?

Add serde with the derive feature, plus a format crate like serde_json; these two dependencies cover 80% of Serde use cases.

# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
 
# For other formats: add as needed:
# toml = "0.8"
# serde_yaml = "0.9"
# bincode = "1"
# rmp-serde = "1"  # MessagePack

The features = ["derive"] enables the proc-macro that generates Serialize and Deserialize implementations. Without it, you'd have to implement the traits manually, which is almost never necessary.


What Do the Serialize and Deserialize Derives Actually Generate?

The derive macros generate type-specific visitor code that describes your struct's fields to Serde's data model; this avoids runtime reflection by doing all type analysis at compile time.

When you write #[derive(Serialize)], the proc-macro inspects your struct definition and generates code equivalent to:

// What #[derive(Serialize)] generates (simplified)
impl Serialize for User {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        let mut state = serializer.serialize_struct("User", 4)?;
        state.serialize_field("id", &self.id)?;
        state.serialize_field("username", &self.username)?;
        state.serialize_field("email", &self.email)?;
        state.serialize_field("active", &self.active)?;
        state.end()
    }
}

The format crate (serde_json, etc.) implements Serializer; it receives these serialize_field calls and writes the appropriate bytes. The format and the type definition are completely decoupled.

This architecture means switching from JSON to MessagePack (for a 3–5x binary size reduction) requires changing only the format crate call, not the struct definitions or any business logic. The same applies to switching from human-readable formats to compact binary formats for inter-service communication.


How Do You Use the Most Important Field Attributes?

Serde's field attributes let you control naming, skipping, defaults, flattening, and type coercion on a per-field basis without changing your business logic.

Renaming fields

#[derive(Serialize, Deserialize)]
struct ApiResponse {
    #[serde(rename = "userId")]        // JSON uses camelCase
    user_id: u64,
 
    #[serde(rename = "createdAt")]
    created_at: String,
}
 
// Or rename all fields at once:
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]     // snake_case → camelCase
struct ApiResponse {
    user_id: u64,      // serialized as "userId"
    created_at: String, // serialized as "createdAt"
}

Supported rename_all values: "camelCase", "PascalCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE".

Skipping fields

#[derive(Serialize, Deserialize)]
struct User {
    username: String,
 
    #[serde(skip)]                    // Never serialize or deserialize
    internal_cache: Option<String>,
 
    #[serde(skip_serializing)]        // Deserialize only
    password_hash: String,
 
    #[serde(skip_serializing_if = "Option::is_none")]  // Skip if None
    nickname: Option<String>,
}

Default values

#[derive(Serialize, Deserialize)]
struct Config {
    host: String,
 
    #[serde(default)]                 // Use Default::default() if field missing
    port: u16,                        // defaults to 0
 
    #[serde(default = "default_timeout")]
    timeout_ms: u64,
}
 
fn default_timeout() -> u64 { 5000 }

Flattening nested structs

#[derive(Serialize, Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}
 
#[derive(Serialize, Deserialize)]
struct UserListRequest {
    query: String,
 
    #[serde(flatten)]                 // Inline pagination fields into parent
    pagination: Pagination,
}
 
// JSON: {"query": "alice", "page": 1, "per_page": 20}
// (not: {"query": "alice", "pagination": {"page": 1, "per_page": 20}})

3 spots open this month → Check if you are eligible.

We help experienced developers transition into Rust roles at €80K–€150K+ in Europe or $130K–$200K+ in the US.

How Do You Handle Enums With Serde?

Serde supports four enum representation strategies: externally tagged (default), internally tagged, adjacently tagged, and untagged. These are controlled by #[serde(tag)] attributes.

// Default: externally tagged
#[derive(Serialize, Deserialize)]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}
// JSON: {"Circle": {"radius": 5.0}}
 
// Internally tagged: cleaner for APIs
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}
// JSON: {"type": "Circle", "radius": 5.0}
 
// Adjacently tagged
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}
// JSON: {"type": "Circle", "data": {"radius": 5.0}}
 
// Untagged: tries each variant, first match wins (use carefully)
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum StringOrNumber {
    String(String),
    Number(f64),
}

The internally tagged representation (#[serde(tag = "type")]) is the most common choice for REST APIs; it produces flat JSON objects that are easy to read and work naturally with frontend JavaScript. The externally tagged default is rarely used in API contexts because it produces a nested object that most clients find awkward.


How Do You Handle Missing, Null, and Unknown Fields?

Serde's error handling for missing/null fields is controlled by Option, #[serde(default)], and #[serde(deny_unknown_fields)].

#[derive(Deserialize)]
struct Config {
    // Required: deserialization fails if missing
    host: String,
 
    // Optional: None if field is absent or null
    port: Option<u16>,
 
    // Has a default: uses Default::default() if absent
    #[serde(default)]
    debug: bool,
}
 
// Strict mode: reject any field not in the struct
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictConfig {
    host: String,
    port: u16,
}

deny_unknown_fields is useful for configuration files where unexpected keys might indicate typos. It should not be used for external API responses where the API might add new fields in the future, as that would break deserialization on every API update.


How Do You Serialize and Deserialize With serde_json Efficiently?

Use to_string for debug and small payloads; use to_writer for streaming to files or HTTP responses; use from_str/from_reader for parsing. Avoid unnecessary intermediate String allocations.

use std::io::{BufWriter, Write};
 
// Small payloads: to_string is fine
let json_string = serde_json::to_string(&user)?;
let json_pretty = serde_json::to_string_pretty(&user)?;
 
// Large payloads: stream directly to writer, no intermediate String
let file = std::fs::File::create("output.json")?;
let writer = BufWriter::new(file);
serde_json::to_writer(writer, &data)?;
 
// Parse from string
let user: User = serde_json::from_str(&json_string)?;
 
// Parse from reader: avoids loading entire file into memory
let file = std::fs::File::open("data.json")?;
let user: User = serde_json::from_reader(file)?;
 
// Dynamic JSON: when you don't know the schema
let value: serde_json::Value = serde_json::from_str(&raw_json)?;
let name = value["user"]["name"].as_str().unwrap_or("unknown");

What Is the Performance of Serde vs Other Languages?

serde_json with derived types parses in the low gigabytes-per-second range on modern hardware, several times faster than reflection-based parsers in Java or Python. The exact number depends heavily on payload shape, so benchmark your own data before assuming.

ApproachRelative throughputWhy
serde_json + #[derive]baseline (fast)Compile-time specialised code; zero-copy for borrowed &str fields
simd-jsonroughly 2–3x over serde_jsonSIMD-accelerated parsing; Serde compatibility is opt-in via its serde_impl feature
Jackson (Java)slowerRuntime reflection on every type
json (Python stdlib)much slowerPure-CPython object construction

These figures are directional, not benchmark results. simd-json is not a literal drop-in: it needs a mutable byte buffer, exposes its own DOM, and only routes through Serde when you enable serde_impl. Measure with cargo bench on representative payloads before committing to a rewrite.

Bottom line: serde_json with derived types is fast enough that serialization is rarely the bottleneck in a Rust service. If profiling proves it is, simd-json can roughly double throughput, at the cost of a mutable input buffer and its serde_impl feature.

This performance profile is one reason Rust web services at companies like Cloudflare, Discord, and Amazon process far more throughput per machine than equivalent Python or Java services. The serialization layer (which touches every request and response) is not a bottleneck in Rust the way it can be in reflection-based runtimes.


How Do You Use Serde With Non-Standard Types?

Serde provides the with attribute and the serialize_with/deserialize_with helpers for types that don't implement Serde traits natively or need custom formats.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
 
// chrono::DateTime serialized as Unix timestamp integer instead of ISO string
#[derive(Serialize, Deserialize)]
struct Event {
    name: String,
 
    #[serde(with = "chrono::serde::ts_seconds")]
    created_at: chrono::DateTime<chrono::Utc>,
}
 
// Custom serialize function for a specific field
fn serialize_as_uppercase<S>(value: &str, serializer: S) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    serializer.serialize_str(&value.to_uppercase())
}
 
#[derive(Serialize)]
struct Tag {
    #[serde(serialize_with = "serialize_as_uppercase")]
    name: String,
}

The with attribute is how you handle types from third-party crates (like chrono, uuid, bytes) that may not implement Serde themselves, or that implement it in a way that doesn't match your wire format requirements.


What Common Mistakes Do Rust Developers Make With Serde?

Serde mistakes range from subtle attribute misuse to performance anti-patterns; most are caught in code review but the worst ones surface only in production.

  • Using serde_json::Value for everything instead of typed structs. Value is a dynamic type that bypasses compile-time safety. Code that deserializes into Value and then accesses fields with value["key"].as_str().unwrap() has runtime panics waiting to happen. Define typed structs for all data you control. Reserve Value for genuinely unknown or self-describing data.

  • Forgetting #[serde(skip_serializing_if = "Option::is_none")] on optional fields. Without this attribute, Option<T> fields serialize as "field": null in JSON. Most APIs and clients expect absent optional fields to be omitted entirely, not sent as null. Add skip_serializing_if for every optional field that should be absent rather than null in the output.

  • Using the wrong enum representation for APIs. The default externally tagged representation ({"Circle": {"radius": 5.0}}) surprises frontend developers who expect flat objects. For REST APIs consumed by JavaScript clients, #[serde(tag = "type")] almost always produces better ergonomics. Choose the representation intentionally based on your consumers, not by accepting the default.

  • Implementing Deserialize on types with secret fields without explicit #[serde(skip)]. If a struct has an internal field (like password_hash or session_token) that must never appear in serialized output, add #[serde(skip)] or #[serde(skip_serializing)]. Missing this is a security bug: the field will appear in JSON responses if the struct is ever serialized directly in an API handler.

  • Not handling the deny_unknown_fields / unknown fields tension correctly. Using deny_unknown_fields on types that receive external API responses means your deserialization breaks whenever the API adds new fields. Save this attribute for configuration files you control. For external data, either accept extra fields silently (the default) or maintain a versioned schema wrapper.

  • Calling to_string() on large structs repeatedly in a hot path. serde_json::to_string allocates a new String every call. In HTTP handlers processing hundreds of requests per second, use to_writer to stream directly into the response body, or pre-serialize responses that don't change between requests.


How Do You Build Real Serde Fluency Step by Step?

If you want hands-on practice designing typed API schemas, writing correct Serde attributes, and building high-performance Rust web services with proper serialization, Rustify's 9-week bootcamp includes dedicated sessions on Serde patterns with 1:1 coaching. The bootcamp covers how production teams at high-growth startups structure their API types for correctness, maintainability, and performance.


Frequently Asked Questions

Yes. Implementing the traits manually gives complete control; necessary for types that don't map cleanly to Serde's data model, or for custom wire formats. The Serde documentation covers the Visitor pattern for custom deserializers in detail. That said, the derive macros handle 95% of real-world cases. Manual implementation is most commonly needed for: types with invariants the derive macro can't know about, types with complex union representations, or interoperability with legacy formats that don't map to standard JSON structures.

serde_json::Value is a dynamic JSON type; it can hold any JSON structure. Use it when you don't know the schema at compile time, when handling truly arbitrary JSON, or when building tools that work with arbitrary JSON data. For typed application code with known schemas, always prefer typed structs, which are safer and faster. A common legitimate use: a logging or debugging endpoint that passes through arbitrary JSON from another service without parsing its structure.

Use #[serde(alias = "old_name")] for backwards compatibility with renamed fields. For more complex version differences, consider separate request/response structs per API version and a conversion layer in your API handler. This approach is more explicit and avoids accumulating complexity in a single struct that tries to handle all versions. Serde's alias attribute allows a field to be deserialized from multiple JSON key names, which handles simple renames cleanly.

Serde itself is synchronous and has no concept of async. Format crates handle async separately: tokio-serde for Tokio streams, actson for async JSON streaming. For typical Axum/Actix handlers, Serde works perfectly; the async boundary is at the HTTP layer, not the serialization layer. The serialization itself is CPU-bound and fast enough that running it synchronously in an async handler is correct and efficient.

with lets you use a custom module to serialize/deserialize a specific field using functions other than the type's own Serialize/Deserialize implementation. Common uses: chrono::serde::ts_seconds for Unix timestamps, custom date formats, or third-party types that don't implement Serde traits. The with attribute expects a module with serialize and deserialize functions following specific signatures. See the Serde documentation for the exact pattern.

Use strum's Display derive with serde's rename_all for unit-only enums, or implement a custom Serialize/Deserialize. For enums where all variants are unit variants (no data), #[derive(Serialize, Deserialize)] #[serde(rename_all = "lowercase")] produces clean string output. For enums with data variants that should still serialize as strings in some contexts, you need a custom implementation or serde_with helpers.


  • Serde: The serialization framework this article is built on
  • serde_json: JSON encoding and decoding in Rust; the most used serde format
  • Derive: #[derive(Serialize, Deserialize)] is a procedural macro derive
  • Struct: Structs are the primary target for serde derive attributes
  • Enum: Serde supports all enum shapes with #[serde(tag)] and #[serde(untagged)]
  • Display / Debug: Debug is commonly derived alongside Serialize/Deserialize
  • From / Into: Type conversions used in custom serde implementations
  • Macro: Serde's derive attributes are procedural macros
  • Trait: Serialize and Deserialize are traits implemented for each type
  • Axum: Uses serde_json internally for Json<T> extractors and responses

Keep Reading

Sources

Ready to Land a $80-120k Rust Job?