Working with JSON in Rust: A Practical serde Deserialization Guide

4 min read

Practice course Part 3 briefly used serde to accept JSON Lines logs. This article widens that serde to working breadth — a pattern collection that holds anywhere Rust meets JSON: API responses, configuration files, data pipelines.

The basic form: one derive line #

Install
cargo add serde --features derive
cargo add serde_json
src/main.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    email: String,
    active: bool,
}

let json = r#"{"id": 7, "email": "kim@example.com", "active": true}"#;
let user: User = serde_json::from_str(json)?;         // JSON → struct
let back = serde_json::to_string_pretty(&user)?;      // struct → JSON

Deserialize reads, Serialize writes. The core value of this approach is that type validation finishes at parse time. If id arrives as a string or email is missing, from_str returns Err — so a User that passed this line is a complete value everywhere downstream. The defensive code of Python’s json.loads — receive a dict, then check key existence one by one — disappears wholesale.

Real-world JSON 1: fields that may be absent — Option and default #

Real API responses have fields that come and go. There are two tools, with different meanings.

src/main.rs
#[derive(Debug, Deserialize)]
struct Profile {
    email: String,
    nickname: Option<String>,     // absent → None — keep "absence" as data
    #[serde(default)]
    retry_limit: u32,             // absent → 0 (the type's default)
    #[serde(default = "default_timeout")]
    timeout_secs: u64,            // absent → 30
}

fn default_timeout() -> u64 { 30 }

If absence is itself meaningful information, keep it as Option; if filling in a fixed value is fine, use default. Config-file structs are usually best filled with the default family for usability, while API responses are safer received as Option with absence handled explicitly.

Real-world JSON 2: different naming conventions — rename_all #

The JSON world is camelCase; Rust is snake_case. Declare the rule once instead of a rename per field.

src/main.rs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
    user_id: u64,        // maps to "userId" in JSON
    created_at: String,  // maps to "createdAt" in JSON
}

Conversely, for internal system-to-system traffic where typos and schema drift should surface fast, #[serde(deny_unknown_fields)] tightens things to “unknown field → error.” Not recommended against external APIs — it breaks every time the other side adds a field.

When the structure is unknown: feeling around with Value #

For the exploration phase before a schema exists, or half-free-form JSON, receive serde_json::Value.

src/main.rs
let v: serde_json::Value = serde_json::from_str(json)?;
let city = v["address"]["city"].as_str().unwrap_or("unknown");

Value is an enum holding any JSON, navigated with index access and the as_str / as_u64 family. The convenience costs the type guarantee: validation scatters from parse time to use time. Explore with Value; promote to structs once the code settles is the healthy order. As a compromise, the mixed form — a struct for most fields with only the free-form part left as metadata: Value — is common too.

JSON in several shapes: receiving as an enum #

Event streams — “different fields depending on kind” — map exactly onto Rust enums.

src/main.rs
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
    UserCreated { id: u64, email: String },
    UserDeleted { id: u64 },
}

// {"type": "user_created", "id": 7, "email": "kim@example.com"} → Event::UserCreated

tag = "type" is internal tagging — “pick the variant by the type field” — the de facto standard shape for event and message JSON. For responses with no tag field at all (data on success, an error object on failure), #[serde(untagged)] tries variants in order by shape. untagged is convenient, but its failure message dulls to “no variant matched,” so when a tag is possible by design, tagging comes first. Handling after receipt is exactly the structure from basics Part 5: match enforces handling of every variant.

How to read the errors #

src/main.rs
let result: Result<User, _> = serde_json::from_str(broken);
if let Err(e) = result {
    eprintln!("{e}"); // invalid type: string "7", expected u64 at line 1 column 9
}

serde_json’s errors carry line and column positions along with what went wrong. In large-file processing that position information translates directly into debugging time, so the standard move is not to swallow errors but to lift them upward on anyhow’s context. An error report that reaches “which line, which field, why” is the quality of the JSON pipeline itself.

Summary #

  • The basic form is derive(Serialize, Deserialize) plus from_str / to_string. A struct that passed parsing is a complete value for all downstream code.
  • Distinguish absent fields by meaning: absence-as-information is Option; fill-and-forget is default.
  • Naming differences are one line of rename_all = "camelCase"; internal traffic can be tightened with deny_unknown_fields.
  • Explore with Value, promote to structs when the code settles. Leaving just the free-form part as Value is a practical hybrid.
  • JSON that varies by kind is standardly a tagged enum (tag = "type"); untagged is the fallback when no tag exists.
  • Errors carry line and column. Lifting them with context until the report says “where and why” — that is the practical finish line.
X