Rust 79 🦀 Data Serialization and Deserialization (serde)
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
| Attribute | Effect |
|---|---|
#[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
| Level | Example | Scope |
|---|---|---|
| Container | #[serde(deny_unknown_fields)] | The whole struct |
| Field | #[serde(rename = "id")] | One field |
Deserialization Functions
| Function | Input | Speed |
|---|---|---|
from_str | &str | Fastest |
from_slice | &[u8] | Fastest |
from_reader | impl Read | Slowest without buffering |
Serde Data Model Types
| Type | Rust Example |
|---|---|
bool | bool |
i32 | i32 |
string | String, &str |
seq | Vec<T> |
map | HashMap<K,V> |
struct | Named struct |
tuple_struct | struct 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Trait not implemented | Serde version mismatch | cargo tree -d and align |
flatten panics | Combined with deny_unknown_fields | Remove one |
from_reader slow | No buffering | Use from_str after read_to_string |
skip field is empty | Deserialize skips it | Provide a default or manual impl |
rename ignored | Used on the wrong level | Field-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
| Item | Value |
|---|---|
| Framework | Serde |
| Traits | Serialize, Deserialize |
| Derive | #[derive(Serialize, Deserialize)] |
| Data model | 29 types |
| Rename | #[serde(rename = "x")] |
| Skip | #[serde(skip)] |
| Default | #[serde(default)] |
| Flatten | #[serde(flatten)] |
| Fast deserialize | from_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
Serializercan 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
serializeanddeserializemethods, which is why Serde can approach hand-written performance . - Field attributes change the wire format, not the Rust type.
renamedecouples the two names,skipomits a field,defaultmakes a field optional, andflatteninlines a nested struct . from_strandfrom_sliceare faster thanfrom_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_zapare 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.
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!