Skip to content
Back to Blog
Tutorials

JSON to Rust Struct: Generate serde Structs (2026 Guide)

Convert JSON to Rust structs the right way: number types (i64/u64/f64), Option for nulls, serde rename for camelCase, plus pitfalls to avoid. Try it free.

11 min read

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:

  1. Paste your JSON. Drop an object, array, or raw API response into the input box. Conversion runs instantly and entirely client-side.
  2. Tune the output. Rename the root struct, toggle the serde derives, add Debug and Clone, or drop pub visibility to match your crate’s style.
  3. 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 valueRust type
"text"String
42i64 (large values u64, beyond that f64)
3.14, 2e3f64
true / falsebool
nullOption<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.

ApproachBest forNote
Online converter (this tool, transform.tools)One-off conversions, sensitive payloads, zero installInfers from a sample, fully client-side
quicktypeMulti-language output, pipeline codegenAlso sample-driven; CLI or web
typeshareSharing existing Rust types with TS, Swift, KotlinOpposite direction: Rust to other languages
schemafy / typifyGenerating from a JSON SchemaInput is a schema, not a sample
Writing structs by handTiny payloads, learning serdeFull 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 Option inference 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 generated i64 or u64.
  • An always-null field becomes Option<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::Value needs the serde_json dependency. If the output contains Value and your Cargo.toml lacks the crate, the code will not compile.
  • A date left as String won’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.

Tags: rust json serde type-safety developer-tools

Related Articles

View all articles