Request Validation
Validate HTTP inputs at the boundary with serde types and the validator crate before business logic runs.
Search across all documentation pages
Validate HTTP inputs at the boundary with serde types and the validator crate before business logic runs.
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.
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:
validator attributes on serde structs.validate() call after JSON deserialization.validate().validator runs sync checks: length, email, regex, custom functions.| Layer | Tool | Catches |
|---|---|---|
| Wire format | serde | Invalid JSON, wrong types |
| Field rules | validator | Email, length, range |
| Business rules | Handler/service | Duplicate email, banned domain |
fn check_reserved(name: &str) -> Result<(), ValidationError> {
if name == "admin" {
return Err(ValidationError::new("reserved"));
}
Ok(())
}
#[validate(custom(function = "check_reserved"))]
username: String,Validate does not auto-run checks. Fix: Call .validate()? in every handler.email: "not-an-email" deserializes fine. Fix: Add #[validate(email)].ValidationErrors debug string in response. Fix: Map to stable field-level codes.Validate on Query structs too.| 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 |
422 for well-formed JSON with invalid field values; 400 for malformed requests.
Validate after Path extraction with manual checks or a newtype with TryFrom.
Use #[validate(nested)] on child structs.
#[validate(required_if = "...")] or custom validation functions.
Generate schemas from types with utoipa and document constraints.
Validate first; normalize (trim, lowercase email) in a separate step before persist.
Map error codes client-side; server returns stable code keys.
Validator is sync and fast; avoid DB calls inside custom validators.
Use Option<T> fields with #[validate(required)] only when present.
POST invalid bodies with tower::ServiceExt::oneshot and assert 422 fields.
Json and QueryStack 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+.
Reviewed by Chris St. John·Last updated Jul 16, 2026