DevToolBoxFREE
BlogAdvertise

JSON to Rust Struct: Complete Guide with serde_json and serde Derive

11 minutesโดย DevToolBox

JSON เป็น Rust Struct: คู่มือสมบูรณ์กับ Serde

แปลง JSON เป็น Rust structs ด้วย Serde derive macros, custom attributes และการแปลงที่ปลอดภัยด้านประเภท

TL;DR

Add serde = { version: "1", features: ["derive"] } and serde_json = "1" to Cargo.toml. Annotate your structs with #[derive(Serialize, Deserialize)]. Use #[serde(rename_all = "camelCase")] for JavaScript APIs, Option<T> for nullable fields, and serde_json::Value for fully dynamic JSON.

1. Why Use Serde for JSON in Rust

Rust has several JSON libraries, but Serde with serde_json is the ecosystem standard. Here is how the main options compare:

CratePerformanceEase of UseFeaturesEcosystem Support
serde_jsonVery fastExcellent (derive)Full: rename, skip, flatten, tagDominant standard
json crateModerateGood (no derive)Basic value parsingMinimal, niche use
simd-jsonFastest (SIMD)Good (serde compat)Mostly serde_json APIGrowing, x86/x64 only

For most Rust projects, serde_json is the right choice. It integrates with the entire Rust ecosystem — Axum, Actix-web, Reqwest, SQLx, and hundreds of other crates all use Serde as their serialization layer.

2. Basic Serde Setup

Start by adding the dependencies to your Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

The features = ["derive"] flag enables the procedural macros. Now define a struct and derive both traits:

use serde::{Deserialize, Serialize};

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

fn main() -> Result<(), serde_json::Error> {
    // Deserialize: JSON string -> Rust struct
    let json = r#"{
        "id": 1,
        "name": "Alice",
        "email": "alice@example.com",
        "age": 30,
        "active": true
    }"#;

    let user: User = serde_json::from_str(json)?;
    println!("Name: {}", user.name);

    // Serialize: Rust struct -> JSON string
    let json_out = serde_json::to_string_pretty(&user)?;
    println!("{}", json_out);

    // from_slice works on bytes instead of &str
    let bytes = json.as_bytes();
    let user2: User = serde_json::from_slice(bytes)?;
    println!("Age: {}", user2.age);

    Ok(())
}

3. Field Renaming and Attributes

Serde provides a rich set of field-level and struct-level attributes for controlling serialization behavior:

use serde::{Deserialize, Serialize};

// rename_all: convert all fields to camelCase
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
    user_id: u64,       // JSON key: "userId"
    first_name: String, // JSON key: "firstName"
    last_name: String,  // JSON key: "lastName"
    created_at: String, // JSON key: "createdAt"
}

#[derive(Debug, Serialize, Deserialize)]
struct Product {
    // Rename a single field
    #[serde(rename = "product_id")]
    id: u64,

    // Skip this field entirely (not serialized or deserialized)
    #[serde(skip)]
    internal_cache: Option<String>,

    // Use Default::default() when field is absent from JSON
    #[serde(default)]
    discount: f64,

    // Skip serializing if the value is None
    #[serde(skip_serializing_if = "Option::is_none")]
    coupon_code: Option<String>,

    // Skip serializing if condition is met
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
}

// Supported rename_all strategies:
// "camelCase"          → userId
// "PascalCase"         → UserId
// "snake_case"         → user_id
// "SCREAMING_SNAKE_CASE" → USER_ID
// "kebab-case"         → user-id
// "SCREAMING-KEBAB-CASE" → USER-ID

4. Optional and Default Fields

Handling nullable and missing fields is one of the most common JSON parsing challenges. Serde provides several tools:

use serde::{Deserialize, Serialize};

fn default_page_size() -> usize { 20 }
fn default_active() -> bool { true }

#[derive(Debug, Serialize, Deserialize)]
struct UserProfile {
    id: u64,
    name: String,

    // Nullable: field can be null or a string
    // None serializes as JSON null
    avatar_url: Option<String>,

    // Missing field → uses Default::default() → 0
    #[serde(default)]
    login_count: u32,

    // Missing field → calls default_page_size() → 20
    #[serde(default = "default_page_size")]
    page_size: usize,

    // Missing or null → true
    #[serde(default = "default_active")]
    is_active: bool,

