StackPractices
beginner By Mathias Paulenko

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.

Topics: api

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

flowchart diagram: Request

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 if statements.
  • Coercion is explicit. Pydantic and Zod can turn "36" into 36, but only where you allow it. Implicit coercion is how "0" becomes falsy and ghosts through an if (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

LanguageLibraryStyleGood for
PythonPydantic v2Typed model classesFastAPI, data pipelines
JS/TSZodSchema builder with TS inferenceExpress, tRPC, shared client/server types
JavaJakarta ValidationAnnotations + ValidatorSpring/Jakarta EE, bean-style models
AnyJSON SchemaLanguage-agnostic contractPublic 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']) beats z.string() + a comment saying “should be one of…”.
  • Return structured errors. {"fields": {"email": "Invalid email"}} beats a prose detail string — 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 curl bypasses 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 becoming 36 — or staying a string — depends on the library’s strictness mode. Know yours.
  • Leaking internals in error messages. “Column users.email violates 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

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.