Rust Input Validation Guide
Input validation in Rust ensures data from external sources — HTTP requests, CLI arguments, configuration files, message queues — meets your application’s rules before processing. While Rust’s type system catches structural errors at compile time, runtime input validation with zod-rs enforces business rules like email format, string length, numeric ranges, and custom constraints.
This guide covers every common input validation pattern in Rust, from simple string checks to deeply nested objects, with ready-to-use code examples.
Why Rust’s type system isn’t enough
Section titled “Why Rust’s type system isn’t enough”Rust’s ownership and type system are the strongest in any mainstream language. A String is always valid UTF-8, a u32 is always non-negative, and an Option<T> forces you to handle the absent case. But types alone cannot enforce business rules:
struct User { email: String, // any string — "not-an-email" compiles fine age: u32, // any u32 — 999999 is technically valid username: String, // empty string? 10000 characters? no check}When data crosses a trust boundary — arriving over HTTP, read from a file, or parsed from command-line arguments — you need runtime validation on top of compile-time types. That is what zod-rs provides.
Where input validation belongs
Section titled “Where input validation belongs”Validate at the boundary, not deep inside business logic:
External world → [ Boundary validation ] → Trusted typed data ↑ Validate here, once. After this point, trust the types.This pattern keeps your core logic clean. Every function past the boundary receives data that has already been checked — no scattered if email.contains('@') guards buried in service layers.
String validation
Section titled “String validation”The string schema covers the most common text rules:
use zod_rs::prelude::*;
// Length constraintslet username = string().min(3).max(20);
// Email formatlet email = string().email();
// URL formatlet website = string().url();
// Regex patternlet slug = string().regex(r"^[a-z0-9-]+$");
// Prefix and suffixlet s3_key = string().starts_with("uploads/");let image = string().ends_with(".png");
// Substring checklet bio = string().contains("rust");Each rule produces a clear error message. A string failing min(3) reports Too small: expected string to have >= 3 characters, not a generic “invalid” message.
Combining string rules
Section titled “Combining string rules”Rules chain naturally:
let password = string() .min(8) .max(128) .regex(r"[A-Z]") // at least one uppercase .regex(r"[0-9]"); // at least one digitEvery rule runs independently — a password that is too short and missing digits reports both issues, not just the first.
Number validation
Section titled “Number validation”The number schema validates numeric values:
// Rangelet age = number().min(13.0).max(120.0);
// Integer only (reject 3.14)let quantity = number().int();
// Positive valueslet price = number().positive();
// Non-negative (zero allowed)let stock = number().min(0.0);
// Combine ruleslet port = number().int().min(1.0).max(65535.0);Object and struct validation
Section titled “Object and struct validation”The object schema validates JSON objects with typed fields:
let user_schema = object() .field("username", string().min(3).max(20)) .field("email", string().email()) .field("age", number().min(13.0).max(120.0).int());Nested objects
Section titled “Nested objects”Objects nest arbitrarily deep — and every error carries the full path:
let schema = object() .field("user", object() .field("profile", object() .field("email", string().email()) .field("bio", string().max(500)) ) .field("settings", object() .field("theme", string().min(1)) ) );
// Error on a nested field reports: user.profile.email: Invalid email addressDerive macro for structs
Section titled “Derive macro for structs”For struct-heavy codebases, derive the schema instead of writing it by hand:
use zod_rs::prelude::*;
#[derive(ZodSchema, serde::Deserialize, Debug)]struct Profile { #[zod(email)] email: String,
#[zod(max_length(500))] bio: String,}
#[derive(ZodSchema, serde::Deserialize, Debug)]struct CreateUser { #[zod(min_length(3), max_length(20))] username: String,
#[zod(min(13.0), max(120.0), int)] age: u32,
profile: Profile, // nested validation is automatic}
// Validate and deserialize in one calllet user = CreateUser::validate_and_parse(&json_data)?;Nested structs that derive ZodSchema are validated recursively. See nested struct validation for the details.
Array validation
Section titled “Array validation”The array schema validates collections with element-level and length rules:
// Array of valid emails, 1 to 10 itemslet emails = array(string().email()).min(1).max(10);
// Array of objectslet items = array( object() .field("name", string().min(1)) .field("quantity", number().int().positive())).min(1);
// Errors include the index: [2].quantity: Expected positive numberOptional and nullable fields
Section titled “Optional and nullable fields”The optional schema handles fields that may be absent or null:
// Field may be missing from the JSONobject().field("nickname", string().min(1).optional())
// In a derive macro#[derive(ZodSchema, serde::Deserialize)]struct UpdateUser { #[zod(min_length(1))] nickname: Option<String>, // optional automatically}When the field is present, every rule applies. When absent or null, validation passes.
Validating CLI arguments
Section titled “Validating CLI arguments”Command-line tools receive untrusted input too. Parse arguments into JSON values and validate:
use serde_json::json;use zod_rs::prelude::*;
fn main() { let args: Vec<String> = std::env::args().collect();
let schema = object() .field("port", number().int().min(1.0).max(65535.0)) .field("host", string().min(1));
let port: f64 = args.get(1) .and_then(|s| s.parse().ok()) .unwrap_or(0.0);
let host = args.get(2) .cloned() .unwrap_or_default();
let input = json!({ "port": port, "host": host });
match schema.safe_parse(&input) { Ok(_) => println!("Starting server on {}:{}", host, port as u16), Err(errors) => { eprintln!("Invalid arguments:\n{}", errors); std::process::exit(1); } }}Validating API request bodies
Section titled “Validating API request bodies”Web services are the most common boundary. With the axum feature flag, zod-rs validates request bodies in the extractor layer — invalid input never reaches your handler:
use zod_rs::prelude::*;
#[derive(ZodSchema, serde::Deserialize)]struct CreateOrder { #[zod(min_length(1))] product_id: String,
#[zod(min(1.0), int)] quantity: u32,
#[zod(email)] buyer_email: String,}
async fn create_order( ZodValid(order): ZodValid<CreateOrder>,) -> impl IntoResponse { // `order` is already validated — trust the types here Json(json!({ "status": "created" }))}Invalid requests get a structured 422 response with every failing field and its error. See the full Axum integration guide for setup instructions.
Validating configuration files
Section titled “Validating configuration files”Config files deserve the same validation rigor as API inputs:
use zod_rs::prelude::*;use std::fs;
#[derive(ZodSchema, serde::Deserialize, Debug)]struct AppConfig { #[zod(min(1.0), max(65535.0), int)] port: u32,
#[zod(min_length(1))] database_url: String,
#[zod(min(1.0), max(3600.0))] timeout_seconds: f64,
#[zod(min_length(1))] allowed_origins: Vec<String>,}
fn load_config(path: &str) -> Result<AppConfig, String> { let text = fs::read_to_string(path) .map_err(|e| format!("Cannot read config: {}", e))?;
let json: serde_json::Value = serde_json::from_str(&text) .map_err(|e| format!("Invalid JSON: {}", e))?;
AppConfig::validate_and_parse(&json) .map_err(|e| format!("Config validation failed:\n{}", e))}Every validation error names the exact field — timeout_seconds: Expected number <= 3600 — so operators know what to fix without reading source code. For more on parsing JSON, see How to Validate JSON in Rust.
Error handling and reporting
Section titled “Error handling and reporting”Every validation error in zod-rs carries a path, a code, and a human-readable message:
match schema.safe_parse(&data) { Ok(value) => handle_valid(value), Err(errors) => { for issue in &errors.issues { eprintln!("Field '{}': {}", issue.path.join("."), issue.message); } }}Errors compose across nesting levels. A form with three invalid fields reports all three, not just the first. See Error Handling for the full error model, including error codes and programmatic inspection.
For internationalized error messages — useful when validation errors are shown directly to end users — see i18n support.
Best practice: validate once, trust the types after
Section titled “Best practice: validate once, trust the types after”The strongest validation pattern in Rust is:
- At the boundary: validate every external input with a schema.
- After the boundary: work exclusively with the validated struct.
- Never re-validate the same data deeper in the call stack.
// Boundary: validate and parselet order = CreateOrder::validate_and_parse(&raw_json)?;
// Business logic: trust the typesprocess_order(order); // order.quantity is guaranteed to be >= 1send_confirmation(order.buyer_email); // guaranteed to be a valid emailThis approach means your core domain code stays clean, testable, and free of defensive checks. The type system carries the guarantee forward from the boundary.
Next steps
Section titled “Next steps”- How to Validate JSON in Rust — a deeper dive into JSON-specific patterns
- How to Validate Structs in Rust — struct-first workflows with derive macros
- Axum Integration — validate HTTP request bodies automatically
- Error Handling — the full error model
- Schema Composition — reusable schema building blocks