Learn/Language Integrations

JSON in Rust — serde, Deserialization & High-Performance Parsing

Rust's serde ecosystem is the gold standard for type-safe, zero-overhead JSON processing. This guide covers everything from basic derive macros to zero-copy deserialization, custom serializers, SIMD-accelerated parsing, and streaming large files.

Getting Started: serde + serde_json

Cargo.toml dependenciestoml
1[dependencies]
2serde = { version = "1", features = ["derive"] }
3serde_json = "1"
Basic serialization and deserializationrust
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Serialize, Deserialize)]
4struct User {
5 name: String,
6 age: u32,
7 email: String,
8}
9
10fn main() -> Result<(), serde_json::Error> {
11 // Deserialize JSON string -> Rust struct
12 let json = r#"{"name":"Alice","age":30,"email":"[email protected]"}"#;
13 let user: User = serde_json::from_str(json)?;
14 println!("{:?}", user);
15
16 // Serialize Rust struct -> JSON string
17 let json_output = serde_json::to_string_pretty(&user)?;
18 println!("{}", json_output);
19
20 Ok(())
21}
serde Data Flow

Struct Tags: Renaming, Defaults, and Skipping

Common serde attributesrust
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Serialize, Deserialize)]
4#[serde(rename_all = "camelCase")] // all fields become camelCase in JSON
5struct ApiResponse {
6 status_code: u16, // serialized as "statusCode"
7 request_id: String, // serialized as "requestId"
8
9 #[serde(rename = "type")] // override: use "type" (reserved word in Rust)
10 response_type: String,
11
12 #[serde(default)] // use Default::default() if missing
13 retry_count: u32,
14
15 #[serde(skip_serializing_if = "Option::is_none")]
16 error_message: Option<String>, // omitted from JSON when None
17
18 #[serde(skip)] // never serialized or deserialized
19 internal_cache: Vec<u8>,
20}
AttributeEffectScope
rename_all = "camelCase"Converts all field names to camelCaseContainer (struct/enum)
rename = "name"Uses a specific JSON key for one fieldField
defaultUses Default::default() if field is missingField or container
skip_serializing_if = "fn"Omits field from output if predicate is trueField
skipExcludes field from both serialization and deserializationField
flattenInlines a nested struct into the parent objectField
alias = "name"Accepts an alternative JSON key during deserializationField
deny_unknown_fieldsRejects JSON with keys not in the structContainer

Enums: Representing Polymorphic JSON

Tagged Enums (Default)

Externally tagged enumrust
1#[derive(Serialize, Deserialize)]
2enum Shape {
3 Circle { radius: f64 },
4 Rectangle { width: f64, height: f64 },
5}
6// JSON: {"Circle":{"radius":5.0}}

Internally Tagged and Untagged

Common enum representationsrust
1// Internally tagged: {"type":"circle","radius":5.0}
2#[derive(Serialize, Deserialize)]
3#[serde(tag = "type", rename_all = "lowercase")]
4enum Shape {
5 Circle { radius: f64 },
6 Rectangle { width: f64, height: f64 },
7}
8
9// Adjacently tagged: {"t":"circle","c":{"radius":5.0}}
10#[derive(Serialize, Deserialize)]
11#[serde(tag = "t", content = "c")]
12enum Message {
13 Text(String),
14 Image { url: String, width: u32 },
15}
16
17// Untagged: tries each variant in order
18#[derive(Serialize, Deserialize)]
19#[serde(untagged)]
20enum Value {
21 Integer(i64),
22 Float(f64),
23 Text(String),
24}

Choosing an Enum Representation

Use internally tagged for API responses with a discriminator field ("type"). Use untagged when consuming third-party JSON where you do not control the format. Avoid untagged for large enums as serde tries each variant sequentially.

Dynamic JSON with serde_json::Value

Working with untyped JSONrust
1use serde_json::{json, Value};
2
3fn main() -> Result<(), Box<dyn std::error::Error>> {
4 // Parse into dynamic Value
5 let data: Value = serde_json::from_str(r#"{"users":[{"name":"Alice"},{"name":"Bob"}]}"#)?;
6
7 // Access nested fields
8 let first_name = &data["users"][0]["name"];
9 println!("First user: {}", first_name); // "Alice"
10
11 // Build JSON with the json! macro
12 let response = json!({
13 "status": "ok",
14 "count": 42,
15 "items": [1, 2, 3],
16 "metadata": null
17 });
18
19 // Modify values
20 let mut config: Value = json!({"debug": false, "port": 8080});
21 config["debug"] = json!(true);
22 config["host"] = json!("0.0.0.0");
23
24 println!("{}", serde_json::to_string_pretty(&config)?);
25 Ok(())
26}

Custom Serialize and Deserialize

Custom date serializationrust
1use chrono::{DateTime, Utc};
2use serde::{self, Deserialize, Deserializer, Serializer};
3
4const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
5
6pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
7where S: Serializer {
8 let s = date.format(FORMAT).to_string();
9 serializer.serialize_str(&s)
10}
11
12pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
13where D: Deserializer<'de> {
14 let s = String::deserialize(deserializer)?;
15 DateTime::parse_from_str(&s, FORMAT)
16 .map(|dt| dt.with_timezone(&Utc))
17 .map_err(serde::de::Error::custom)
18}
19
20// Usage:
21#[derive(Serialize, Deserialize)]
22struct Event {
23 name: String,
24 #[serde(with = "self")] // use the custom module above
25 timestamp: DateTime<Utc>,
26}

