Skip to content
Back to Blog
By JSONConvert Team··7 min read

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 TypeRust TypeNotes
"string"StringOwned string. Use &str only with explicit lifetimes
123i64 / u64 / i32Pick based on range. i64 is the safe default
1.5f64All JSON numbers with decimals
truebool
nullOptionRequired for any field that can be null or absent
[1, 2, 3]VecOwned, growable list
{}Custom structNested 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.

Related Tools