    // Omit from serialized output when None
    #[serde(skip_serializing_if = "Option::is_none")]
    bio: Option<String>,
}

// Difference: null vs absent
// Option<T>: field present as null → Some? NO → None
//            field absent          → None
//            field present as value → Some(value)
//
// For truly three-state (absent / null / value):
#[derive(Debug, Serialize, Deserialize)]
struct Patch {
    // None = absent (not updated), Some(None) = set to null, Some(Some(v)) = set value
    #[serde(default, skip_serializing_if = "Option::is_none")]
    email: Option<Option<String>>,
}

5. Nested Structs and Arrays

Serde handles nested structures and arrays automatically through Rust's type system:

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Serialize, Deserialize)]
struct Address {
    street: String,
    city: String,
    country: String,
    zip_code: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct Tag {
    id: u32,
    name: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BlogPost {
    id: u64,
    title: String,
    author: Author,               // nested struct
    tags: Vec<Tag>,               // array of structs
    images: Vec<String>,          // array of strings
    metadata: Option<PostMeta>,   // optional nested
    // HashMap for dynamic keys: {"en": "Hello", "es": "Hola"}
    translations: HashMap<String, String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct Author {
    id: u64,
    name: String,
    address: Option<Address>,
}

#[derive(Debug, Serialize, Deserialize)]
struct PostMeta {
    views: u64,
    likes: u32,
}

// Nested arrays
#[derive(Debug, Serialize, Deserialize)]
struct Matrix {
    data: Vec<Vec<f64>>,           // [[1.0, 2.0], [3.0, 4.0]]
}

// HashMap with struct values for dynamic objects
#[derive(Debug, Serialize, Deserialize)]
struct Config {
    settings: HashMap<String, serde_json::Value>,  // arbitrary values
}

6. Enums with Serde

Serde supports four enum representation modes, giving you full control over how discriminated unions appear in JSON:

use serde::{Deserialize, Serialize};

// 1. External tagging (default)
// {"Circle": {"radius": 1.0}}
#[derive(Debug, Serialize, Deserialize)]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Point,
}

// 2. Internal tagging: {"type": "Login", "user_id": 42}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
    Login { user_id: u64, timestamp: String },
    Logout { user_id: u64 },
    Purchase { user_id: u64, amount: f64 },
}

// 3. Adjacent tagging: {"type": "Email", "data": {...}}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Notification {
    Email { to: String, subject: String },
    Push { device_token: String, message: String },
}

// 4. Untagged: tries each variant in order
// Useful for union types from external APIs
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum StringOrNumber {
    Text(String),
    Integer(i64),
    Float(f64),
}

// Simple string enums map to JSON strings
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Status {
    Active,    // "active"
    Inactive,  // "inactive"
    Pending,   // "pending"
}

7. Custom Deserialize with deserialize_with

For special types like dates, use deserialize_with and serialize_with to plug in custom functions:

use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::de::Error;

// Custom date handling with chrono
// Cargo.toml: chrono = { version = "0.4", features = ["serde"] }
use chrono::{DateTime, Utc, NaiveDate};

fn deserialize_date<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(D::Error::custom)
}

fn serialize_date<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(&date.format("%Y-%m-%d").to_string())
}

#[derive(Debug, Serialize, Deserialize)]
struct Event {
    name: String,

    #[serde(
        deserialize_with = "deserialize_date",
        serialize_with = "serialize_date"
    )]
    date: NaiveDate,

    // chrono's built-in serde support handles RFC 3339
    created_at: DateTime<Utc>,
}

// Implementing Deserialize manually for complex cases
use serde::de::{self, Visitor, MapAccess};
use std::fmt;

struct PointVisitor;

impl<'de> Visitor<'de> for PointVisitor {
    type Value = (f64, f64);

    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        formatter.write_str("an array [x, y] or object {x, y}")
    }

    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
    where
        A: serde::de::SeqAccess<'de>,
    {
        let x = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?;
        let y = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(1, &self))?;
        Ok((x, y))
    }
}

8. serde_json::Value for Dynamic JSON

When the JSON structure is unknown at compile time, use serde_json::Value:

use serde_json::{Value, json};

