JSON to Rust Struct: Generate serde Structs (2026 Guide)
Paste your payload into the JSON to Rust converter and copy the serde struct it generates. That is the whole JSON to Rust struct workflow: nothing to install, nothing uploaded, all done in your browser. It covers the “I need a struct right now” case in seconds.
Generating a struct and generating the right struct are different problems, though. A converter can only guess. And here Rust raises the stakes. In TypeScript a wrong guess degrades to a loose any you can ignore. With serde, a wrong guess fails at deserialization: a float dropped into an i64, an unexpected null in a non-optional field, and serde_json::from_str hands you an Err instead of your data. That is why getting the struct right matters more in Rust than in most languages.
Below is how the inference actually works, the number-typing rule most converters get wrong, when to reach for #[serde(rename)] versus #[serde(rename_all)], and how to handle dates, dynamic keys, and Rust keywords so the output always compiles.
How to convert JSON to Rust
Converting JSON to Rust takes three steps:
- Paste your JSON. Drop an object, array, or raw API response into the input box. Conversion runs instantly and entirely client-side.
- Tune the output. Rename the root struct, toggle the serde derives, add
DebugandClone, or droppubvisibility to match your crate’s style. - Copy or download. Grab the generated Rust with one click and paste it straight into your project.
That is the whole transactional path. If your input is minified or you are not sure it is valid, run it through a JSON formatter first so the converter has clean, well-formed JSON to work from. The rest of this guide explains how to read the output so you can fix the cases a tool can’t infer on its own.
How JSON types map to Rust
Every JSON value has a Rust counterpart, but the mapping is not quite one-to-one:
| JSON value | Rust type |
|---|---|
"text" | String |
42 | i64 (large values u64, beyond that f64) |
3.14, 2e3 | f64 |
true / false | bool |
null | Option<T> (or Option<serde_json::Value>) |
[1, 2, 3] | Vec<T> |
{ ... } | a named struct |
Objects are where the work happens. Take a typical REST payload:
{
"id": 101,
"name": "Ada Lovelace",
"email": "ada@example.com",
"active": true,
"roles": ["admin", "user"]
}
The converter produces a serde-ready struct:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub id: i64,
pub name: String,
pub email: String,
pub active: bool,
pub roles: Vec<String>,
}
Each scalar maps to a primitive, and roles becomes Vec<String>. The one JSON type that does not map cleanly is number: JSON has a single numeric type, while Rust makes you choose between i64, u64, and f64. That choice is the subject of a whole section below, because it is where most generators go wrong.
How the converter infers structs
Four rules cover almost everything you will feed it: one struct per object shape, key-by-key merging for arrays, precise number typing, and idiomatic field names.
Structural inference: one named struct per object
Each distinct object shape becomes its own named struct. Nested objects are not inlined; they get hoisted into separate, referenced definitions:
{ "repo": "serde", "owner": { "login": "dtolnay", "id": 100 } }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub repo: String,
pub owner: Owner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Owner {
pub login: String,
pub id: i64,
}
The nested owner becomes its own Owner struct, referenced by field. Identical shapes are deduplicated, so two fields with the same structure share a single struct instead of producing copies.
Array merging and Option fields
When you pass an array of objects, the converter merges them key by key. A key present in some elements but not others becomes an Option:
{ "users": [{ "id": 1, "nick": "x" }, { "id": 2 }] }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub users: Vec<User>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub nick: Option<String>,
}
id appears in every element, so it stays required. nick is missing from the second user, so it becomes Option<String>. Note what is not here: no #[serde(default)]. serde already treats Option as optional and deserializes a missing key to None, so the attribute would be redundant.
Correct number typing: i64, u64, f64
This is the rule competing tools skip, and it is the one that breaks on real payloads. The generator applies three tiers. An integer maps to i64. If a value exceeds i64::MAX, it promotes to u64. Beyond u64::MAX, it falls back to f64. Any token written with a decimal point or an exponent, like 1.0 or 2e3, maps to f64 regardless of magnitude.
{ "user_id": 12000000000000000000, "balance": 19.99, "retries": 3 }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub user_id: u64,
pub balance: f64,
pub retries: i64,
}
Here user_id is larger than i64::MAX, so it lands in u64 and still round-trips. balance carries a decimal point, so it is f64, not an integer. This matters because serde enforces the type at deserialization. Hand it a struct that types a snowflake or Twitter-style ID as i32 and the value overflows; type a float field as i64 and serde returns an error rather than silently truncating. The three-tier rule is what keeps a generated rust struct from json deserializing instead of panicking on the first large ID.
snake_case fields and #[serde(rename)]
JSON keys are frequently camelCase; idiomatic Rust fields are snake_case. The converter renames the field and adds a #[serde(rename)] that maps it back to the exact JSON key:
{ "login": "octocat", "publicRepos": 15, "followerCount": 9001, "createdAt": "2011-01-25" }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub login: String,
#[serde(rename = "publicRepos")]
pub public_repos: i64,
#[serde(rename = "followerCount")]
pub follower_count: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
}
A key that is already snake_case, like login, gets no rename. The others map cleanly back to the wire format while your Rust code reads naturally.
#[serde(rename)] vs #[serde(rename_all)]
The tool emits a per-field #[serde(rename)] because it always works, even when one payload mixes camelCase, snake_case, and irregular keys in the same object. It never has to reason about a shared convention that may not exist.
When every field in a struct does share one convention, you can collapse those attributes into a single container-level rename. Delete the per-field lines and put one #[serde(rename_all = "camelCase")] on the struct:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Root {
pub login: String,
pub public_repos: i64,
pub follower_count: i64,
pub created_at: String,
}
Both versions deserialize the same JSON. Use per-field rename for mixed or irregular keys; reach for rename_all when a struct is uniform and you want less noise. serde also ships other conventions such as "snake_case", "kebab-case", and "SCREAMING_SNAKE_CASE" for the same attribute.
Rust keywords and non-identifier keys
JSON keys are just strings, so nothing stops an API from sending a key named type or match, both of which are reserved words in Rust. The generator sanitizes these into a legal identifier plus a rename:
{ "type": "user", "match": true, "first-name": "Ada" }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
#[serde(rename = "type")]
pub type_: String,
#[serde(rename = "match")]
pub match_: bool,
#[serde(rename = "first-name")]
pub first_name: String,
}
The trailing-underscore form (type_) is deliberately chosen over a raw identifier like r#type. Raw identifiers cannot express every keyword in every position; self, crate, and super in particular are rejected as raw identifiers, so a sanitize-plus-rename approach is the only one that always compiles. Non-identifier keys such as first-name or a leading-digit 2fa get the same treatment: a valid Rust name and a rename back to the literal JSON key.
Deserializing with serde_json
Once you have the struct, add the dependencies to Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Then it is a single line to parse. This example is complete and compiles as-is:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub id: i64,
pub name: String,
pub email: String,
pub active: bool,
pub roles: Vec<String>,
}
fn main() -> Result<(), serde_json::Error> {
let data = r#"{"id":101,"name":"Ada Lovelace","email":"a@ex.com","active":true,"roles":["admin"]}"#;
let root: Root = serde_json::from_str(data)?;
println!("{} has {} role(s)", root.name, root.roles.len());
Ok(())
}
The Deserialize derive does all the work. Use serde_json::from_str for a string, from_slice for a byte slice, or from_reader for a file or HTTP body, and serde_json::to_string to serialize a value back to JSON. This is the payoff for getting the types right: the serde derive you generated is a runtime validator, so a serde_json deserialize call that returns Ok is a guarantee the data matched your struct, not just a compiler’s belief that it did.
Dates, dynamic keys, and unknown fields
Some shapes don’t map to a plain struct field, and the generator hands them a sensible default you then tighten by hand.
Dates. JSON has no date type, so an ISO or RFC 3339 timestamp arrives as a plain String. For real date handling, switch the field to a chrono type and enable chrono’s serde feature (chrono = { version = "0.4", features = ["serde"] }). serde then parses RFC 3339 automatically:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub name: String,
pub created_at: DateTime<Utc>,
}
Dynamic keys. When keys vary at runtime, like a map of IDs to values, a fixed struct is the wrong shape. Use a HashMap keyed by String:
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub scores: HashMap<String, i64>,
}
Unknown extra fields. To keep a typed struct but still capture keys you did not model, add a #[serde(flatten)] field backed by a HashMap. For a value that is genuinely dynamic, serde_json::Value is the catch-all. When your real need is validating structure rather than generating types, the JSON Schema validation guide covers enforcing a contract end to end.
json-to-rust vs quicktype vs typeshare vs manual
There is no single best way to produce Rust types from JSON. It depends on where the JSON lives and which direction you are converting.
| Approach | Best for | Note |
|---|---|---|
| Online converter (this tool, transform.tools) | One-off conversions, sensitive payloads, zero install | Infers from a sample, fully client-side |
| quicktype | Multi-language output, pipeline codegen | Also sample-driven; CLI or web |
| typeshare | Sharing existing Rust types with TS, Swift, Kotlin | Opposite direction: Rust to other languages |
| schemafy / typify | Generating from a JSON Schema | Input is a schema, not a sample |
| Writing structs by hand | Tiny payloads, learning serde | Full control, tedious and error-prone at scale |
The distinction worth internalizing: an online converter and quicktype both infer types from a sample of JSON, while typeshare runs the other way, turning annotated Rust types into TypeScript or Swift. schemafy and typify take a JSON Schema as input, not example data. If your codebase is TypeScript rather than Rust, the same sample-driven approach applies with the JSON to TypeScript converter, and the JSON to TypeScript interface guide covers that side in depth.
Common pitfalls when generating Rust from JSON
Generated structs are a starting point. Watch for these before you trust the output on live data.
- A single sample rarely reveals every shape. Optional fields can only be inferred from multiple array elements. Paste a representative array so the
Optioninference is accurate rather than a guess from one lucky object. - Empty or mixed arrays fall back to
serde_json::Value. That is the honest answer with nothing to infer from. Feed a richer sample to get a concrete element type. - Do not narrow a large ID to
i32. Snowflake and similar IDs exceed 2^53 and overflow a 32-bit field. Keep the generatedi64oru64. - An always-
nullfield becomesOption<serde_json::Value>, not a plain optional. The tool has no type to infer, so treat it as a placeholder and give it a real type once you know one. serde_json::Valueneeds theserde_jsondependency. If the output containsValueand yourCargo.tomllacks the crate, the code will not compile.- A date left as
Stringwon’t do date math. If you plan to compare or format timestamps, switch to a chrono type instead of parsing strings by hand.
Frequently asked questions
Why does serde reject my JSON even when the struct looks right?
Almost always a type mismatch. The two usual causes are a floating-point value landing in an i64 field, and a key that is sometimes absent but not typed as Option. serde enforces the type at deserialization, so fix the field to f64 or Option<T>, or regenerate the struct from a representative sample.
What Rust number type should a JSON integer become?
Default to i64. Promote to u64 when a value exceeds i64::MAX, and fall back to f64 past u64::MAX. Any number written with a decimal point or exponent maps to f64 regardless of size, because serde rejects a float deserialized into an integer field.
How do I make a field optional when JSON omits it?
Type it as Option<T>. serde treats Option as optional automatically and deserializes a missing key to None, so you do not need #[serde(default)]. The converter marks a field Option when some sampled items lack the key.
Should I use #[serde(rename)] or #[serde(rename_all)]?
Use per-field #[serde(rename)] when a payload mixes naming styles; it always works. If every field in a struct follows one convention, delete the per-field attributes and put a single #[serde(rename_all = "camelCase")] on the struct instead. Both deserialize identically.
How do I handle JSON keys that are Rust keywords like type?
The generator emits type_ with a #[serde(rename = "type")] mapping back to the original key. That is more robust than a raw identifier such as r#type, because raw identifiers cannot cover self, crate, and super in every position.
Why are JSON dates typed as String instead of a date type?
JSON has no date type, so a timestamp is just a string on the wire, and String is the honest default. For real date handling, change the field to chrono’s DateTime<Utc> or NaiveDate and enable chrono’s serde feature; serde then parses RFC 3339 for you.
How do I deserialize JSON into the generated struct?
Add serde_json to Cargo.toml, then write let root: Root = serde_json::from_str(json)?;. The Deserialize derive handles the rest. Use from_slice for a byte slice and from_reader for a file or HTTP body.
Is my JSON private when using an online JSON to Rust struct converter?
Yes. Conversion runs 100% in your browser with JavaScript. Your JSON, including tokens, IDs, and customer data, never leaves the page and is never sent to a server. When you are ready, try the JSON to Rust converter on your own payload.