Introduction
Bad input flows through your API like water through a cracked foundation—silent, damaging, and expensive to fix later. Validation in Rust APIs is where we catch those problems early, before they corrupt your database or crash your services. This article walks through real-world lessons and examples from production systems handling thousands of requests per second, showing you exactly how to reject bad input with confidence and clarity.
Why validation matters in production
We've all seen the aftermath: a NULL in a field that should never be empty, a negative quantity in an order, or an email address that somehow passed through and broke downstream systems. Validation isn't just about being correct—it's about protecting your data integrity and giving your team predictable, debuggable errors instead of mysterious bugs that surface days later in production.
In Rust, the type system gives us structure. Validation gives us semantic correctness. Together, they form a reliable API that your frontend and mobile teams can trust.
The validator crate with derive macros
The validator crate provides a clean, composable way to declare validation rules directly on your structs. Instead of writing validation logic in separate functions, you annotate your fields with predicates that describe what valid data looks like.
Let's walk through a real example: an e-commerce API accepting order creation requests.
use validator::{Validate, ValidationError};
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct CreateOrderRequest {
#[validate(length(min = 1, max = 50))]
pub customer_name: String,
#[validate(email)]
pub customer_email: String,
#[validate(range(min = 0.01, max = 999999.99))]
pub total_amount: f64,
#[validate(length(min = 1))]
pub line_items: Vec,
}
#[derive(Deserialize, Validate)]
pub struct LineItem {
#[validate(range(min = 1))]
pub product_id: u32,
#[validate(range(min = 1))]
pub quantity: u32,
#[validate(range(min = 0.01))]
pub unit_price: f64,
}
Now in your handler, validation is a single method call:
use actix_web::{web, HttpResponse, Result as ActixResult};
#[post("/orders")]
async fn create_order(
req: web::Json,
) -> ActixResult {
// This single line checks all decorated fields
req.validate()
.map_err(|e| {
log::warn!("Order validation failed: {:?}", e);
actix_web::error::ErrorBadRequest("Invalid request")
})?;
// Safe to proceed—input is structurally valid
let order = save_order(&req).await?;
Ok(HttpResponse::Created().json(order))
}
The validator crate supports many built-in rules: length, range, email, url, regex, and more. For nested structs (like line_items above), you don't need extra work—derive handles the tree automatically as long as each level implements Validate.
Custom validation functions
Real-world constraints often go beyond what built-in validators offer. Maybe your business rule says "premium customers can order up to 1,000 units, but standard customers only 100." Maybe you need to cross-check fields against each other. That's where custom validators come in.
The validator crate lets you attach custom functions to fields or the whole struct. Here's a practical example from a ride-hailing system:
use chrono::{DateTime, Utc, Duration};
#[derive(Deserialize, Validate)]
pub struct BookRideRequest {
#[validate(range(min = 1))]
pub source_lat: f64,
#[validate(range(min = 1))]
pub dest_lat: f64,
#[validate(custom = "validate_pickup_time")]
pub pickup_time: DateTime,
#[validate(custom = "validate_customer_tier")]
pub customer_tier: String,
}
fn validate_pickup_time(pickup: &DateTime) -> Result<(), ValidationError> {
let now = Utc::now();
let min_advance = now + Duration::minutes(5);
let max_advance = now + Duration::days(30);
if pickup < &min_advance {
let mut err = ValidationError::new("too_soon");
err.message = Some("Pickup must be at least 5 minutes from now".into());
return Err(err);
}
if pickup > &max_advance {
let mut err = ValidationError::new("too_far");
err.message = Some("Cannot book more than 30 days in advance".into());
return Err(err);
}
Ok(())
}
fn validate_customer_tier(tier: &str) -> Result<(), ValidationError> {
match tier {
"standard" | "premium" | "vip" => Ok(()),
_ => {
let mut err = ValidationError::new("invalid_tier");
err.message = Some("Unknown customer tier".into());
Err(err)
}
}
}
Custom validators receive a reference to the field value and return Result<(), ValidationError>. You control the error type and message, so downstream code gets precise feedback about what went wrong.
For struct-level validation (comparing multiple fields), use the #[validate] attribute on the struct itself:
#[derive(Deserialize, Validate)]
#[validate(schema(function = "validate_date_range"))]
pub struct DateRangeFilter {
pub start_date: DateTime,
pub end_date: DateTime,
}
fn validate_date_range(req: &DateRangeFilter) -> Result<(), ValidationError> {
if req.end_date <= req.start_date {
let mut err = ValidationError::new("invalid_range");
err.message = Some("end_date must be after start_date".into());
return Err(err);
}
Ok(())
}
Return field-level errors
Generic error messages like "Invalid request" frustrate frontend teams. They don't know which field failed or why. Production APIs return structured error responses that let clients fix the issue programmatically.
When validate() fails, it returns a ValidationErrors struct containing a map of field names to errors. Extract that into a clean JSON response:
use validator::ValidationErrors;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub fields: Option>>,
}
fn handle_validation_error(errors: ValidationErrors) -> HttpResponse {
let mut field_errors: HashMap> = HashMap::new();
for (field, error) in errors.field_errors() {
let messages = error
.iter()
.filter_map(|e| e.message.clone().map(|m| m.to_string()))
.collect();
field_errors.insert(field.to_string(), messages);
}
HttpResponse::BadRequest().json(ErrorResponse {
error: "Validation failed".to_string(),
fields: Some(field_errors),
})
}
Now your response looks like this:
// Response body (JSON)
{
"error": "Validation failed",
"fields": {
"customer_email": ["Invalid email format"],
"total_amount": ["must be between 0.01 and 999999.99"],
"line_items": ["must have at least 1 item"]
}
}
Your frontend can now render field-specific error messages without guessing. This is especially valuable in forms with many fields.
Validate before database calls
Here's the critical lesson from production incidents: validate input immediately, before touching the database. Every rejected request should fail fast in memory, not after a round-trip to Postgres or DynamoDB.
Consider this pattern from a trading platform:
use sqlx::PgPool;
#[derive(Deserialize, Validate)]
pub struct PlaceTradeRequest {
#[validate(range(min = 1))]
pub user_id: u64,
#[validate(regex = "SYMBOL_REGEX")]
pub symbol: String,
#[validate(range(min = 0.01))]
pub quantity: f64,
#[validate(range(min = 0.01))]
pub price: f64,
}
pub async fn place_trade(
pool: &PgPool,
req: web::Json,
) -> Result {
// Step 1: Validate input first
req.validate().map_err(|e| {
log::warn!("Trade validation failed: {:?}", e);
ApiError::BadRequest(format_validation_errors(&e))
})?;
// Step 2: Only then query the database
let user = sqlx::query!(
"SELECT balance FROM users WHERE id = $1",
req.user_id as i64
)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::NotFound("User not found".into()))?;
// Step 3: Apply business logic
let order_value = req.quantity * req.price;
if user.balance < order_value {
return Err(ApiError::InsufficientFunds);
}
// Step 4: Persist
let trade = insert_trade(pool, &req).await?;
Ok(TradeResponse { trade_id: trade.id })
}
Notice the order: validate first, query second, execute third. This pattern keeps your database clean and your logs readable. Failed validations don't create noise in query logs or consume connection pool slots.
Common mistakes
- Validating after database reads. If you check constraints after inserting or updating, you've already wasted a database round-trip and potentially created inconsistent state. Validate input before any I/O.
- Generic error messages in production. "Invalid request" tells your frontend nothing. Always return structured, field-level errors so clients can recover gracefully.
- Forgetting to validate nested collections. If you have
Vec<Item>whereItemhas its own#[derive(Validate)], each item is validated automatically—but only if you callreq.validate()on the parent. Don't skip that step. - Writing custom validators that query the database. Custom validators should be pure functions. If you need to check uniqueness or cross-reference data, do that as a separate step after validation, not inside the validator itself. It keeps your error handling clear and your validators fast.
- Not logging validation failures. Silent rejections are debugging nightmares. Log what failed and why. Use structured logging so you can aggregate patterns and catch abuse early.
Key takeaways
- Use
validatorcrate with derive macros to declare validation rules declaratively on your request structs. - Build custom validators for business logic that extends beyond built-in predicates, keeping them pure and side-effect-free.
- Return structured, field-level error responses so frontend teams can fix issues without guessing.
- Validate input immediately—before any database queries or I/O—to keep your system fast and your data clean.
- Log validation failures with enough context to spot patterns and detect potential abuse.
- Treat validation as a permanent part of your handler, not an afterthought. Build it in from the first commit.






Comments
Loading comments…