Error Handling & Validation
Treat serde parsing as the first security gate - map errors clearly and validate semantics after syntax succeeds.
Recipe
Quick-reference recipe card - copy-paste ready.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Input {
#[serde(deserialize_with = "bounded_string")]
name: String,
}
fn bounded_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let s = String::deserialize(d)?;
if s.len() > 256 { return Err(serde::de::Error::custom("name too long")); }
Ok(s)
}When to reach for this: Every public API, webhook, or upload endpoint parsing JSON from untrusted clients.
Working Example
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Signup {
email: String,
age: u8,
}
#[derive(Debug)]
enum ParseError {
Serde(serde_json::Error),
Business(&'static str),
}
fn parse_signup(raw: &str) -> Result<Signup, ParseError> {
let body: Signup = serde_json::from_str(raw).map_err(ParseError::Serde)?;
if !body.email.contains('@') {
return Err(ParseError::Business("invalid email"));
}
if body.age < 18 {
return Err(ParseError::Business("too young"));
}
Ok(body)
}
fn main() {
let ok = parse_signup(r#"{"email":"a@b.co","age":21}"#).unwrap();
assert_eq!(ok.email, "a@b.co");
let extra = parse_signup(r#"{"email":"a@b.co","age":21,"admin":true}"#);
assert!(extra.is_err());
}What this demonstrates:
deny_unknown_fieldsrejects unexpected JSON keys early.- Two-phase validation: serde syntax, then business rules.
- Distinct error types for client vs server mapping.
Deep Dive
Validation Pipeline
| Stage | Checks |
|---|---|
| Size limit | Max body bytes before parse |
| Serde | Types, required fields, unknown keys |
| Semantic | Email, ranges, referential integrity |
| Authz | Caller allowed to set fields |
Rust Notes
// Map serde errors to HTTP 400
fn bad_request(err: serde_json::Error) -> ApiError {
ApiError::BadRequest(err.to_string())
}Gotchas
- Only serde validation - Accepts syntactically valid nonsense emails. Fix: Add domain validation after parse.
- Error messages to clients - Raw serde errors leak field names and types. Fix: Map to stable
code+ safe message. - No size limit - Gigabyte JSON causes OOM before serde runs. Fix:
RequestBodyLimitLayerin Axum. - Integer overflow - Large numbers in JSON for
u8fail serde - good. Fix: Use appropriate widths; document limits. - Duplicate keys - Behavior depends on deserializer. Fix:
deny_unknown_fields+ strict producers.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
validator crate | Declarative field rules | Custom wire-only checks |
| JSON Schema | External contract enforcement | Internal-only APIs |
garde | Compile-time validation | Already on validator |
FAQs
400 vs 422?
400 malformed JSON; 422 valid JSON failing business rules - document team standard.
Log parse failures?
Log truncated body hash and error, not full PII payload.
Partial deserialization?
serde_json::Value first for probing - loses strictness.
Path traversal in strings?
Validate filenames separately from serde parse.
Nested depth bomb?
Limit JSON depth/recursion in parser or reject huge nested Value.
simd-json errors?
Same mapping pattern - wrap in domain error type.
Batch imports?
Collect per-row errors; do not fail entire file on one bad row if product allows.
Protobuf?
Different error model - see gRPC page.
Fuzzing?
cargo fuzz on deserialize entry points finds panics and crashes.
Testing?
Table tests for too-long strings, unknown fields, type mismatches.
Related
- JSON with serde_json - Parse functions
- Custom (De)serialization - Inline validation
- Request Validation - HTTP layer
- Input Validation & Injection - Security focus
- serde Best Practices - Schema rules
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.