Introduction
JSON serialization is one of those tasks that feels simple until it becomes your bottleneck in production. You're handling API responses, database queries, message queues—all of it flows through some form of JSON encoding or decoding. Serde for JSON: a complete Rust guide for production teams gives you the tools to handle serialization and deserialization safely, efficiently, and with minimal boilerplate across your entire stack.
Serde (short for "serialize/deserialize") is Rust's de facto standard for converting between Rust data structures and formats like JSON. If you're building microservices, event-driven systems, or any application that moves data across network boundaries, understanding Serde's capabilities will save you days of debugging and keep your code maintainable as your schemas evolve.
The Basics: #[derive(Serialize, Deserialize)]
The simplest way to unlock JSON support in Rust is to add two derives to your struct or enum. Let's start with a real example from a typical e-commerce system:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct Order {
pub id: u64,
pub customer_name: String,
pub total_amount: f64,
pub items: Vec<OrderItem>,
}
#[derive(Serialize, Deserialize)]
pub struct OrderItem {
pub product_id: u64,
pub quantity: u32,
pub price: f64,
}With these derives in place, you can serialize to JSON and deserialize from JSON almost effortlessly:
use serde_json::json;
let order = Order {
id: 12345,
customer_name: "Alice".to_string(),
total_amount: 299.99,
items: vec![
OrderItem { product_id: 1, quantity: 2, price: 50.0 },
OrderItem { product_id: 2, quantity: 1, price: 199.99 },
],
};
// Serialize to JSON string
let json_string = serde_json::to_string(&order).unwrap();
println!("{}", json_string);
// Deserialize from JSON string
let parsed: Order = serde_json::from_str(&json_string).unwrap();The derives handle the heavy lifting. Serde inspects your type at compile time and generates serialization and deserialization code tailored to your exact structure. No reflection, no runtime overhead—just straightforward, predictable behavior.
In production, you'll often want serde_json::to_string_pretty() for debugging and logging, though the compact form is what you ship across the network. Remember that both operations can fail, so proper error handling with Result types is essential, especially in APIs where malformed JSON from clients needs graceful rejection.
Handling Naming Conventions with rename_all
Your Rust code uses snake_case (the idiomatic convention), but your API contracts—whether with frontend teams or external partners—often expect camelCase or PascalCase. Hardcoding alternative field names leads to maintenance nightmares. Serde's rename_all attribute solves this elegantly:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserProfile {
pub user_id: u64,
pub first_name: String,
pub last_name: String,
pub email_address: String,
pub phone_number: Option<String>,
}
// Serializes to:
// {
// "userId": 42,
// "firstName": "Bob",
// "lastName": "Smith",
// "emailAddress": "bob@example.com",
// "phoneNumber": null
// }The rename_all attribute applies to every field in the struct. Serde supports multiple conventions: camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, and more. If you need per-field control, use #[serde(rename = "customName")] on individual fields to override the global rule.
We've used this pattern across CRM and trading systems where backend services speak snake_case internally but expose camelCase APIs to JavaScript frontends. It keeps your Rust code idiomatic while conforming to external naming expectations without any manual conversion code.
Optional Fields and Defaults with Option
APIs evolve. Fields get added, removed, or become conditional. Option<T> paired with Serde gives you flexible schema evolution:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct Product {
pub id: u64,
pub name: String,
pub price: f64,
pub description: Option<String>,
pub discount_percent: Option<f64>,
#[serde(default)]
pub in_stock: bool,
}
// Deserializes successfully even if description and discount_percent are missing
let json = r#"{"id": 1, "name": "Widget", "price": 19.99}"#;
let product: Product = serde_json::from_str(json).unwrap();
// description: None, discount_percent: None, in_stock: false (default)When Serde encounters an Option<T> field during deserialization, it treats the field as optional. If the JSON doesn't include it, the field becomes None. If the JSON explicitly has null, the same thing happens. If the field is present and non-null, it's parsed into Some(value).
The #[serde(default)] attribute is equally powerful. It tells Serde to use the type's default value (from the Default trait) if the field is missing. For custom defaults, use #[serde(default = "function_name")] and implement a function that returns your desired default.
This pattern is critical in ride-hailing and travel systems where different API versions coexist. Legacy clients send old schemas; new clients send extended schemas. By carefully using Option and defaults, you can accept both without branching logic.
Custom Serializers for Special Formats
Not everything fits neatly into Serde's default behavior. Timestamps, UUIDs, custom enums with business logic, or domain-specific encodings often need custom handling. That's where custom serializers and deserializers come in:
use serde::{Serialize, Deserialize, Serializer, Deserializer};
use chrono::{DateTime, Utc};
// Custom serializer: DateTime to RFC3339 string
fn serialize_datetime<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = dt.to_rfc3339();
serializer.serialize_str(&s)
}
// Custom deserializer: RFC3339 string to DateTime
fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
DateTime::parse_from_rfc3339(&s)
.map(|dt| dt.with_timezone(&Utc))
.map_err(serde::de::Error::custom)
}
#[derive(Serialize, Deserialize)]
pub struct Event {
pub event_id: u64,
#[serde(serialize_with = "serialize_datetime", deserialize_with = "deserialize_datetime")]
pub created_at: DateTime<Utc>,
pub event_type: String,
}Custom serializers accept a reference to the value and a serializer trait object; they produce the serialized form. Custom deserializers accept a deserializer trait object and return a deserialized value. This gives you complete control over the wire format while keeping your domain types clean.
In production systems handling Kafka topics or REST APIs, we've used this for compressing large nested objects, encrypting sensitive fields at the JSON boundary, or normalizing timestamps across multiple timezone-aware services. The custom serializer function signature is slightly different for each format (JSON, MessagePack, etc.), but Serde abstracts that away.
Common Mistakes
- Forgetting error handling:
serde_json::from_str()returnsResult. In production, clients send malformed JSON. Unwrap it only in tests; in real code, handle the error gracefully (return 400 Bad Request in APIs, log and retry in background tasks). - Using Box<T> without understanding performance: Boxing adds indirection. If you're serializing millions of small objects, consider whether you actually need a boxed trait object or if a concrete type would be clearer and faster.
- Applying rename_all globally when you need per-field exceptions: You can combine them. Use
#[serde(rename_all = "camelCase")]on the struct and#[serde(rename = "id")]on individual fields to override. - Not testing schema evolution: Add Option fields or defaults, but test deserialization of old JSON payloads. Regression in your test suite catches schema drift before it breaks production integrations.
- Overcomplicating custom serializers: Start simple. If you're writing 50 lines of custom code for a timestamp, you probably need a helper function or a dedicated type. Keep serializers focused on format conversion, not business logic.
Key Takeaways
- Use
#[derive(Serialize, Deserialize)]to unlock zero-cost JSON support for any struct or enum. - Apply
#[serde(rename_all = "camelCase")]to keep your Rust code idiomatic while conforming to external naming conventions. - Leverage
Option<T>and#[serde(default)]for graceful schema evolution and backward compatibility. - Reach for custom serializers and deserializers only when default behavior doesn't fit your domain requirements.
- Always handle deserialization errors properly in production code; never unwrap JSON parsing at API boundaries.
- Test your schema contracts. Use property-based testing or snapshot tests to catch serialization bugs early.






Comments
Loading comments…