The bouncer at the door of your data
You're building a signup endpoint. A request arrives with {"email": "alice", "password": "123"}. You parse the JSON, shove it into a struct, and save it to the database. Two seconds later, your database rejects the row because the email format is invalid, or worse, the password is too short and you just stored a plaintext "123" in production. The request wasn't malicious, just sloppy. But your code treated it like a valid user.
Validation is the bouncer at the door of your application logic. Without it, garbage data flows straight into your business rules, your database constraints, and eventually your users' faces. Rust's type system guarantees structure. It guarantees that a String field contains a string. It does not guarantee that the string is an email address. It does not guarantee the password has a number. The validator crate fills that gap. It attaches semantic rules to your structs so you can reject bad data before it touches your core logic.
Declarative rules on structs
Rust code often follows a pattern: deserialize input into a struct, then process it. The validator crate hooks into this pattern using attribute macros. You write the rules right on the struct fields. The macro generates a validate() method that checks every rule when you call it.
Think of it like a form with built-in checks. You don't write the check logic manually every time. You declare the constraints once, and the crate enforces them. This keeps your validation logic close to your data definition. If you change the struct, the rules move with it. You avoid the drift where the struct evolves but the validation function in some other file gets forgotten.
The crate supports common checks out of the box: email format, URL format, length constraints, range checks for numbers, regex patterns, and custom functions. It also handles nested structs, so you can validate complex payloads without writing recursive checks by hand.
Minimal working example
Start by adding the crate to your dependencies. The validator crate splits functionality into features. You almost always need the derive feature to use the attribute macros.
[dependencies]
validator = { version = "0.18", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Define a struct with #[derive(Validate)]. Add #[validate(...)] attributes to the fields that need checking. The email attribute checks format. The length attribute checks string size.
use validator::Validate;
/// Signup request with validation rules attached to fields.
#[derive(Validate, serde::Deserialize)]
struct SignupRequest {
/// Email must be valid format and within length bounds.
#[validate(email)]
#[validate(length(min = 5, max = 255))]
email: String,
/// Password must be at least 8 characters long.
#[validate(length(min = 8))]
password: String,
}
fn main() {
// Simulate parsing a JSON request body.
let json = r#"{"email": "bad", "password": "short"}"#;
let request: SignupRequest = serde_json::from_str(json).unwrap();
// Run validation rules against the struct fields.
match request.validate() {
Ok(_) => println!("Request is valid"),
Err(errors) => println!("Validation failed: {errors}"),
}
}
The validate() method returns Result<(), ValidationErrors>. If everything passes, you get Ok(()). If any rule fails, you get Err containing a collection of errors. The error type maps field names to lists of error messages, so you can tell the user exactly what went wrong.
Write the rules once. Trust the macro to enforce them.
How validation runs
When you call request.validate(), the generated code walks through every field with a #[validate] attribute. It applies the checks in order. If a field has multiple attributes, like email and length, both run. The crate collects all failures. It doesn't stop at the first error. This is useful for user-facing forms where you want to show all problems at once instead of making the user fix one error, submit again, and see the next one.
The ValidationErrors type behaves like a map. You can call .field_errors() to get an iterator over (field_name, Vec<ValidationError>) pairs. Each ValidationError has a message string and a code string. The message is human-readable. The code is a machine-readable identifier you can use for localization or frontend logic.
use validator::{Validate, ValidationErrors};
/// Convert validation errors into a structured list for API responses.
fn format_errors(errors: ValidationErrors) -> Vec<(String, String)> {
// Extract field names and their associated error messages.
errors
.field_errors()
.into_iter()
.flat_map(|(field, errs)| {
// Map each error to a tuple of field and message.
errs.iter().map(move |e| (field.clone(), e.message.clone()))
})
.collect()
}
Convention aside: in web frameworks like Axum or Actix, you typically map these errors to a JSON response with a 422 Unprocessable Entity status code. Don't panic on validation errors. They are expected failures. Return them to the client so they can fix the input.
Realistic handler with cross-field checks
Simple field checks cover most cases, but sometimes rules depend on multiple fields. For example, a password confirmation field must match the password. The validator crate supports custom validation functions that receive the entire struct. This lets you write cross-field logic while keeping the declarative style.
use validator::{Validate, ValidationError};
/// Signup request with a cross-field check for password confirmation.
#[derive(Validate, serde::Deserialize)]
struct SignupRequest {
#[validate(length(min = 8))]
password: String,
/// Confirmation must match the password field.
#[validate(custom = "passwords_match")]
password_confirmation: String,
}
/// Custom validator that checks if password and confirmation match.
fn passwords_match(s: &SignupRequest) -> Result<(), ValidationError> {
if s.password != s.password_confirmation {
// Return a validation error attached to the field being validated.
return Err(ValidationError::new("passwords do not match"));
}
Ok(())
}
fn main() {
let json = r#"{"password": "secure123", "password_confirmation": "wrong"}"#;
let request: SignupRequest = serde_json::from_str(json).unwrap();
match request.validate() {
Ok(_) => println!("Valid"),
Err(errors) => {
// Print detailed errors for debugging.
for (field, errs) in errors.field_errors() {
for err in errs {
println!("Field '{}': {}", field, err.message);
}
}
}
}
}
The custom function takes a reference to the struct. It returns Result<(), ValidationError>. If it returns an error, the crate attaches it to the field that triggered the custom check. You can also create errors for other fields manually, but the convention is to attach the error to the field where the user made the mistake. In this case, the mismatch is reported on password_confirmation.
Nested structs need explicit opt-in. If your struct contains another struct that derives Validate, the validator ignores it unless you add #[validate(nested)]. This prevents accidental validation of internal state or optional sub-objects that shouldn't be checked in every context.
Check your nested structs. The validator skips them unless you ask.
Pitfalls and compiler signals
The most common mistake is assuming validation happens automatically. It doesn't. The macro only generates the validate() method. You must call it. If you forget, your code compiles and runs, but bad data slips through. The compiler won't warn you. You have to add the call in your handler or service layer.
If you try to call .validate() on a struct without #[derive(Validate)], the compiler rejects you with E0599 (no method named validate found for struct). This is a clear signal that the derive macro is missing or the feature flag isn't enabled.
Another pitfall is mixing parsing errors with validation errors. If you use serde to deserialize JSON, you get a parsing error if the JSON is malformed or a field is missing. Validation runs after parsing. If the JSON is valid but the data violates rules, you get validation errors. Keep these separate in your error handling. Parsing errors usually mean 400 Bad Request. Validation errors usually mean 422 Unprocessable Entity.
Regex patterns in #[validate(regex(path = "..."))] are compiled at runtime the first time they run. The crate caches the compiled regex, so there's no performance penalty on subsequent calls. Just ensure the regex path points to a valid static string. If the path is wrong, you get a compile-time error from the macro.
Convention aside: keep validation logic in the same module as the struct. If validation rules are scattered across files, they drift. Co-locate the struct, the derive, and the custom validator functions. It makes refactoring safer.
Choosing your validation strategy
Rust offers several ways to validate data. Pick the tool that matches the complexity of your rules and the stage where you want to reject bad input.
Use the validator crate when you have a struct with multiple fields and need declarative rules like length, email, URL, or regex. Use manual validation functions when the rules are complex, depend on external state like a database lookup, or involve cross-field logic that attributes can't express cleanly. Use serde's deserialize_with for validation when you want to reject invalid data immediately during JSON parsing, though this mixes parsing errors with validation errors and makes error reporting harder. Use custom new-type wrappers when you want to enforce validity at the type level so that a valid value can never be constructed in an invalid state.
If a value is invalid, the type system should make it impossible to exist.