| |

Rust 79 🦀 Data Serialization and Deserialization (serde)

⏱️ Estimated read: 14 min | |

Serde is the serialization framework that the Rust ecosystem standardized on, and its design is shaped by a constraint that most serialization libraries do not share: Rust has no runtime reflection. Serde does not inspect a type at runtime to discover its fields; it generates the serialization code at compile time through a derive macro, and the generated code calls the serializer’s methods with the field names and types baked in as constants . This is why Serde can claim performance close to a hand-written serializer: the “reflection” cost that other languages pay at runtime is paid once, at compile time, and then optimized away.

The framework is built on a data model, not on any specific format. The Serialize trait maps a Rust type into one of Serde’s 29 data model types—bool, i32, string, seq, map, struct, and so on—and the format’s Serializer implementation maps that model into bytes . The same #[derive(Serialize)] works for JSON, MessagePack, TOML, and every other format that implements the Serializer trait, because the derive generates code against the data model, not against a format. This separation is what makes Serde a framework rather than a JSON library.

Key point: #[derive(Serialize, Deserialize)] generates implementations that map a type to Serde’s data model at compile time, and any format that implements Serializer/Deserializer can then encode or decode that type. Field attributes (rename, skip, default, flatten) customize the mapping without writing manual impls, and the from_str/from_slice functions are faster than from_reader because they avoid per-byte I/O overhead.


Why Serde is built on a data model

The format explosion problem. A library that serializes to JSON directly cannot serialize to MessagePack without a second implementation, and a third for TOML. Serde solves this by inserting an abstraction: the type implements Serialize once, against the data model, and each format implements Serializer once, against the same model . The number of implementations is the sum of types and formats, not their product. A new format that implements the traits works with every existing type that derives Serialize, without changing the type.

The reflection problem. Other languages discover a type’s fields at runtime through reflection, which prevents inlining and adds overhead. Serde’s derive macro reads the struct definition at compile time and generates a serialize method that calls serializer.serialize_struct("Point", 2) followed by serialize_field("x", &self.x) and serialize_field("y", &self.y) . The field names are string literals in the generated code, and the compiler can inline the entire chain. This is why Serde can match hand-written serializers in the common case .

The compatibility problem. A serialized format is a contract with every consumer. If a field is renamed or a variant is added, the wire format changes. Serde’s field attributes are the mechanism that decouples the Rust field name from the wire name: #[serde(rename = "user_name")] keeps the Rust field as user_name while emitting user_name in JSON . The #[serde(default)] attribute handles the reverse problem: when a consumer sends a payload that omits a field, the deserializer uses the default value instead of failing . These attributes are compile-time directives to the derive macro, not runtime configuration.

The error problem. Serialization and deserialization can fail for different reasons at different layers: the Serialize impl can fail (a poisoned mutex), the Serializer can fail (a map with non-string keys in JSON), the Deserializer can fail (invalid syntax), or the Deserialize impl can fail (wrong type) . Serde defines the ser::Error and de::Error traits so the format can expose its error constructors to the data structure, which means a custom Deserialize impl can return a format-appropriate error with a span or line number rather than a generic message.


a. The derive and the data model

The derive macro is the entry point for most applications. The Cargo.toml declares serde with the derive feature, and the type annotates itself with #[derive(Serialize, Deserialize)] .

// Import the derive macros from serde
use serde::{Serialize, Deserialize};

// The derive generates Serialize and Deserialize for this struct
// The generated code maps each field to the Serde data model
#[derive(Serialize, Deserialize, Debug)]
struct Point {
    // The field name and type are baked into the generated code
    // The serializer receives "x" and &self.x as compile-time constants
    x: i32,
    // The second field, same pattern
    y: i32,
}

fn main() {
    // Create a Point
    let point = Point { x: 1, y: 2 };

    // Serialize to a JSON string
    // to_string uses the Serialize impl generated by the derive
    let serialized = serde_json::to_string(&point).unwrap();
    // The output is {"x":1,"y":2}
    println!("serialized = {}", serialized);

    // Deserialize from the JSON string
    // from_str uses the Deserialize impl generated by the derive
    let deserialized: Point = serde_json::from_str(&serialized).unwrap();
    // The output is Point { x: 1, y: 2 }
    println!("deserialized = {:?}", deserialized);
}

