When the wire format disagrees with your code
You are building a Rust service that talks to a legacy API. The API expects JSON keys like firstName and createdAt. Your Rust code uses first_name and created_at because that is what cargo clippy demands and what the rest of your team writes. You do not want to rename your Rust fields to match the API. That breaks your codebase style and forces every function in your application to use the external naming convention. You need a bridge. Serde provides that bridge without touching your struct definitions.
The rename attribute
Serde lets you decouple your internal data representation from the external wire format. The #[serde(rename = "...")] attribute creates a one-to-one mapping between a Rust field and a serialized key. You keep snake_case in Rust. The output gets whatever key you specify. This works for both serialization (Rust to JSON) and deserialization (JSON to Rust). Serde checks the incoming key against your rename target. If the JSON contains the renamed key, Serde maps the value to the Rust field. If the JSON contains the original Rust field name, deserialization fails.
use serde::{Deserialize, Serialize};
/// Represents a user with fields renamed for a legacy API.
#[derive(Serialize, Deserialize, Debug)]
struct User {
// Maps Rust field 'username' to JSON key 'user_name'.
// This applies to both serialization and deserialization.
#[serde(rename = "user_name")]
username: String,
// Renames 'email' to 'email_address' in the wire format.
#[serde(rename = "email_address")]
email: String,
}
fn main() {
let user = User {
username: "alice".to_string(),
email: "alice@example.com".to_string(),
};
// Serialize to JSON string.
// Serde uses the renamed keys in the output.
let json = serde_json::to_string(&user).unwrap();
println!("{}", json);
// Output: {"user_name":"alice","email_address":"alice@example.com"}
}
How Serde maps the keys
When you call serde_json::to_string, Serde looks at the Serialize implementation. It sees the rename attribute on each field. Instead of writing the Rust field name to the buffer, it writes the renamed key. During deserialization, Serde parses the JSON keys. It looks for user_name. If found, it maps that value to the username field. If the JSON has username instead, deserialization fails. The rename is strict. It is not a fallback.
The attribute works on enum variants too. You can rename variants to match external string representations.
use serde::{Deserialize, Serialize};
/// Status enum with renamed variants for the API.
#[derive(Serialize, Deserialize, Debug)]
enum Status {
// Serializes to "active" instead of "Active".
#[serde(rename = "active")]
Active,
// Serializes to "inactive" instead of "Inactive".
#[serde(rename = "inactive")]
Inactive,
}
fn main() {
let status = Status::Active;
// Serialize the enum variant.
// Output is a string because of the default enum representation.
let json = serde_json::to_string(&status).unwrap();
println!("{}", json);
// Output: "active"
}
Real-world patterns
In production code, you rarely rename every field manually. You combine rename with rename_all to handle bulk conventions and exceptions. rename_all applies a transformation to all fields in a struct. rename overrides rename_all for specific fields. This gives you a global rule with targeted exceptions.
use serde::{Deserialize, Serialize};
/// API response with camelCase keys, except for one legacy field.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
// Renamed to 'totalItems' by rename_all.
total_items: u32,
// Override rename_all for this specific field.
// The API sends 'data_payload' instead of 'dataPayload'.
#[serde(rename = "data_payload")]
data: Vec<String>,
}
fn main() {
let response = ApiResponse {
total_items: 42,
data: vec!["item1".to_string(), "item2".to_string()],
};
// Serialize to JSON.
// total_items becomes totalItems.
// data becomes data_payload due to the explicit rename.
let json = serde_json::to_string(&response).unwrap();
println!("{}", json);
// Output: {"totalItems":42,"data_payload":["item1","item2"]}
}
Sometimes the external format is inconsistent. The API might send user_name in one version and username in another. You can accept multiple incoming keys using alias. alias adds secondary incoming keys. The primary key is still determined by rename or the field name. alias only affects deserialization. It does not change the output.
use serde::Deserialize;
/// User struct that accepts multiple key names for username.
#[derive(Deserialize, Debug)]
struct User {
// Primary key is 'user_name'.
// Also accepts 'username' and 'userName' during deserialization.
#[serde(rename = "user_name", alias = "username", alias = "userName")]
username: String,
}
fn main() {
// JSON with the primary key.
let json1 = r#"{"user_name": "alice"}"#;
let user1: User = serde_json::from_str(json1).unwrap();
println!("{:?}", user1);
// Output: User { username: "alice" }
// JSON with an alias key.
let json2 = r#"{"username": "bob"}"#;
let user2: User = serde_json::from_str(json2).unwrap();
println!("{:?}", user2);
// Output: User { username: "bob" }
}
Pitfalls and errors
The biggest trap is assuming rename is one-way. It is not. If you rename id to userId, the JSON must contain userId for deserialization to work. If the API sends id, Serde rejects it. You get a runtime error like missing field 'userId'. This is not a compiler error. The code compiles fine. The crash happens when you parse bad JSON. Test your deserialization with real payloads.
If you forget to derive Serialize or Deserialize, you get a compile error. The compiler rejects you with E0277 (the trait bound Serialize is not satisfied). This happens when you try to pass a struct to serde_json::to_string without the derive. Add #[derive(Serialize)] to fix it.
Serde also validates your attributes at compile time. If you use rename on a field that does not exist, the derive macro fails. You get an error like field 'foo' is not declared in the struct. Check your spelling.
Convention aside: use rename_all for bulk changes and rename for exceptions. Do not rename every field manually if a global rule works. It creates maintenance debt. When the API changes its convention, you have to update every field. With rename_all, you update one line.
Decision: choosing the right attribute
Use #[serde(rename = "...")] when a single field needs a specific key that differs from the Rust name. Use #[serde(rename_all = "...")] when the entire struct follows a consistent naming convention like camelCase or SCREAMING_SNAKE_CASE. Use #[serde(alias = "...")] when the external format might send multiple different keys for the same field, and you need to accept all of them. Use #[serde(skip_serializing_if = "...")] when you want to omit a field from the output based on its value, rather than renaming it. Use #[serde(flatten)] when you want to merge the fields of a nested struct into the parent object's keys.
Serde is a translator, not a magician. It maps keys exactly as you tell it. If the mapping is wrong, the data does not flow. Verify your keys against the actual wire format. Trust the rename. It works both ways.