fn process_dynamic_json(json_str: &str) -> Result<(), serde_json::Error> {
    let v: Value = serde_json::from_str(json_str)?;

    // Navigate with bracket indexing (returns &Value::Null if missing)
    let name = &v["user"]["name"];
    println!("{}", name); // "Alice" or null

    // Type-safe access with as_ methods
    if let Some(id) = v["user"]["id"].as_i64() {
        println!("ID: {}", id);
    }
    if let Some(email) = v["user"]["email"].as_str() {
        println!("Email: {}", email);
    }
    if let Some(active) = v["user"]["active"].as_bool() {
        println!("Active: {}", active);
    }

    // Safer access with get()
    if let Some(user) = v.get("user") {
        if let Some(tags) = user.get("tags") {
            if let Some(arr) = tags.as_array() {
                for tag in arr {
                    println!("Tag: {}", tag.as_str().unwrap_or("unknown"));
                }
            }
        }
    }

    // Build JSON with the json! macro
    let response = json!({
        "status": "ok",
        "count": 42,
        "items": ["a", "b", "c"],
        "meta": {
            "page": 1,
            "per_page": 20
        }
    });
    println!("{}", serde_json::to_string_pretty(&response)?);

    Ok(())
}

// Mix: typed struct with a dynamic field
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct ApiResponse {
    status: String,
    code: u32,
    // This field can be any JSON value
    data: Value,
}

9. Error Handling

serde_json returns a serde_json::Error which tells you what went wrong and where:

use serde::{Deserialize, Serialize};
use serde_json::Error;

#[derive(Debug, Deserialize)]
struct Config {
    host: String,
    port: u16,
    debug: bool,
}

fn parse_config(input: &str) -> Result<Config, Error> {
    // from_str: parse &str
    serde_json::from_str(input)
}

fn parse_config_bytes(input: &[u8]) -> Result<Config, Error> {
    // from_slice: parse &[u8] (avoids UTF-8 validation overhead)
    serde_json::from_slice(input)
}

fn parse_config_reader<R: std::io::Read>(reader: R) -> Result<Config, Error> {
    // from_reader: streaming parse (ideal for files/network)
    serde_json::from_reader(reader)
}

fn main() {
    let bad_json = r#"{"host": "localhost", "port": "not-a-number"}"#;

    match serde_json::from_str::<Config>(bad_json) {
        Ok(config) => println!("Parsed: {:?}", config),
        Err(e) => {
            eprintln!("Parse error: {}", e);
            eprintln!("Line: {}, Column: {}", e.line(), e.column());
            // Check error category
            if e.is_data() {
                eprintln!("Type mismatch or constraint violation");
            } else if e.is_syntax() {
                eprintln!("Invalid JSON syntax");
            } else if e.is_eof() {
                eprintln!("Unexpected end of input");
            }
        }
    }

    // Use anyhow or thiserror in production for better error propagation
    // anyhow::Result<Config> = serde_json::from_str(json).context("parsing config")?;
}

10. Performance Tips

Serde is already very fast, but these techniques maximize throughput for performance-critical paths:

// 1. Use from_reader for large files — avoids loading everything into memory
use std::fs::File;
use std::io::BufReader;

fn load_large_json<T: serde::de::DeserializeOwned>(path: &str) -> serde_json::Result<T> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);  // BufReader is crucial for performance
    serde_json::from_reader(reader)
}

// 2. simd-json for maximum throughput (x86/ARM SIMD acceleration)
// Cargo.toml: simd-json = { version = "0.13", features = ["serde_impl"] }
// simd-json requires &mut [u8] (modifies buffer in place)
// use simd_json;
// let mut buffer = json_bytes.to_vec();
// let value: MyStruct = simd_json::from_slice(&mut buffer)?;

// 3. Avoid serde_json::Value when struct type is known
// Value allocates a heap Map<String, Value> — much slower than typed parsing
// BAD:
// let v: Value = serde_json::from_str(json)?;
// let name = v["name"].as_str();
// GOOD:
// let user: User = serde_json::from_str(json)?;
// let name = &user.name;

// 4. Use &str instead of String for zero-copy deserialization
// The lifetime is tied to the input JSON string
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct ZeroCopy<'a> {
    #[serde(borrow)]
    name: &'a str,    // No allocation — borrows from input
    #[serde(borrow)]
    email: &'a str,
}

// 5. Serialize to writer directly instead of String
fn write_json_to_writer<W: std::io::Write>(
    value: &impl serde::Serialize,
    writer: W,
) -> serde_json::Result<()> {
    serde_json::to_writer(writer, value)
    // or serde_json::to_writer_pretty(writer, value)
}

