Serialization with Serde
Reviewed & published by Brayan K
Serde is Rust's universal serialization framework — derive two traits once and your types convert to and from JSON, TOML, YAML, and many more formats with almost no overhead.
Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll derive Serialize and Deserialize, round-trip data through serde_json, customize fields with attributes, handle enums and dynamic JSON, and see how the same code targets other formats.
What You'll Learn in This Lesson
- #[derive(Serialize, Deserialize)]
- to_string and from_str with serde_json
- Field attributes: rename, default, skip
- rename_all and case conventions
- Enums and tagged representations
- serde_json::Value and other formats
1️⃣ Deriving Serialize and Deserialize
Serde is built on two traits: Serialize turns a value into a format, and Deserialize reads it back. The derive macros generate both. With serde_json, to_string and from_str round-trip your type through JSON.
// Serde works through two traits: Serialize and Deserialize.
// The derive macros generate the conversion code for your type.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Config {
name: String,
port: u16,
debug: bool,
}
fn main() {
let config = Config { name: "api".into(), port: 8080, debug: true };
// Serialize the struct to a JSON string.
let json = serde_json::to_string(&config).unwrap();
println!("{json}");
// Deserialize a JSON string back into a Config.
let parsed: Config = serde_json::from_str(&json).unwrap();
println!("{parsed:?}");
}One derive gave you both directions. Swap serde_json for the toml or serde_yaml crate and the very same type serializes to TOML or YAML.
2️⃣ Customizing Fields with Attributes
Attributes adjust the mapping between Rust and the wire format. rename changes a key, default fills in a missing field, skip drops a field entirely, and a container-level rename_all renames everything at once.
// Field attributes customize how individual fields are (de)serialized.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")] // userName, isActive, ... in JSON
struct User {
user_name: String,
// Missing in the input? Fall back to Default (false here).
#[serde(default)]
is_active: bool,
// Use a different key than the Rust field name.
#[serde(rename = "id")]
user_id: u32,
// Never written out, ignored on input.
#[serde(skip)]
cached_token: String,
}
fn main() {
// Note: no "isActive" key, and "id" not "userId".
let input = r#"{"userName":"ada","id":7}"#;
let user: User = serde_json::from_str(input).unwrap();
println!("{user:?}");
println!("{}", serde_json::to_string(&user).unwrap());
}Notice the input had no isActive key, so default supplied false, and the skipped token never appears in the output.
3️⃣ Enums and Dynamic JSON
Enums serialize using a tagged representation; #[serde(tag = "type")] selects an internally tagged form. When you don't have a fixed struct, parse into serde_json::Value — an enum that models any JSON — and index into it.
// Enums map to "tagged" JSON, and Value handles dynamic JSON.
use serde::{Serialize, Deserialize};
use serde_json::Value;
#[derive(Serialize, Deserialize, Debug)]
// Internally tagged: a "type" key names the variant.
#[serde(tag = "type")]
enum Event {
Login { user: String },
Logout,
}
fn main() {
let e = Event::Login { user: "ada".into() };
let json = serde_json::to_string(&e).unwrap();
println!("{json}"); // {"type":"Login","user":"ada"}
// When you don't have a fixed struct, parse into a Value tree.
let v: Value = serde_json::from_str(r#"{"a":[1,2,3],"b":true}"#).unwrap();
println!("a[1] = {}", v["a"][1]); // index into the JSON tree
println!("b = {}", v["b"]);
}Use a concrete struct whenever you know the schema; reach for Value only for genuinely dynamic data whose shape isn't known until runtime.
Mini-Challenge: Round-Trip a Point
Derive the Serde traits on a small struct and convert it to JSON and back.
// 🎯 MINI-CHALLENGE: round-trip a Point through JSON.
//
// 1. Define a struct Point { x: i32, y: i32 } and derive
// Serialize, Deserialize, and Debug.
// 2. Build a Point, serialize it with serde_json::to_string.
// 3. Deserialize the string back with serde_json::from_str and print it.
//
// Expected JSON: {"x":3,"y":4}
// your code hereCommon Errors (and the fix)
- "cannot find derive macro Serialize" — Enable the derive feature: serde = { features = ["derive"] }.
- "missing field" on parse — Add #[serde(default)] or make the field an Option<T>.
- Keys don't match the API — Use #[serde(rename = "...")] or rename_all.
- "the trait Serialize is not implemented" — Derive Serialize/Deserialize on every nested type too.
Pro Tips
- 💡 Prefer typed structs: they catch schema mistakes at compile time — use Value only for dynamic data.
- 💡 to_string_pretty for humans: use it for config files and logs you'll read by eye.
- 💡 One derive, many formats: the same type serializes to JSON, TOML, YAML, or bincode by changing only the format crate.
📋 Quick Reference — Serde
| Goal | Rust Syntax |
|---|---|
| Derive traits | #[derive(Serialize, Deserialize)] |
| To JSON | serde_json::to_string(&v) |
| From JSON | serde_json::from_str(&s) |
| Rename key | #[serde(rename = "id")] |
| Default field | #[serde(default)] |
| Dynamic JSON | serde_json::Value |
🎉 Lesson Complete!
- ✅ #[derive(Serialize, Deserialize)] generates both conversions for your types
- ✅ serde_json::to_string and from_str round-trip JSON
- ✅ Attributes like rename, default, and skip shape the format
- ✅ Tagged enums and serde_json::Value handle dynamic data; the same derives target TOML and YAML
Practice quiz
What is Serde?
- A generic serialization and deserialization framework
- An async runtime
- A testing library
- A web server framework
Answer: A generic serialization and deserialization framework. Serde is Rust's de-facto framework for SERializing and DEserializing data structures efficiently and generically.
Which derive macros let a struct convert to and from a data format?
- Read and Write
- ToJson and FromJson
- Serialize and Deserialize
- Encode and Decode
Answer: Serialize and Deserialize. Adding #[derive(Serialize, Deserialize)] generates the code to convert a type to and from any Serde-supported format.
Which crate provides the JSON data format for Serde?
- jsonify
- serde_json
- json_rs
- serde_format
Answer: serde_json. serde_json is the official crate implementing the JSON format on top of Serde's core traits.
Which serde_json function turns a value into a JSON string?
- serde_json::write
- serde_json::parse
- serde_json::encode
- serde_json::to_string
Answer: serde_json::to_string. serde_json::to_string serializes a Serialize value into a JSON String; to_string_pretty adds indentation.
Which serde_json function parses a JSON string into a typed value?
- serde_json::from_str
- serde_json::decode
- serde_json::load
- serde_json::read
Answer: serde_json::from_str. serde_json::from_str deserializes a JSON string into any type that implements Deserialize.
What does #[serde(rename = "...")] on a field do?
- Deletes the field
- Changes the field's serialized key name
- Marks the field optional
- Encrypts the value
Answer: Changes the field's serialized key name. The rename attribute maps a Rust field name to a different key in the serialized output and input.
What does #[serde(default)] do when a field is missing during deserialization?
- Panics
- Returns an error
- Skips the whole struct
- Uses the type's Default value
Answer: Uses the type's Default value. With #[serde(default)], a missing field falls back to Default::default() instead of failing.
What does #[serde(skip)] do to a field?
- It becomes required
- It is hashed
- It is never serialized or deserialized
- It is renamed
Answer: It is never serialized or deserialized. A field marked #[serde(skip)] is omitted from output and ignored on input, using its default when needed.
What does #[serde(rename_all = "camelCase")] apply to?
- The whole crate
- All fields (or variants) at once
- Only one chosen field
- Every variant's discriminant only
Answer: All fields (or variants) at once. rename_all on a struct or enum converts every field or variant name to the chosen case convention.
What is serde_json::Value used for?
- A loosely typed JSON tree when you don't have a fixed struct
- Speeding up compilation
- Locking shared data
- Defining new formats
Answer: A loosely typed JSON tree when you don't have a fixed struct. serde_json::Value is an enum representing arbitrary JSON, useful when the shape isn't known at compile time.
Continue this course
- Previous: FFI & C Interop
- Next: Error Handling with thiserror & anyhow — Ergonomic library and application errors beyond Box<dyn Error>
- Quick reference: Rust cheat sheet
Frequently asked questions
What exactly is Serde and why is it so widely used?
Serde is Rust's standard framework for serialization and deserialization — turning in-memory data structures into a format like JSON and back again. Its key idea is a clean separation: the serde crate defines two generic traits, Serialize and Deserialize, that describe a type's data model once, while separate format crates (serde_json, toml, serde_yaml, bincode, and many more) implement how that data model is read and written. Because of this design you derive the traits a single time and your type works with every format, with no runtime reflection and very little overhead. That combination of speed, type safety, and broad format support is why almost every Rust project that touches structured data depends on Serde.
How do the derive macros and the core traits relate?
At the core, Serialize and Deserialize are ordinary traits you could implement by hand, but that is tedious. The serde crate's derive feature provides #[derive(Serialize, Deserialize)], which generates correct, efficient implementations for your structs and enums at compile time. The generated code describes your type to Serde's data model (a struct with these fields, an enum with these variants, and so on); a format crate then maps that model to concrete bytes. Enabling the derive macros requires the derive feature, commonly added as serde = { version = "1", features = ["derive"] } in Cargo.toml.
What can field and container attributes do?
Attributes fine-tune the mapping between Rust names and the serialized representation. On a single field, #[serde(rename = "id")] changes its key, #[serde(default)] supplies a value when the field is absent on input, and #[serde(skip)] removes the field from both directions entirely. On a whole struct or enum, #[serde(rename_all = "camelCase")] rewrites every field or variant name to a chosen case convention in one line, which is handy when matching an external API's style. There are many more — skip_serializing_if, flatten, with, and others — but these few cover the majority of real-world needs.
How are enums represented, and when should I use serde_json::Value?
By default Serde uses an externally tagged representation for enums, but you can choose internally tagged (#[serde(tag = "type")]), adjacently tagged, or untagged to match an existing JSON shape. When the data's structure is not known at compile time — arbitrary or deeply dynamic JSON — you can deserialize into serde_json::Value, an enum that models any JSON value (null, bool, number, string, array, object). You can index into a Value, inspect it, and even convert parts of it into strongly typed structs later. Reach for a concrete struct whenever you know the schema, and fall back to Value only for genuinely dynamic data. Other formats work the same way: swap serde_json for the toml or serde_yaml crate and the same derived types serialize to TOML or YAML.