Why Auto-Generated Rust Structs Fail in Production: 5 Serde Deserialization Traps

rust dev.to

When integrating third-party REST APIs or microservices in Rust, writing boilerplate struct definitions for complex JSON payloads is tedious. Most developers copy a representative JSON payload and pass it through a generator or write initial structs by hand.

At first, serde_json::from_str::<MyPayload>(&body) works flawlessly in local tests. But once real-world traffic hits production, deserialization errors begin popping up. Dynamic schemas, unexpected null values, reserved keyword clashes, and number precision quirks quickly break naive Serde structs.

Here are 5 common deserialization edge cases in Rust and how to harden your Serde structs against them.


1. Reserved Keywords in Field Names

JSON payloads often contain keys like "type", "match", "ref", "fn", or "box". Because these are reserved keywords in Rust, naive struct generation will either produce syntax errors or fail compilation:

// ❌ Compilation Error: `type` is a reserved keyword
#[derive(Debug, Deserialize, Serialize)]
pub struct Event {
    pub id: String,
    pub type: String, 
}
Enter fullscreen mode Exit fullscreen mode

There are two clean ways to solve this in Rust:

Option A: Raw Identifiers (r#)

#[derive(Debug, Deserialize, Serialize)]
pub struct Event {
    pub id: String,
    pub r#type: String,
}
Enter fullscreen mode Exit fullscreen mode

Option B: Serde Field Rename

#[derive(Debug, Deserialize, Serialize)]
pub struct Event {
    pub id: String,
    #[serde(rename = "type")]
    pub event_type: String,
}
Enter fullscreen mode Exit fullscreen mode

Field renames are generally preferred because event_type is more descriptive throughout your domain logic than r#type.


2. Missing Fields vs. Explicit null

In JSON, a field can be:

  1. Present with a value: {"bio": "Software Engineer"}
  2. Explicitly null: {"bio": null}
  3. Completely omitted: {}

If you define a field as pub bio: Option<String>, Serde handles case 1 and case 2. However, if an upstream API omits the key entirely, deserialization will fail unless you mark it with #[serde(default)]:

#[derive(Debug, Deserialize, Serialize)]
pub struct UserProfile {
    pub username: String,

    // Handles present nulls AND omitted keys
    #[serde(default)]
    pub bio: Option<String>,
}
Enter fullscreen mode Exit fullscreen mode

If you omit #[serde(default)] on non-optional fields with default fallbacks, your parser will reject valid partial payloads.


3. Floating-Point Drift and Large Integer Truncation

JavaScript and standard JSON parsers treat all numbers as IEEE 754 double-precision floats (f64). When dealing with 64-bit integer IDs (like Snowflake IDs or database sequence keys):

{"transaction_id":9223372036854775807,"balance":"1250.50"}
Enter fullscreen mode Exit fullscreen mode

If an auto-generator infers f64 for numerical fields, large integers lose precision beyond $2^{53} - 1$ (9,007,199,254,740,991). Always map large IDs to u64 or i64.

If the upstream API inconsistently sends numbers as both strings and integers (e.g., "100" in some endpoints and 100 in others), use serde_aux or a custom deserializer:

use serde_aux::field_attributes::deserialize_number_from_string;

#[derive(Debug, Deserialize)]
pub struct Order {
    #[serde(deserialize_with = "deserialize_number_from_string")]
    pub item_count: u32,
}
Enter fullscreen mode Exit fullscreen mode

When scaffolding complex nested structs with dozens of fields, you can use browser-based tools like Nutilz JSON to Rust to quickly generate base structs with proper casing conversions (snake_case), optionality flags, and derive attributes before applying custom validation rules.


4. Untagged Enums for Heterogeneous Payloads

Webhooks and event streams frequently return polymorphism in a single field. For example, a metadata field might be a string ID in version 1, but an object in version 2:

//EventA{"payload":"user_created_v1"}//EventB{"payload":{"version":2,"actor_id":"usr_99"}}
Enter fullscreen mode Exit fullscreen mode

Rust does not allow union types in struct fields, but Serde provides untagged enums:

#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum PayloadData {
    Legacy(String),
    Structured {
        version: u8,
        actor_id: String,
    },
}

#[derive(Debug, Deserialize, Serialize)]
pub struct WebhookEvent {
    pub payload: PayloadData,
}
Enter fullscreen mode Exit fullscreen mode

Serde will attempt to deserialize variants sequentially from top to bottom until one succeeds without error.


5. Casing Inconsistencies (snake_case vs camelCase)

Most backend APIs emit camelCase or kebab-case JSON, while Rust convention mandates snake_case field names. Rather than annotating every single field with #[serde(rename = "...")], apply container-level attributes:

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDetails {
    pub first_name: String,
    pub last_name: String,
    pub created_at: String,

    // You can still override individual edge cases
    #[serde(rename = "IP_Address")]
    pub ip_address: String,
}
Enter fullscreen mode Exit fullscreen mode

Summary Checklist for Production Serde Structs

Before shipping your deserialization layer to production:

  • [ ] Are reserved keywords renamed or escaped with r#?
  • [ ] Are optional and nullable fields annotated with Option<T> and #[serde(default)]?
  • [ ] Are large integer IDs typed as u64/i64 rather than f64?
  • [ ] Is #[serde(rename_all = "...")] configured on container structs to match API conventions?
  • [ ] Are polymorphic responses mapped to #[serde(untagged)] or tagged enums?

When converting large API specifications or raw JSON responses into Rust models, scaffolding your structs with Nutilz JSON to Rust Converter gives you a solid starting point with idiomatic naming and derive macros ready for production tuning.

Source: dev.to

arrow_back Back to Tutorials