How to Validate JSON in Rust
Every Rust service that accepts JSON — an API endpoint, a webhook, a config file — needs to answer two questions: is this JSON shaped correctly, and are the values actually valid? This guide walks through doing both in one step with zod-rs.
The problem with serde alone
Section titled “The problem with serde alone”serde_json checks structure, but not meaning:
#[derive(serde::Deserialize)]struct SignupRequest { username: String, email: String, age: u32,}
let req: SignupRequest = serde_json::from_str(input)?;This happily accepts an empty username, "email": "not-an-email", and "age": 5000. Type-correct, value-wrong. You end up writing ad-hoc if checks after deserializing — scattered, untested, and with error messages that don’t tell the caller which field failed.
Step 1: Define a schema
Section titled “Step 1: Define a schema”A zod-rs schema states the rules once, as a value:
use zod_rs::prelude::*;
let schema = object() .field("username", string().min(3).max(20)) .field("email", string().email()) .field("age", number().min(13.0).max(120.0).int());Step 2: Validate the JSON
Section titled “Step 2: Validate the JSON”use serde_json::json;
let data = json!({ "username": "alice", "age": 25});
match schema.safe_parse(&data) { Ok(value) => println!("Valid: {:?}", value), Err(errors) => println!("Invalid: {}", errors),}Invalid input produces errors with the full path to each failing field:
- username: Too small: expected string to have >= 3 characters- email: Invalid email addressPaths work at any depth — a bad value nested three objects deep reports as user.profile.email, and each issue is individually accessible via errors.issues. See Error Handling for the full error model.
Step 3: Get a typed struct, not just valid JSON
Section titled “Step 3: Get a typed struct, not just valid JSON”For most applications you want the validated data as your struct. Derive the schema from the type instead of writing it by hand:
use zod_rs::prelude::*;
#[derive(ZodSchema, serde::Deserialize, Debug)]struct SignupRequest { #[zod(min_length(3), max_length(20))] username: String,
#[zod(email)] email: String,
#[zod(min(13.0), max(120.0), int)] age: u32,}
// Validate and deserialize in one calllet req = SignupRequest::validate_and_parse(&data)?;
// Or straight from a JSON stringlet req = SignupRequest::from_json(r#"{"username":"alice","email":"[email protected]","age":25}"#)?;Nested structs validate automatically when their types also derive ZodSchema — see Nested Structs and the attributes reference for every available rule.
Common validation recipes
Section titled “Common validation recipes”// Optional fieldsobject().field("nickname", string().min(1).optional())
// Arrays with element rulesobject().field("tags", array(string().min(1)).max(10))
// One of several allowed valuesunion() .variant(literal("admin".to_string())) .variant(literal("member".to_string()))
// Reusable schema functionsfn email_field() -> impl Schema<String> { string().email().max(254)}See Schema Composition for structuring larger rule sets.
Validating HTTP request bodies
Section titled “Validating HTTP request bodies”If the JSON arrives over HTTP, you usually want validation in the extractor layer rather than in every handler. The Axum integration does exactly that with the axum feature flag — invalid bodies are rejected with structured errors before your handler runs.
See also
Section titled “See also”- How to Validate Structs in Rust — derive validation from type definitions
- Rust Input Validation Guide — validate data at every application boundary
- API and Form Validation — validate HTTP requests in Axum
- Share Validation Between Rust and TypeScript — one schema, two languages
- Choosing a Rust Validation Library — how zod-rs compares to validator and garde