The JSON problem in Rust
You are building a Rust service. You have a User struct with an ID, a name, and a status flag. You need to send this data to a web frontend. In Python, you call json.dumps on a dictionary and get a string. In JavaScript, JSON.stringify handles objects with zero friction.
Rust does not include JSON support in the standard library. The language design keeps the core minimal and pushes format-specific logic to crates. You cannot serialize a struct without adding a dependency. The ecosystem standard is serde, paired with serde_json.
Serde is the translator
serde stands for Serialize/Deserialize. It is a framework that defines how data moves between Rust types and external formats. serde_json is the implementation that speaks JSON. You can use serde with YAML, TOML, or MessagePack by swapping the format crate while keeping the same struct definitions.
The core mechanism is the Serialize trait. When you derive this trait on a struct, you are asking the compiler to generate code that converts your struct into a sequence of JSON tokens. Think of #[derive(Serialize)] as a stamp on your struct. The stamp tells the compiler to write a custom serialization function tailored to your exact fields.
This approach is zero-cost. Unlike runtime reflection in other languages, serde generates the serialization logic at compile time. The resulting code is as fast as hand-written serialization. There is no introspection overhead. There are no virtual dispatch calls. The compiler sees the generated function, optimizes it, and inlines it where possible.
Minimal working example
Add the dependencies to your Cargo.toml. The derive feature is required to use the macro. Without it, you must write manual trait implementations.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Define the struct and serialize it. The to_string function returns a Result, not a String. Serialization can fail if the data contains invalid characters or if a custom serializer encounters an error. The Result type forces you to handle these cases.
use serde::Serialize;
use serde_json;
#[derive(Serialize)]
struct User {
id: u32,
name: String,
active: bool,
}
fn main() {
let user = User {
id: 42,
name: "Alice".to_string(),
active: true,
};
// to_string takes a reference and returns Result<String, Error>.
// Use expect for quick prototyping; handle errors in production.
let json = serde_json::to_string(&user).expect("Serialization failed");
println!("{}", json);
// Output: {"id":42,"name":"Alice","active":true}
}
Convention aside: Always enable the derive feature in Cargo.toml. The community considers serde without derive to be an edge case. Manual implementations are reserved for types where you need custom logic that attributes cannot express.
How the compiler builds the serializer
When you write #[derive(Serialize)], the serde macro runs during compilation. It inspects the struct definition and writes a impl Serialize for User block. The generated code iterates over the fields, calls the serialization method for each field type, and writes the JSON structure.
The serde_json::to_string function takes a reference &T. It does not consume the value. You can serialize the same struct multiple times without moving it. This matches the mental model of serialization as a read-only operation.
The return type is Result<String, serde_json::Error>. Even for simple structs with only primitives, the API returns a Result. This design keeps the interface uniform. If you later add a field with a custom serializer that can fail, the function signature does not change. You always handle the Result.
In production code, propagate the error using the ? operator. This avoids panics and lets the caller decide how to handle failures.
use serde::Serialize;
use serde_json;
#[derive(Serialize)]
struct Config {
host: String,
port: u16,
}
/// Serializes config to JSON string.
/// Returns an error if serialization fails.
fn get_config_json(config: &Config) -> Result<String, serde_json::Error> {
serde_json::to_string(config)
}
The derive macro is a code generator, not a runtime helper. The serialization logic exists in your binary as concrete functions.
Real-world struct patterns
Real data rarely matches Rust struct layouts perfectly. APIs use different naming conventions. Optional fields should often be omitted rather than sent as null. Nested structures require careful trait bounds. serde provides attributes to control serialization without changing the Rust code.
Use #[serde(rename_all = "camelCase")] at the struct level to transform all field names. This is the standard approach for JSON APIs that expect camelCase keys while Rust code uses snake_case. It avoids cluttering every field with individual rename attributes.
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
status_code: u16,
error_message: Option<String>,
total_count: u64,
}
Use #[serde(skip_serializing_if = "Option::is_none")] to omit optional fields when they are None. This keeps JSON payloads smaller and matches the convention that missing keys represent absence of data.
use serde::Serialize;
#[derive(Serialize)]
struct Post {
id: u64,
title: String,
// Omit the field if draft is None.
// This prevents "draft": null in the output.
#[serde(skip_serializing_if = "Option::is_none")]
draft: Option<String>,
}
Nested structs work automatically if they also derive Serialize. serde recurses into contained types. Vectors, maps, and tuples serialize correctly as long as their elements implement the trait.
use serde::Serialize;
#[derive(Serialize)]
struct Author {
username: String,
}
#[derive(Serialize)]
struct Article {
title: String,
// Nested struct must derive Serialize.
author: Author,
// Vectors serialize if the element type does.
tags: Vec<String>,
}
Attributes are your control panel. Use them to shape the JSON output while keeping the Rust struct idiomatic.
Pitfalls and compiler errors
If you forget to derive Serialize, the compiler rejects the code with E0277 (the trait bound Serialize is not satisfied). The error message lists the type that lacks the trait and suggests adding the derive attribute.
error[E0277]: the trait bound `User: Serialize` is not satisfied
--> src/main.rs:15:26
|
15 | let json = serde_json::to_string(&user);
| --------------------- ^^^^^ the trait `Serialize` is not implemented for `User`
| |
| required by a bound introduced by this call
Add #[derive(Serialize)] to the struct definition. The error resolves immediately.
Another common issue is memory allocation. serde_json::to_string builds a String in memory. If you serialize a large dataset, you allocate the full JSON string before writing it to a socket or file. This doubles memory usage and can cause allocation failures.
Use serde_json::to_writer to stream the output directly to any type that implements std::io::Write. This avoids the intermediate string allocation.
use serde::Serialize;
use serde_json;
use std::fs::File;
#[derive(Serialize)]
struct LargeData {
records: Vec<String>,
}
fn save_to_file(data: &LargeData) -> Result<(), serde_json::Error> {
// Create a file writer.
let mut file = File::create("output.json")?;
// Stream JSON directly to the file.
// No String allocation occurs.
serde_json::to_writer(&mut file, data)
}
Check the size. If the JSON fits in a tweet, to_string is fine. If it fits in a novel, stream it.
Decision: choosing the right serialization path
Use serde_json::to_string when you need a JSON string in memory for small payloads like API responses, configuration objects, or cache entries.
Use serde_json::to_writer when streaming large data to a file, socket, or HTTP response body to avoid allocating the full string and reduce memory pressure.
Use serde_json::to_string_pretty only for debugging, logging, or human-readable output files; never use it in production APIs where bandwidth and latency matter.
Use #[serde(rename_all = "camelCase")] at the struct level when your API contract requires camelCase keys, saving you from annotating every field individually.
Use #[serde(skip_serializing_if = "Option::is_none")] when optional fields should disappear from JSON rather than appear as null, keeping payloads minimal and semantically clear.
Use #[serde(rename = "custom_key")] on a single field when only that field needs a different name in JSON, avoiding global renaming that would affect other fields.
Pick the tool that matches the data flow. Memory string for small data, writer for streams.