Skip to content
datahacks.dev
JSON

Reading JSON in Rust With serde json and Typed Structs

Read JSON in Rust with serde_json 1.0.154: derived structs, Option and null, deny_unknown_fields, duplicates, integer ranges, the 128 level limit and enums.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

In Rust, JSON means serde and serde_json. You derive Deserialize on a type, call from_str, and get either a value or an error that says where it went wrong. The compiler enforces the shape on the Rust side, and the library enforces it on the data side. What you get from the defaults is stricter than most languages' JSON libraries in some places and looser in others.

I wrote one program that reads an order document and tries each edge. The results are in the order I ran them.

Derive and read

Rust
#[derive(Debug, Deserialize, Serialize)]
struct Order {
    id: i64,
    customer: Customer,
    items: Vec<Item>,
    status: String,
    extra: Value,
    created_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    note: Option<String>,
}

serde_json::from_str::<Order>(&data) read the document: id was 9007199254740993 in an i64, the customer's email was None, there were two items, and extra was a serde_json::Value holding {"gift":true,"wrap":"blue"}. A Value is the escape hatch for the part of the document you do not want to model. note is an Option and is absent in the data, and Serde treats a missing Option field as None without an attribute.

Writing it back gave the same text with one change: the second item's price went from 12 to 12.0. The field is an f64, and Serde writes floats with a decimal point. If the original number was an integer, the round trip does not preserve its spelling. Use Value or the arbitrary_precision feature when the digits matter, and an integer type when the field is an integer.

Timestamps are strings here. For real dates use the chrono or time crates with their Serde support, which I did not run in this test.

Errors are exact

Every failure came with a message, and a line and column:

Text
unknown field `surprise`, expected `status` at line 1 column 27
missing field `status` at line 1 column 2
invalid type: string "x", expected i32 at line 1 column 25
invalid value: integer `3000000000`, expected i32 at line 1 column 32
invalid type: floating point `1.5`, expected i32 at line 1 column 25
invalid value: integer `-1`, expected u32 at line 1 column 19

The message says what arrived and what was expected, which is easier to turn into a 400 response than most. Integer ranges are enforced: 3,000,000,000 does not fit an i32, 1.5 is not an integer, and a negative number is not a u32. There is no silent truncation or coercion. A string "5" is not a number either; Serde does not convert between them unless you ask with a helper.

serde_json::Error has methods line(), column() and classify() (Syntax, Data, Eof, Io), so the handler can tell a malformed document from a valid one with the wrong shape.

Unknown, case and duplicates

Unknown keys are ignored by default, as in Go. To reject them add an attribute:

Rust
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Strict { status: String }

{"status":"paid","surprise":1} returned unknown field surprise, expected status. Use it for configuration and request bodies. Leave it off for consumers of other people's data (handling unknown JSON fields in Go goes through the same decision). A caveat: deny_unknown_fields does not work together with #[serde(flatten)], which the documentation notes.

Key matching is exact. {"STATUS":"a"} returned missing field status, which is the opposite of Go. If you must accept other spellings, use #[serde(alias = "...")] for each.

Duplicates depend on the target. A struct rejected them:

Text
duplicate field `status` at line 1 column 22

A Value did not: {"role":"user","role":"admin"} became {"role":"admin"}. So the same parser gives an error on a typed read and a silent last-wins on a dynamic one. If a body makes a security decision, read it into a struct, or check duplicates before building a Value (JSON security mistakes in apps and pipelines).

Null versus missing

An Option<T> field is None when the key is absent and also when it is null. For PATCH-style input that distinction matters, and the usual answer is a nested Option with a custom deserializer:

Rust
fn double_option<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
where T: Deserialize<'de>, D: Deserializer<'de> {
    Ok(Some(Option::deserialize(d)?))
}
#[serde(default, deserialize_with = "double_option")] n: Option<Option<i32>>

With that field, the three inputs read as None (key missing), Some(None) (explicit null) and Some(Some(5)) (a value). It is a few lines, and it is the answer to the question in partial updates and patch semantics.

Dynamic reads with Value

Rust
let v: Value = serde_json::from_str(&data)?;
v["id"]                                    // 9007199254740993
v["id"].is_i64()                           // true
v["id"].as_f64()                           // Some(9007199254740992.0)
v.pointer("/customer/email").unwrap().is_null()   // true
v["nope"]                                  // null  (indexing a missing key is not a panic)
v.get("nope")                              // None

Indexing a missing key on a Value gives Value::Null and not a panic, and get returns an Option, so the missing case is explicit. The pointer method takes an RFC 6901 JSON Pointer, which is the tool for deep paths. A number that fits u64 stayed exact: 18446744073709551615 read back as Some(18446744073709551615), while 18446744073709551616 became a floating Number(1.8446744073709552e19). Without the arbitrary_precision feature a number beyond 64 bits is rounded.

Limits and strictness

The parser is strict about syntax: NaN returned expected value at line 1 column 1, a trailing comma returned trailing comma at line 1 column 8, and a second value after the first returned trailing characters at line 1 column 9. A document nested 200 levels deep returned recursion limit exceeded at line 1 column 128, and 127 levels parsed, so the limit is 128. That is a built-in guard against stack overflow (denial of service through deeply nested JSON), and it can be disabled only with Deserializer::disable_recursion_limit, which is meant for trusted input and needs the unbounded_depth feature.

There is no size limit, so cap the body before it reaches the parser (limiting depth and size of incoming JSON).

Tagged enums for variants

Rust
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum Event {
    #[serde(rename = "paid")] Paid { total: f64 },
    #[serde(rename = "refunded")] Refunded { total: f64, reason: String },
}

{"type":"paid","total":5.5} read as Paid { total: 5.5 }, and {"type":"gold","total":5} returned unknown variant gold, expected paid or refunded at line 1 column 14. That is the clearest way I know to model "an event with a type" and it makes the unknown case an explicit error (event payloads in JSON and schema evolution).

The JSON to Rust converter generates structs from a sample. The reference is the serde documentation and the serde_json crate docs.

When a typed struct is more ceremony than you need

A one-off script. To pull a single value, parse into Value and use pointer. You get the answer in three lines.

The shape is open-ended. A document whose keys are data, or one that you only pass through, belongs in a Value or a HashMap<String, Value>.

You need to preserve the text. If exact number spelling or key order matter on the way back out, use serde_json::value::RawValue for the parts you pass through, or the preserve_order feature for key order.

Performance is the constraint. serde_json is fast, and faster crates exist for specific cases. I did not benchmark them here, so measure on your data before switching.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.