Zero-Copy Deserialization

Borrow string data directly from the input buffer to avoid allocations. Use &'a str instead of String:

Zero-copy with borrowed stringsrust
1#[derive(Deserialize)]
2struct LogEntry<'a> {
3 #[serde(borrow)]
4 message: &'a str, // borrows from the input buffer
5 #[serde(borrow)]
6 service: &'a str,
7 level: u8, // primitives are always copied (trivially cheap)
8}
9
10fn process_logs(input: &[u8]) -> Result<(), serde_json::Error> {
11 // from_slice enables borrowing from the byte buffer
12 let entry: LogEntry = serde_json::from_slice(input)?;
13 println!("{}: {}", entry.service, entry.message);
14 Ok(())
15}

Note

Zero-copy deserialization requires the input to outlive the deserialized struct. It works with from_slice and from_str but not with from_reader (which buffers internally).

High-Performance Parsing with simd-json

For maximum throughput, simd-json uses SIMD CPU instructions to parse JSON 2–4x faster than serde_json. It implements the serde Deserialize trait, making it a near drop-in replacement:

Using simd-json with serde structsrust
1use simd_json;
2use serde::Deserialize;
3
4#[derive(Deserialize)]
5struct Record {
6 id: u64,
7 name: String,
8 score: f64,
9}
10
11fn parse_fast(input: &mut [u8]) -> Result<Record, simd_json::Error> {
12 // simd-json requires a mutable slice (it modifies the buffer in place)
13 simd_json::serde::from_slice(input)
14}
15
16// For dynamic parsing without structs:
17fn parse_dynamic(input: &mut [u8]) -> Result<simd_json::OwnedValue, simd_json::Error> {
18 simd_json::to_owned_value(input)
19}
LibraryThroughput (approx)AllocationAPI
serde_json~300 MB/sStandardIdiomatic serde
simd-json~1.2 GB/sMinimal (in-place)serde-compatible (mutable buffer required)
serde_json from_slice~350 MB/sReduced (zero-copy possible)Idiomatic serde with borrowing

Streaming JSON with StreamDeserializer

Process JSON Lines (NDJSON) streamsrust
1use serde::Deserialize;
2use std::io::BufReader;
3use std::fs::File;
4
5#[derive(Deserialize)]
6struct LogLine {
7 timestamp: String,
8 level: String,
9 message: String,
10}
11
12fn process_log_file(path: &str) -> Result<(), Box<dyn std::error::Error>> {
13 let file = File::open(path)?;
14 let reader = BufReader::new(file);
15 let stream = serde_json::Deserializer::from_reader(reader)
16 .into_iter::<LogLine>();
17
18 let mut error_count = 0u64;
19 for result in stream {
20 let entry = result?;
21 if entry.level == "error" {
22 error_count += 1;
23 }
24 }
25 println!("Total errors: {}", error_count);
26 Ok(())
27}

Error Handling Patterns

Robust error handling with contextrust
1use serde::Deserialize;
2use thiserror::Error;
3
4#[derive(Error, Debug)]
5enum AppError {
6 #[error("Failed to parse JSON: {0}")]
7 JsonParse(#[from] serde_json::Error),
8
9 #[error("Invalid data: {field} — {reason}")]
10 Validation { field: String, reason: String },
11}
12
13#[derive(Deserialize)]
14struct Config {
15 port: u16,
16 host: String,
17 max_connections: u32,
18}
19
20fn load_config(json: &str) -> Result<Config, AppError> {
21 let config: Config = serde_json::from_str(json)?;
22
23 if config.port == 0 {
24 return Err(AppError::Validation {
25 field: "port".into(),
26 reason: "must be non-zero".into(),
27 });
28 }
29 Ok(config)
30}
31
32// serde_json errors include line/column for debugging:
33// "expected value at line 3 column 12"

Frequently Asked Questions

What is serde in Rust?
serde (SERialize/DEserialize) is Rust's framework for converting data structures to and from various formats. serde_json is the JSON-specific implementation. Together, they are the most downloaded crates on crates.io, handling billions of JSON operations in production systems worldwide.
How do I parse JSON into a Rust struct?
Add serde and serde_json to your Cargo.toml, derive Serialize and Deserialize on your struct, then call serde_json::from_str(&json_string). The derive macro generates all the parsing code at compile time with zero runtime reflection.
What is the difference between serde_json::Value and typed deserialization?
serde_json::Value is a dynamic type that can hold any JSON value (like a HashMap/Vec tree). Typed deserialization maps JSON directly into your Rust structs with compile-time type safety. Use Value for unknown schemas; use structs when the shape is known.
What is zero-copy deserialization in serde?
Zero-copy deserialization borrows string data directly from the input buffer instead of allocating new String objects. You use &'a str instead of String in your structs and deserialize with serde_json::from_slice. This significantly reduces allocations for read-heavy workloads.
How fast is simd-json compared to serde_json?
simd-json uses SIMD CPU instructions to parse JSON and is typically 2-4x faster than serde_json for large payloads. It is a drop-in replacement that implements the serde Deserialize trait, so you can switch without changing your struct definitions.
How do I handle optional fields in JSON with serde?
Wrap the field type in Option<T> and add #[serde(default)] or #[serde(skip_serializing_if = "Option::is_none")]. Option::None maps to missing JSON fields, and you can use #[serde(default)] to provide fallback values for any type.