The generated Serialize impl is not a runtime loop over fields. It is a sequence of method calls: serialize_struct with the name and field count, serialize_field for each field, and serialize_struct end. The Deserialize impl is a Visitor that receives the fields by name and constructs the struct. The field names and types are in the generated code as literals, which is why the derive has no runtime reflection cost .

The data model is the contract between the two sides. The Serializer trait has 29 methods, one for each type in the model: serialize_bool, serialize_i32, serialize_str, serialize_seq, serialize_map, serialize_struct, and so on . A format implements these methods to produce its output. The Serialize impl for Point calls serialize_struct and serialize_field, which means a format that implements serialize_struct can encode a Point without knowing what a Point is.


b. Field attributes: renaming, skipping, defaulting, flattening

The derive macro’s default behavior is to use the Rust field name as the wire name, to require every field during deserialization, and to nest nested structs as nested objects. The field attributes change these defaults without writing a manual impl .

use serde::{Serialize, Deserialize};

// The derive with field attributes
#[derive(Serialize, Deserialize, Debug)]
struct User {
    // The wire name is "id" while the Rust field is "user_id"
    // This is the rename attribute: it decouples the two names
    #[serde(rename = "id")]
    user_id: u32,

    // The normal field, no attribute
    name: String,

    // The skip attribute omits this field from both serialization and deserialization
    // The field is still in the Rust struct, but it is not in the wire format
    #[serde(skip)]
    password: String,

    // The default attribute uses Default::default() when the field is absent
    // This makes the field optional in the wire format without making it Option<T>
    #[serde(default)]
    active: bool,

    // The flatten attribute inlines the fields of the nested struct
    // The wire format has street and city at the top level, not nested
    #[serde(flatten)]
    address: Address,
}

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

The rename attribute is the most common. It handles the case where the external API uses camelCase and the Rust code uses snake_case, or where the wire name is a reserved keyword in Rust . The attribute supports independent names for serialization and deserialization: #[serde(rename(serialize = "ser_name", deserialize = "de_name"))] allows the two directions to disagree, which is sometimes necessary for backward compatibility.

The skip attribute omits the field entirely. It is the correct tool for sensitive data—a password field that must not leave the process—and for derived fields that can be recomputed. The default attribute is the tool for optional fields that have a sensible default. It is different from Option<T>: an Option<T> field can be None in the wire format, while a #[serde(default)] field is absent from the wire format and receives the default value. The flatten attribute removes one level of nesting: the Address fields appear at the same level as the User fields in the serialized output . The flatten attribute is not supported in combination with deny_unknown_fields on either the outer or inner struct .


c. Performance: from_str vs from_reader and the format trade-off

The deserialization function choice has a larger impact than most developers expect. from_reader reads from an I/O stream without buffering, which means it performs a read for every byte or small group of bytes. from_str and from_slice read from an in-memory buffer, where the next byte is a pointer increment rather than a syscall .

A GitHub issue on serde_json documents the difference. Parsing a 20 MB JSON file from an unbuffered file took 4,710 ms. Parsing from a buffered reader took 562 ms. Parsing from a string, after reading the file into memory, took 55 ms . The from_reader documentation itself states that “counter to intuition, this function is usually slower than reading a file completely into memory and then applying from_str or from_slice on it” . The reason is that from_reader calls the underlying Read implementation for every byte it needs, and without buffering, those calls are syscalls.

The format trade-off is the second performance consideration. A benchmark of Rust serialization frameworks shows that serde_json achieves 3.32% of the best serializer’s speed and 6.45% of the best deserializer’s speed, while a zero-copy format like rkyv reaches near-instant access . The JSON format is not the fastest; it is the most interoperable. The serde_zap format, which uses the same Serde traits, reaches 83.75% serialization speed and 50.95% deserialization speed, with a size that is 69% of the best format’s size . The choice between JSON and a binary format is a choice between interoperability and speed, and Serde makes the choice a format change rather than a type change.