11. Axum and Actix-web Integration

Both major Rust web frameworks use serde_json under the hood, making JSON handling seamless:

// === Axum ===
// Cargo.toml: axum = { version = "0.7", features = ["json"] }
use axum::{
    extract::Json,
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
struct CreateUserRequest {
    name: String,
    email: String,
}

#[derive(Debug, Serialize)]
struct UserResponse {
    id: u64,
    name: String,
    email: String,
    created_at: String,
}

// Json extractor deserializes body; Json return serializes response
async fn create_user(
    Json(payload): Json<CreateUserRequest>,
) -> Json<UserResponse> {
    Json(UserResponse {
        id: 1,
        name: payload.name,
        email: payload.email,
        created_at: "2026-02-27T00:00:00Z".to_string(),
    })
}

// Error handling with axum
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};

async fn safe_create_user(
    Json(payload): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, (StatusCode, String)> {
    if payload.email.is_empty() {
        return Err((StatusCode::BAD_REQUEST, "Email is required".into()));
    }
    Ok(Json(UserResponse {
        id: 42,
        name: payload.name,
        email: payload.email,
        created_at: "2026-02-27T00:00:00Z".to_string(),
    }))
}

// === Actix-web ===
// Cargo.toml: actix-web = "4"
use actix_web::{web, App, HttpResponse, HttpServer, Responder};

async fn actix_create_user(body: web::Json<CreateUserRequest>) -> impl Responder {
    let response = UserResponse {
        id: 1,
        name: body.name.clone(),
        email: body.email.clone(),
        created_at: "2026-02-27T00:00:00Z".to_string(),
    };
    HttpResponse::Created().json(response)
}

// Actix-web: query parameters from JSON-like structs
use actix_web::web::Query;

#[derive(Debug, Deserialize)]
struct PaginationQuery {
    page: Option<u32>,
    per_page: Option<u32>,
}

async fn list_users(query: Query<PaginationQuery>) -> impl Responder {
    let page = query.page.unwrap_or(1);
    let per_page = query.per_page.unwrap_or(20);
    HttpResponse::Ok().json(serde_json::json!({
        "page": page,
        "per_page": per_page,
        "data": []
    }))
}

12. Common Pitfalls

Watch out for these issues when working with serde_json:

use serde::{Deserialize, Serialize};
use std::borrow::Cow;

// PITFALL 1: Ownership vs borrowing — &str in structs requires lifetime
// This will NOT compile without a lifetime:
// struct Bad { name: &str }  // error: missing lifetime specifier

// Use String (owned) for most cases:
#[derive(Debug, Deserialize)]
struct OwnedVersion {
    name: String,  // Always works
}

// Use &str with lifetime for zero-copy (advanced):
#[derive(Debug, Deserialize)]
struct BorrowedVersion<'a> {
    #[serde(borrow)]
    name: &'a str,  // Borrows from input, input must outlive struct
}

// PITFALL 2: Cow<str> — flexible owned or borrowed
#[derive(Debug, Deserialize, Serialize)]
struct FlexibleStruct<'a> {
    #[serde(borrow)]
    name: Cow<'a, str>,  // Borrows if possible, owns if needed
}

// PITFALL 3: Integer overflow from JSON numbers
// JSON has no integer size constraints; Rust types do
// This panics on overflow in debug mode:
#[derive(Debug, Deserialize)]
struct OverflowRisk {
    count: u8,  // Fails if JSON has count: 300
}

// Use larger types or validate:
#[derive(Debug, Deserialize)]
struct SafeCount {
    count: u64,  // Safe for all positive JSON integers
}

// PITFALL 4: Recursive types must use Box<T>
// This will NOT compile (infinite size):
// struct Node { children: Vec<Node> }

// Correct: use Box for recursion
#[derive(Debug, Serialize, Deserialize)]
struct TreeNode {
    value: i32,
    children: Vec<Box<TreeNode>>,  // Box breaks infinite size
}

// PITFALL 5: Missing serde import causes cryptic errors
// Every file using #[derive(Serialize, Deserialize)] needs:
use serde::{Serialize as _Serialize, Deserialize as _Deserialize};
// Or: use serde::*; (less idiomatic but explicit)

