Input Validation: Schema-First Examples in 3 Languages
Validate user input at the API boundary with schema libraries. Pydantic, Zod, and Jakarta Validation examples with sanitization and structured errors.
Overview
Every injection attack, corrupted record, and confusing 500 starts the same way: data entered the system in a shape nobody checked. Input validation rejects malformed, hostile, or nonsense data at the boundary — before it reaches business logic, the database, or an error log that tells you nothing.
The practical answer in 2025 is schema-first validation: declare the shape once (“a name is a non-blank string up to 100 chars, an age is an integer 0–150”), let a library do the checking, and get structured, field-level errors for free. This recipe shows the same UserCreate schema in the three most-used stacks: Pydantic (Python), Zod (JavaScript/TypeScript), and Jakarta Validation (Java).
When to Use
Use schema validation when:
- Accepting data from APIs, forms, or file uploads
- Parsing external data (JSON, CSV, XML) before processing
- Enforcing business rules on incoming payloads (age ranges, allowed values)
- Converting untrusted strings into typed values safely
- Returning field-level errors that a UI can render next to each input
For deeper sanitization work — escaping HTML, stripping control characters, normalizing Unicode — pair validation with input sanitization and see the Security Guide for the full defense stack.
When NOT to Use
- Internal boundaries between two services your team controls, where the producer is already typed and tested
- Prototypes where a shared TypeScript interface is enough — add server validation before real users touch it
- Latency-critical paths that re-validate the same payload at every layer; validate once at the edge and pass typed objects inward
- When a database constraint alone is the honest boundary (e.g., a uniqueness rule — the DB owns it; the schema just makes errors friendlier)
Solution
Python (Pydantic v2)
from pydantic import BaseModel, EmailStr, Field, ValidationError, field_validator
class UserCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
age: int = Field(ge=0, le=150)
bio: str | None = Field(default=None, max_length=500)
@field_validator('name')
@classmethod
def name_must_not_be_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError('name cannot be blank')
return v.strip()
# Usage
try:
user = UserCreate(name="Ada Lovelace", email="ada@example.com", age=36)
print(user.model_dump())
except ValidationError as e:
for err in e.errors():
print(f"{'.'.join(map(str, err['loc']))}: {err['msg']}")
Requires pip install pydantic[email] for EmailStr.
JavaScript (Zod)
const { z } = require('zod');
const UserCreate = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
bio: z.string().max(500).optional(),
});
// Throws on invalid input
try {
const user = UserCreate.parse({
name: 'Ada Lovelace',
email: 'ada@example.com',
age: 36,
});
console.log(user);
} catch (e) {
console.error('Validation error:', e.issues);
}
// safeParse returns a result object instead of throwing
const result = UserCreate.safeParse({ name: '', email: 'bad' });
if (!result.success) {
for (const issue of result.error.issues) {
console.error(`${issue.path.join('.')}: ${issue.message}`);
}
}
Java (Jakarta Validation)
import jakarta.validation.*;
import jakarta.validation.constraints.*;
import java.util.Set;
public class UserCreate {
@NotBlank
@Size(max = 100)
private String name;
@NotBlank
@Email
private String email;
@Min(0)
@Max(150)
private int age;
@Size(max = 500)
private String bio;
public UserCreate(String name, String email, int age, String bio) {
this.name = name; this.email = email;
this.age = age; this.bio = bio;
}
public String getName() { return name; }
public String getEmail() { return email; }
public int getAge() { return age; }
public String getBio() { return bio; }
}
// Usage
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
UserCreate user = new UserCreate("Ada Lovelace", "ada@example.com", 36, null);
Set<ConstraintViolation<UserCreate>> violations = validator.validate(user);
for (ConstraintViolation<UserCreate> v : violations) {
System.out.println(v.getPropertyPath() + ": " + v.getMessage());
}
How It Works
The flow has two gates, and their order matters. Validation asks “is this the right shape?” — wrong shapes get rejected with a 400 and field-level errors. Sanitization asks “is this clean?” — trimming whitespace, escaping HTML, normalizing Unicode happens on data that passed the shape check, before the handler stores or renders it.
Three properties make schema libraries worth the dependency:
- The schema is the contract. Constraints live next to the fields they govern, so “what does this endpoint accept?” has one answer in one file — not scattered
ifstatements. - Coercion is explicit. Pydantic and Zod can turn
"36"into36, but only where you allow it. Implicit coercion is how"0"becomes falsy and ghosts through anif (age)check. - Errors aggregate.
e.errors()/result.error.issues/ConstraintViolations return all violations, not the first one — users fix three fields in one round trip instead of three.
Validation isn’t sanitization, and neither one is security on its own. A schema that accepts <script> as a bio is valid and dangerous. Sanitize output per context (sanitize-user-input), parameterize queries (sql-injection-prevention), and never put user input in an error message without escaping it.
Variants
| Language | Library | Style | Good for |
|---|---|---|---|
| Python | Pydantic v2 | Typed model classes | FastAPI, data pipelines |
| JS/TS | Zod | Schema builder with TS inference | Express, tRPC, shared client/server types |
| Java | Jakarta Validation | Annotations + Validator | Spring/Jakarta EE, bean-style models |
| Any | JSON Schema | Language-agnostic contract | Public APIs, codegen — see validate-json-schema |
What Works
- Validate at the boundary. Controllers/middleware reject bad input; the service layer receives typed objects, not raw strings.
- Prefer allowlists over catch-alls.
z.enum(['draft','published'])beatsz.string()+ a comment saying “should be one of…”. - Return structured errors.
{"fields": {"email": "Invalid email"}}beats a prosedetailstring — see handle-errors for response shapes. - Re-validate server-side always. Client-side validation is UX, not security.
- Keep schemas in shared modules. If the same shape is validated in two endpoints, it lives in one file — two copies drift.
Companion code: The input-validation companion repo has runnable Pydantic v2 and Zod examples plus tests covering the error paths.
Common Mistakes
- Trusting client-side validation. It improves UX; it stops nothing — anyone with
curlbypasses it. - Regex where a schema belongs. Regex is great for formats inside a field (regular-expressions); use it for patterns, not for parsing structured payloads.
- Coercion surprises.
age: "abc"failing is expected;age: "36"silently becoming36— or staying a string — depends on the library’s strictness mode. Know yours. - Leaking internals in error messages. “Column
users.emailviolates unique constraint” maps your schema for an attacker; return field-level messages, log the detail. - Validating too late. Checking after
db.save()leaves partial state; validate before any mutation.
Further Reading
- Pydantic — Validators —
field_validator, model validators, strict mode. - Zod — Basic usage — schema composition,
safeParse, error formatting. - Jakarta Validation 3.0 specification — the constraint annotations used above.
- OWASP Input Validation Cheat Sheet — the security-side checklist.
Frequently Asked Questions
Should I validate in the controller or the service layer?
At the boundary — controller or middleware. The service layer should receive objects it can trust; if your service re-validates everything, your boundary validation isn't doing its job.
How do I validate nested objects and arrays?
All three libraries compose: Pydantic nests BaseModels, Zod nests z.object()s and z.array()s, Jakarta cascades with @Valid on nested fields. Define the child schema once and reference it.
What is the difference between validation and sanitization?
Validation checks whether input meets constraints and rejects it if not. Sanitization transforms input — escaping HTML, trimming whitespace, normalizing Unicode — so it's safe to store or render. Validate first, sanitize second, and don't conflate "rejected" with "cleaned".
Which library should I pick per stack?
Pydantic for Python (and it comes free with FastAPI), Zod for TypeScript-first JS projects, Jakarta Validation for Java/Spring. For a language-agnostic public API contract, JSON Schema.
What status code should validation errors return?
400 Bad Request is the standard; 422 Unprocessable Content when the syntax is valid JSON but the semantics fail — common with schema validation. Pick one and keep it consistent across endpoints.
Related Resources
Handle API Errors with RFC 7807 and HTTP Status Codes
Patterns for consistent, predictable API error handling across multiple languages and frameworks.
RecipeRegular Expressions
How to use regular expressions for pattern matching, validation, and text extraction across Python, JavaScript, and Java.
RecipeSanitize User Input
How to sanitize and validate user input in Python, Java, and JavaScript to prevent injection attacks.
RecipePrevent SQL Injection Attacks
How to write parameterized queries and use ORMs to eliminate SQL injection vulnerabilities across Python, JavaScript, and Java.
RecipeSchema-Based Data Validation with Zod in TypeScript
Validate and sanitize incoming data using Zod schemas with TypeScript inference, custom refinements, and error formatting for reliable API and form validation
RecipeAPI Versioning
How to version REST and GraphQL APIs to maintain backward compatibility while evolving your interface.