Request Validation
Validate HTTP inputs at the boundary with serde types and the validator crate before business logic runs.
Recipe
Quick-reference recipe card - copy-paste ready.
use axum::Json;
use serde::Deserialize;
use validator::{Validate, ValidationError};
#[derive(Debug, Deserialize, Validate)]
struct SignupRequest {
#[validate(email)]
email: String,
#[validate(length(min = 8))]
password: String,
}
async fn signup(Json(body): Json<SignupRequest>) -> Result<(), AppError> {
body.validate().map_err(AppError::from)?;
// business logic
Ok(())
}When to reach for this: Untrusted JSON, query params, or forms must be checked before database or auth operations.
Working Example
use axum::{routing::post, Json, Router};
use serde::Deserialize;
use validator::{Validate, ValidationError};
#[derive(Deserialize, Validate)]
struct CreateUser {
#[validate(email(message = "invalid email"))]
email: String,
#[validate(length(min = 2, max = 50))]
display_name: String,
#[validate(range(min = 18, max = 120))]
age: u8,
}
#[derive(serde::Serialize)]
struct ErrorBody { fields: Vec<String> }
enum AppError { Validation(Vec<String>) }
impl axum::response::IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
match self {
AppError::Validation(fields) => (
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
Json(ErrorBody { fields }),
).into_response(),
}
}
}
async fn create_user(Json(body): Json<CreateUser>) -> Result<&'static str, AppError> {
body.validate().map_err(|e| {
AppError::Validation(e.field_errors().keys().map(|k| k.to_string()).collect())
})?;
Ok("created")
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What this demonstrates:
validatorattributes on serde structs.- Explicit
validate()call after JSON deserialization. - 422 response with field names for client forms.
- Separation of transport parsing (serde) and business rules (validator).
Deep Dive
How It Works
- Serde parses JSON into types; malformed JSON never reaches
validate(). validatorruns sync checks: length, email, regex, custom functions.- Return 422 (Unprocessable Entity) for semantic validation failures.
Validation Layers
| Layer | Tool | Catches |
|---|---|---|
| Wire format | serde | Invalid JSON, wrong types |
| Field rules | validator | Email, length, range |
| Business rules | Handler/service | Duplicate email, banned domain |
Rust Notes
fn check_reserved(name: &str) -> Result<(), ValidationError> {
if name == "admin" {
return Err(ValidationError::new("reserved"));
}
Ok(())
}
#[validate(custom(function = "check_reserved"))]
username: String,Gotchas
- Skipping validate() - Deriving
Validatedoes not auto-run checks. Fix: Call.validate()?in every handler. - serde passes invalid semantics -
email: "not-an-email"deserializes fine. Fix: Add#[validate(email)]. - Database as validator - Uniqueness checks belong after format validation. Fix: Two-phase: validator then DB query.
- Leaking validation library errors - Raw
ValidationErrorsdebug string in response. Fix: Map to stable field-level codes. - Query param validation forgotten - Only validating JSON bodies. Fix: Derive
ValidateonQuerystructs too.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
garde | Compile-time validation derive | Team already standardized on validator |
Manual if checks | Tiny handlers with one rule | Reusable DTOs across endpoints |
| JSON Schema | External contract validation | Simple CRUD with Rust types |
FAQs
422 vs 400?
422 for well-formed JSON with invalid field values; 400 for malformed requests.
Validate path params?
Validate after Path extraction with manual checks or a newtype with TryFrom.
Nested objects?
Use #[validate(nested)] on child structs.
Conditional rules?
#[validate(required_if = "...")] or custom validation functions.
OpenAPI integration?
Generate schemas from types with utoipa and document constraints.
Sanitization vs validation?
Validate first; normalize (trim, lowercase email) in a separate step before persist.
Internationalized messages?
Map error codes client-side; server returns stable code keys.
Performance?
Validator is sync and fast; avoid DB calls inside custom validators.
Partial updates PATCH?
Use Option<T> fields with #[validate(required)] only when present.
Test validation?
POST invalid bodies with tower::ServiceExt::oneshot and assert 422 fields.
Related
- Extractors & State -
JsonandQuery - Error Handling in Handlers - Map validation errors
- Input Validation & Injection - Security-focused validation
- API Design Basics - Error envelopes
- serde Basics - Deserialize fundamentals
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+.