// PITFALL 6: f64 precision for large integers
// JSON: {"id": 9007199254740993}
// f64 cannot represent this exactly — use i64 or u64
#[derive(Debug, Deserialize)]
struct Precise {
    id: u64,      // Correct: exact integer
    score: f64,   // OK: floating-point precision is expected
}

Key Takeaways

  • Add serde = { version: "1", features: ["derive"] } and serde_json = "1" to Cargo.toml
  • #[derive(Serialize, Deserialize)] generates all JSON parsing/writing code at compile time
  • #[serde(rename_all = "camelCase")] maps all snake_case Rust fields to camelCase JSON keys
  • Option<T> handles null/missing fields — #[serde(skip_serializing_if = "Option::is_none")] omits None in output
  • Use #[serde(tag = "type")] for internally tagged enums (discriminated unions)
  • serde_json::Value handles fully dynamic JSON at the cost of heap allocations
  • Use Box<T> for recursive struct types and BufReader + from_reader for large files
  • Axum's Json<T> extractor and Actix-web's web::Json<T> both use serde_json transparently

Frequently Asked Questions

Q: What is Serde and why is it used for JSON in Rust?

Serde is Rust's de-facto serialization/deserialization framework. It provides generic Serialize and Deserialize traits. serde_json is the concrete implementation for JSON. Using #[derive(Serialize, Deserialize)] on your structs, Serde generates all the parsing and serialization code at compile time with zero runtime overhead.

Q: How do I add Serde to my Rust project?

Add to Cargo.toml: serde = { version = "1", features = ["derive"] } and serde_json = "1". The "derive" feature enables the #[derive(Serialize, Deserialize)] macros. Then import with: use serde::{Deserialize, Serialize}; in each file that uses it.

Q: How do I handle camelCase JSON keys with snake_case Rust fields?

Use #[serde(rename_all = "camelCase")] on the struct. This converts all field names automatically: user_name becomes "userName", created_at becomes "createdAt". For a single field use #[serde(rename = "specificKey")]. Supported strategies include camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, and kebab-case.

Q: How does Option<T> work with serde_json for nullable fields?

Option<T> maps naturally: None serializes as null, absent keys deserialize as None. Use #[serde(skip_serializing_if = "Option::is_none")] to omit None fields from output. Use #[serde(default)] to treat absent keys as None. For fields that can be both absent and null, use Option<Option<T>>.

Q: How do I deserialize JSON arrays in Rust?

Use Vec<T> where T implements Deserialize. Serde handles it automatically: let items: Vec<Item> = serde_json::from_str(json)?; In a struct field, declare tags: Vec<Tag>. For optional arrays use Option<Vec<T>>. For heterogeneous arrays use Vec<serde_json::Value>.

Q: What are the different enum tagging strategies in Serde?

Serde supports four enum representations: External tagging (default) wraps data in the variant name key. Internal tagging (#[serde(tag = "type")]) adds a discriminant field inside the object. Adjacent tagging (#[serde(tag = "type", content = "data")]) keeps type and data separate. Untagged (#[serde(untagged)]) tries each variant in order — useful for union types.

Q: When should I use serde_json::Value instead of a struct?

Use serde_json::Value when the JSON structure is unknown at compile time, for truly dynamic data, or when you need to inspect/modify JSON before converting. Value has variants: Null, Bool, Number, String, Array(Vec<Value>), Object(Map<String, Value>). Access with v["key"] and cast with v.as_str(), v.as_i64(), v.as_f64().

Q: How do I integrate serde_json with Axum or Actix-web?

In Axum, use the Json extractor: async fn handler(Json(body): Json<MyRequest>) -> Json<MyResponse>. Axum automatically deserializes requests and serializes responses using serde_json. In Actix-web, use web::Json<T>: async fn handler(body: web::Json<MyRequest>) -> impl Responder. Both frameworks return 422 Unprocessable Entity on deserialization failure.

𝕏 Twitterin LinkedIn
บทความนี้มีประโยชน์ไหม?

Stay Updated

Get weekly dev tips and new tool announcements.

No spam. Unsubscribe anytime.

Partner Picks

Sponsor this article

Place your product next to this developer topic with tracked clicks.

Ask about article sponsorship

ลองเครื่องมือที่เกี่ยวข้อง

This site uses cookies for analytics and to display ads. By continuing to browse, you agree. Privacy Policy