JSON to Rust: Serde Structs Made Easy
Serde is the de-facto serialization library for Rust, and serde_json is how almost every Rust project parses JSON. The good news: derive macros do most of the work. The annoying part: writing the struct definitions by hand for a 20-field API response with three nested objects burns an hour you'd rather spend on real logic. Here's how the mapping works and where it gets tricky.
JSON to Rust Type Mapping
| JSON Type | Rust Type | Notes |
|---|---|---|
"string" | String | Owned string. Use &str only with explicit lifetimes |
123 | i64 / u64 / i32 | Pick based on range. i64 is the safe default |
1.5 | f64 | All JSON numbers with decimals |
true | bool | |
null | Option | Required for any field that can be null or absent |
[1, 2, 3] | Vec | Owned, growable list |
{} | Custom struct | Nested struct definition |
String (owned) is almost always what you want for deserialized data. Borrowed &str works only if your input outlives the struct, which gets ugly fast with serde. Default to String and switch to borrowed types only after profiling shows allocation pressure.
Basic Struct with Derive Macros
Given this API response:
The serde-compatible struct is:
The two derive macros do all the work. Deserialize lets you parse JSON into this struct, Serialize lets you turn the struct back into JSON. Adding Debug is almost free and makes {:?} formatting work, which you will want the first time something fails to parse.
Parsing is one line:
Renaming Fields for camelCase APIs
Most JSON APIs use camelCase. Rust uses snake_case. Don't rename your struct fields - tell serde to map them:
rename_all = "camelCase" is the most common case. Serde also supports PascalCase, SCREAMING_SNAKE_CASE, kebab-case, and a few others.
For one-off renames, use #[serde(rename = "...")] on the field:
This pattern matters when JSON keys collide with Rust keywords like type, match, or fn.
Nested Structs
JSON objects inside objects become separate Rust structs:
Each nested object needs its own struct definition. Order matters less than you'd think - Rust resolves types after parsing, so define them in whatever order reads best.
Handling Optional Fields
If a field can be null or missing in the JSON, wrap it in Option:
Without Option, missing or null fields cause a parse error. With Option, both null and absence become None.
If you also want serialization to skip None instead of writing "nickname": null:
For fields that should default when absent (instead of becoming None), use #[serde(default)]:
Arrays and Vec
JSON arrays map to Vec:
Vec is the right default. Use a fixed-size array [T; N] only when you know the JSON guarantees the length, which API responses rarely do.
Enums for Tagged Variants
JSON unions and discriminated types map cleanly to Rust enums:
The tag = "type" attribute tells serde to look at the "type" field to pick a variant. This is the internal tagging strategy. Serde supports four tagging styles - external, internal, adjacent, and untagged - depending on how your API encodes the discriminator.
Cargo Dependencies
Add to Cargo.toml:
The derive feature enables the #[derive(Serialize, Deserialize)] macros. Without it, you'd implement the traits manually, which is rarely worth it.
Catching Unknown Fields in Development
By default, serde silently ignores JSON fields that don't exist in your struct. That's usually what you want - APIs add fields over time. But during development, when you've miscopied a field name, silent ignoring hides bugs.
#[serde(deny_unknown_fields)] flips the default:
Now any unexpected field becomes a parse error. Use this on configuration structs where typos should fail loudly. Skip it on API response types where forward compatibility matters.
Generating Structs from JSON
For deeply nested responses with many fields, generating the initial scaffold is the obvious shortcut. Paste your JSON into the JSON to Rust converter and get the full struct hierarchy with derive macros, correct Option wrappers for nullable fields, and Vec for arrays.
If your sample JSON might be malformed, validate it first with the JSON Validator - invalid JSON produces incorrect struct shapes, especially around array vs single-object detection. For complex nested payloads, the JSON Viewer makes it easier to see the structure before generating.
The generated structs are a starting point, not a final answer. You'll usually want to tighten integer types (i32 instead of i64 for known-small values), add #[serde(rename_all = "camelCase")] if your API uses it, and decide which optional fields really need Option versus #[serde(default)]. But the boilerplate is gone in seconds.