Complete Example Session

The following session builds a type with the four field attributes, serializes it to JSON, deserializes it back, and compares from_str with a manual read.

// ============================================
// PART 1: THE TYPES WITH ATTRIBUTES
// ============================================
use serde::{Serialize, Deserialize};

// The nested struct is flattened into the parent
#[derive(Serialize, Deserialize, Debug)]
struct Address {
    // The street field appears at the parent's level in JSON
    street: String,
    // The city field appears at the parent's level in JSON
    city: String,
}

// The parent struct with all four attributes
#[derive(Serialize, Deserialize, Debug)]
struct User {
    // The Rust field is user_id, the JSON key is "id"
    #[serde(rename = "id")]
    user_id: u32,
    // The normal field, no attribute
    name: String,
    // The password is never serialized and never required on deserialize
    #[serde(skip)]
    password: String,
    // The active field defaults to false when absent
    #[serde(default)]
    active: bool,
    // The address fields are inlined into the parent
    #[serde(flatten)]
    address: Address,
}

// ============================================
// PART 2: SERIALIZE AND DESERIALIZE
// ============================================
fn main() {
    // Create a User with all fields set
    let user = User {
        user_id: 42,
        name: "Alice".into(),
        password: "secret".into(),
        active: true,
        address: Address {
            street: "Main St".into(),
            city: "Springfield".into(),
        },
    };

    // Serialize to JSON
    // The output has "id" instead of "user_id"
    // The output has no "password" field
    // The output has "street" and "city" at the top level
    let json = serde_json::to_string(&user).unwrap();
    println!("serialized: {}", json);
    // Output: {"id":42,"name":"Alice","active":true,"street":"Main St","city":"Springfield"}

    // Deserialize from JSON that omits "active"
    // The default attribute fills in false
    let partial = r#"{"id":7,"name":"Bob","street":"Oak Ave","city":"Shelbyville"}"#;
    let parsed: User = serde_json::from_str(partial).unwrap();
    // The active field is false because it was absent
    println!("active: {}", parsed.active);
    // The password field is an empty string because it was skipped
    println!("password: '{}'", parsed.password);
}

The two parts cover the types with attributes and the serialize/deserialize cycle. What the session demonstrates is that the attributes change the wire format without changing the Rust struct, and the default attribute makes a field optional without making it Option<T>.


Quick Reference

Derive Attributes

AttributeEffect
#[derive(Serialize)]Generate serialization
#[derive(Deserialize)]Generate deserialization
#[serde(rename = "x")]Change the wire name
#[serde(skip)]Omit from both directions
#[serde(default)]Use default when absent
#[serde(flatten)]Inline nested struct fields

Container vs Field Attributes

LevelExampleScope
Container#[serde(deny_unknown_fields)]The whole struct
Field#[serde(rename = "id")]One field

Deserialization Functions

FunctionInputSpeed
from_str&strFastest
from_slice&[u8]Fastest
from_readerimpl ReadSlowest without buffering

Serde Data Model Types

TypeRust Example
boolbool
i32i32
stringString, &str
seqVec<T>
mapHashMap<K,V>
structNamed struct
tuple_structstruct Rgb(u8,u8,u8)

Best Practices

✅ Do This:

// Use rename for external API compatibility
#[serde(rename = "user_name")]
name: String,

// Use skip for sensitive or derived fields
#[serde(skip)]
password: String,

// Use default for optional fields with a sensible default
#[serde(default)]
active: bool,

// Use from_str for in-memory data
serde_json::from_str(&json).unwrap()

❌ Don’t Do This:

// Use from_reader without buffering for a file
serde_json::from_reader(file).unwrap() // slow

// Assume flatten works with deny_unknown_fields
#[serde(flatten)] // panics with deny_unknown_fields

// Make a required field default if absence is an error
#[serde(default)] // hides missing-field errors

Common Pitfalls

