The gap between data and trust
You built an API endpoint for user signup. It accepts JSON. A client sends {"username": "x", "email": "bob"}. Your code parses the JSON, creates the struct, and tries to save it to the database. The database rejects it because the username is too short. Or worse, you save it, and now your UI crashes because it expects a valid email format.
Parsing checks the shape. Validation checks the content. serde will happily turn that JSON into a struct because the types match. The string exists. The email field exists. But the data violates your business rules. Validation is the step that closes the gap between "data exists" and "data is usable."
Validation checks the content, not the shape
Think of validation like a customs inspection. The passport exists (the data parsed), but is it expired? Does the photo match? Are you carrying prohibited items? You don't let the traveler through until every stamp is green.
In Rust, you often use serde to deserialize input into structs. That handles the structure. The validator crate handles the semantics. You attach rules to fields using attributes, and the crate generates a method that checks every rule at runtime. If any rule fails, you get a structured report of what went wrong.
The validator crate
The validator crate uses a derive macro. You add #[derive(Validate)] to your struct, then attach #[validate(...)] attributes to fields. The macro generates a validate method that returns a Result.
Add the crate to your dependencies. You need the derive feature to get the macro.
[dependencies]
validator = { version = "0.19", features = ["derive"] }
Here is a minimal struct with rules. The length attribute checks string length. The email attribute checks format.
use validator::Validate;
#[derive(Validate)]
struct SignupRequest {
// Enforce minimum length for the username.
#[validate(length(min = 3, message = "Username must be at least 3 characters"))]
username: String,
// Check that the email matches a standard format.
#[validate(email(message = "Invalid email address"))]
email: String,
}
fn main() {
let request = SignupRequest {
username: "ab".to_string(),
email: "not-an-email".to_string(),
};
// Call the generated validate method.
match request.validate() {
Ok(_) => println!("Valid!"),
Err(e) => println!("Validation failed: {:?}", e),
}
}
Convention aside: always use the explicit message attribute in production code. The default messages are technical and often confusing for end users. Setting a clear message upfront saves you from hunting down error strings later.
How the macro works
The macro generates an implementation of the Validate trait. The validate method runs every rule attached to every field. It collects all errors, not just the first one. This matters for user experience. If a user submits a form with three mistakes, they want to see all three at once. Fixing one error and resubmitting to find the next is frustrating.
The method returns Result<(), ValidationError>. The ValidationError struct contains a map of field names to error details. You can iterate over the map to extract field names, error codes, and messages.
use validator::{Validate, ValidationError};
#[derive(Validate)]
struct User {
#[validate(length(min = 3))]
username: String,
#[validate(email)]
email: String,
}
fn handle_validation(req: User) {
if let Err(errors) = req.validate() {
// Iterate over field errors.
for (field, field_errors) in errors.field_errors() {
for error in field_errors {
// Access the code and message for each error.
println!("Field '{}': code='{}', message='{}'",
field,
error.code.as_str(),
error.message.as_deref().unwrap_or("no message"));
}
}
}
}
The error structure gives you programmatic access to what failed. You can map these errors to API response codes, log them, or format them for a frontend.
Collect all errors. Don't stop at the first one. Users hate fixing one error only to find another.
Real-world patterns
Real validation often involves more than length and email. You might need regex patterns, custom logic, or validation of nested structs.
Custom validation functions
When built-in rules aren't enough, use a custom function. The custom attribute points to a function that takes a reference to the value and returns a Result<(), ValidationError>.
use validator::{Validate, ValidationError};
#[derive(Validate)]
struct PasswordReset {
#[validate(length(min = 8))]
password: String,
// Validate that the password contains a digit.
#[validate(custom = "contains_digit")]
password_confirmation: String,
}
fn contains_digit(value: &str) -> Result<(), ValidationError> {
if value.chars().any(|c| c.is_ascii_digit()) {
Ok(())
} else {
let mut err = ValidationError::new("must contain a digit");
err.add_param("value".into(), &value);
Err(err)
}
}
The custom function runs at validation time. You can create a ValidationError manually and attach parameters. Parameters let you include dynamic values in error messages, like the actual value that failed.
Regex validation
Regex rules require a static pattern. Define the regex at the module level and reference it in the attribute.
use lazy_static::lazy_static;
use regex::Regex;
use validator::Validate;
lazy_static! {
static ref USERNAME_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_]+$").unwrap();
}
#[derive(Validate)]
struct Profile {
#[validate(regex(path = "USERNAME_REGEX"))]
username: String,
}
Convention aside: validator requires the regex feature flag if you use regex rules. Add features = ["derive", "regex"] to your dependency. The lazy_static crate is the standard way to define static regex patterns in Rust.
Nested validation
If your struct contains other structs, you can validate them recursively. Add #[validate(nested)] to the field. The inner struct must also derive Validate.
#[derive(Validate)]
struct Address {
#[validate(length(min = 1))]
street: String,
}
#[derive(Validate)]
struct User {
#[validate(length(min = 1))]
name: String,
#[validate(nested)]
address: Address,
}
When you call validate on User, it checks name and then descends into address to check street. Errors from nested fields appear in the error map with a dotted path like address.street.
Validation is a runtime cost. Pay it at the boundary, not in the core.
Pitfalls and compiler errors
Validation runs at runtime. The compiler does not enforce validation rules. You can construct a struct with invalid data as long as you don't call validate. This is a common source of bugs. If you create a struct manually in tests or internal code, you might bypass validation and pass bad data downstream.
If you forget to derive Validate, the compiler rejects calls to validate with E0599 (no method named validate). If you try to call validate on a type that doesn't implement the trait, you get E0277 (the trait bound Validate is not satisfied).
struct NoValidate {
value: String,
}
fn main() {
let item = NoValidate { value: "test".to_string() };
// Error[E0599]: no method named `validate` found for struct `NoValidate`
item.validate();
}
Option fields require attention. By default, validator skips None values. If the field is Some, it validates the inner value. If you need to validate None or require the field to be present, use the required attribute or a custom validator.
#[derive(Validate)]
struct Settings {
// Skips validation if None. Validates length if Some.
#[validate(length(min = 1))]
theme: Option<String>,
// Requires the field to be Some and validates length.
#[validate(required, length(min = 1))]
language: Option<String>,
}
Performance matters in hot paths. Validation adds overhead. If you are processing trusted internal data, skip validation. If you are in a tight loop processing millions of records, measure the cost. Validation is for untrusted input and API boundaries, not for internal data manipulation.
The compiler won't save you here. Write the tests.
When to use validator
Use validator when you have a struct with many fields and standard rules like length, email, URL, or regex. The derive macro reduces boilerplate and keeps rules close to the data definition.
Use manual validation functions when your rules are complex, involve cross-field dependencies, or require database lookups. The validator crate supports custom functions, but if the logic grows large, a dedicated validation method on the struct is clearer.
Use type-based validation when you want compile-time guarantees that a value is valid. Crates like email_address or url provide types that can only be constructed from valid input. This moves validation to construction time and eliminates runtime checks.
Reach for serde validation attributes only for basic shape checking. serde attributes like skip_serializing_if or deserialize_with handle format conversion, not semantic rules. serde does not validate content.
Pick the tool that matches your trust model. Untrusted data needs a fortress.