PitfallWhy It HappensFix
Trait not implementedSerde version mismatchcargo tree -d and align
flatten panicsCombined with deny_unknown_fieldsRemove one
from_reader slowNo bufferingUse from_str after read_to_string
skip field is emptyDeserialize skips itProvide a default or manual impl
rename ignoredUsed on the wrong levelField-level, not container-level

Real-World Examples

1. Basic derive

#[derive(Serialize, Deserialize)]
struct Point { x: i32, y: i32 }

2. Rename

#[serde(rename = "id")]
user_id: u32,

3. Skip

#[serde(skip)]
password: String,

4. Default

#[serde(default)]
active: bool,

5. Flatten

#[serde(flatten)]
address: Address,

6. Serialize to JSON

serde_json::to_string(&point).unwrap()

7. Deserialize from JSON

serde_json::from_str(&json).unwrap()

8. Deserialize from bytes

serde_json::from_slice(&bytes).unwrap()

9. Custom error

impl ser::Error for MyError { ... }

10. Manual impl

impl Serialize for MyType { fn serialize<S>(&self, s: S) -> ... }

Visual

The Data Model Pipeline

┌──────────────────────────────────────────────┐
│  Rust type (Point)                           │
│         │                                    │
│         ▼                                    │
│  Serialize impl (generated)                  │
│  maps to data model: struct { x, y }         │
│         │                                    │
│         ▼                                    │
│  Serializer impl (format)                    │
│  maps to bytes: {"x":1,"y":2}               │
└──────────────────────────────────────────────┘

Field Attributes

┌──────────────────────────────────────────────┐
│  Rust field: user_id                         │
│  rename = "id"                               │
│  → JSON key: "id"                            │
│                                              │
│  Rust field: password                        │
│  skip                                        │
│  → not in JSON                               │
│                                              │
│  Rust field: active                          │
│  default                                     │
│  → optional in JSON, false when absent       │
└──────────────────────────────────────────────┘

from_str vs from_reader

┌──────────────────────────────────────────────┐
│  from_reader: read per byte, syscall         │
│  → 4,710 ms for 20 MB                        │
│                                              │
│  from_reader + BufReader: buffered           │
│  → 562 ms                                    │
│                                              │
│  from_str: in-memory, pointer increment      │
│  → 55 ms                                     │
└──────────────────────────────────────────────┘

Summary

ItemValue
FrameworkSerde
TraitsSerialize, Deserialize
Derive#[derive(Serialize, Deserialize)]
Data model29 types
Rename#[serde(rename = "x")]
Skip#[serde(skip)]
Default#[serde(default)]
Flatten#[serde(flatten)]
Fast deserializefrom_str, from_slice

Key takeaways:

  • Serde is a data model, not a JSON library. The derive maps a Rust type to one of 29 data model types, and any format that implements Serializer can encode it. This is why the same derive works for JSON, MessagePack, and TOML .
  • The derive generates code at compile time. There is no runtime reflection. The field names and types are baked into the generated serialize and deserialize methods, which is why Serde can approach hand-written performance .
  • Field attributes change the wire format, not the Rust type. rename decouples the two names, skip omits a field, default makes a field optional, and flatten inlines a nested struct .
  • from_str and from_slice are faster than from_reader. The reader performs I/O per byte; the string and slice functions read from memory. The documentation itself recommends reading the file into memory first .
  • The format choice is a trade-off. JSON is the most interoperable; binary formats like serde_zap are faster and smaller. Serde makes the choice a format change, not a type change .

Remember: Serde is the serialization layer that the Rust ecosystem standardized on because it solves the format explosion problem without runtime reflection. The derive macro reads the type at compile time and generates code against a data model, and the format implements the other side of that model. The field attributes are the customization points: rename for external names, skip for sensitive fields, default for optional fields, flatten for inlined structs. The performance story is the same as everywhere else in Rust: the compile-time work is free at runtime, the in-memory functions are faster than the streaming ones, and the format choice determines the ceiling. Serde is not the fastest serializer, but it is the one that works with every format, and the format is the decision that matters most.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!