Acumatica · Schema

Schema Validation for APIs

API schema validation catches malformed requests at the boundary, before they touch business logic — the real decision is whether your schema is just a contract (JSON Schema, OpenAPI) or the thing enforcing shape at runtime (Zod, Joi, Pydantic).

John Kihiu12 min read

Most API bugs I've debugged that looked like "business logic is wrong" turned out to be "the input was never the shape the code assumed." A string where an integer was expected, a missing field that a downstream function accessed without a null check, an array where the code assumed at most one item. Schema validation at the boundary doesn't make your business logic smarter — it just guarantees that whatever reaches it actually matches what it was written against, so the bugs you do have are real bugs, not shape mismatches wearing a business-logic costume.

Why validate at the boundary

The boundary is the edge of your system — the point where a request from the outside world becomes data your code trusts. If you validate there and only there, everything past it can assume the data is well-formed: the right types, the required fields present, the enums constrained to known values. Skip that and every function three layers deep ends up doing its own defensive checks, which is slower to write, inconsistent across the codebase, and produces worse error messages because by the time the code notices something's wrong, it's lost the context of what the original request looked like. Validating at the boundary is also the only place where you can reject bad input before it does anything — before a partial write, before a queued job, before a side effect you can't undo.

A schema as contract vs a library as enforcement

JSON Schema and OpenAPI describe shape — they're documentation and static contract, not something that runs. An OpenAPI spec tells consumers a field is a required string with a max length of 120, but nothing forces a server to check that at request time unless you wire a validator into the middleware. Some frameworks do that wiring for you (Fastify validates against the JSON Schema you attach to a route; Fastify's ajv integration is a common example), but plenty of "OpenAPI-documented" APIs just have a spec that quietly drifts out of sync with what the code actually accepts, because nothing enforces it. Runtime validation libraries — Zod, Joi, Pydantic — are the opposite: the schema *is* the enforcement. You write it once, and every request either passes through it or gets rejected with a structured error before your handler ever sees it. The two aren't competing; a well-run API often generates its OpenAPI contract from the same runtime schema, so the documentation can't drift from what's actually enforced.

Zod vs Joi vs Pydantic, concretely

All three do the same job — parse untrusted input, return either validated data or a list of errors — but the ergonomics differ enough to matter day to day. Joi came first in the Node ecosystem and is still common in older Express codebases; it's a fluent builder API with no compile-time type inference, so you write the schema once and the TypeScript type separately (or not at all). Zod, by contrast, infers the static type directly from the schema — z.infer<typeof schema> gives you a TypeScript type with zero duplication, which is why most new TypeScript projects reach for it over Joi now. Pydantic is Python's equivalent, but it's a step further integrated: you define a class, not a builder chain, and that class is both your validator and your data model, with IDE autocomplete and (in Pydantic v2) a Rust-based core that made it dramatically faster than v1.

TYPESCRIPT · ZOD SCHEMA
import { z } from "zod";

const CreateOrderSchema = z.object({
  customerId: z.string().uuid(),
  items: z.array(z.object({
    sku: z.string().min(1),
    quantity: z.number().int().positive(),
  })).min(1),
  currency: z.enum(["USD", "KES", "EUR"]),
  notes: z.string().max(500).optional(),
});

type CreateOrder = z.infer;

export function handleCreateOrder(body: unknown) {
  const result = CreateOrderSchema.safeParse(body);
  if (!result.success) {
    // result.error.issues is a structured, per-field list
    return { status: 400, errors: result.error.issues };
  }
  const order: CreateOrder = result.data;
  // order.items[0].quantity is a validated number, no casting needed
}

The Python equivalent with Pydantic looks different in shape but does the same job — validation and type both come from one class definition:

PYTHON · PYDANTIC MODEL
from pydantic import BaseModel, Field, field_validator
from typing import Literal
from uuid import UUID

class OrderItem(BaseModel):
    sku: str = Field(min_length=1)
    quantity: int = Field(gt=0)

class CreateOrder(BaseModel):
    customer_id: UUID
    items: list[OrderItem] = Field(min_length=1)
    currency: Literal["USD", "KES", "EUR"]
    notes: str | None = Field(default=None, max_length=500)

    @field_validator("items")
    @classmethod
    def no_duplicate_skus(cls, items: list[OrderItem]) -> list[OrderItem]:
        skus = [i.sku for i in items]
        if len(skus) != len(set(skus)):
            raise ValueError("duplicate sku in order")
        return items

# FastAPI calls this automatically on the request body and
# returns a 422 with a field-by-field error list on failure

The practical difference that bites people: Zod's safeParse never throws, so you have to remember to check result.success; parse throws and is easy to forget in an async handler where an uncaught exception becomes a 500 instead of a 400. Pydantic always raises ValidationError on invalid input, and FastAPI catches it for you at the framework level, which is one less thing to get wrong. Joi's .validate() returns an object with an error property you have to check manually, similar to Zod's safeParse, but without the inferred TypeScript type — you're maintaining the shape twice if you want compile-time safety.

Coercion defaults differ and will surprise you

Joi coerces types by default — a query string "quantity=5" becomes the number 5 unless you turn that off. Zod does not coerce unless you explicitly opt in with z.coerce.number(). Pydantic v2 coerces less aggressively than v1 did for exactly this reason — silent coercion is a common source of bugs where a validation "passes" but changes the value's type underneath the caller. Know your library's default before you rely on it.

Validating at build time vs generating types from schema

There are two different problems that both get called "schema validation," and conflating them causes confusion. One is runtime validation: checking that an actual incoming request matches the schema, which has to happen on every request because you can't trust what arrives. The other is type generation: using the schema as the single source of truth for your TypeScript types (or Python type hints) so your editor and compiler catch mismatches before the code ships. Zod and Pydantic give you both from one definition — the schema is live at runtime and the static type is derived from it, so there's exactly one place to update when a field changes. Tools built purely around OpenAPI (like openapi-typescript) only solve the second problem: they generate types from a spec file, but nothing stops your actual server code from returning a shape that no longer matches the spec, because the generated types aren't wired into request handling. If you want both guarantees, you need either a runtime-schema-first approach (Zod/Pydantic generating the contract) or a spec-first approach where the generated types are paired with a validator that actually runs against the spec at request time — not just at build time.

Error message design for API consumers

A validation error is the first thing a client developer sees when they get your API wrong, and it's often the only debugging signal they have — they don't have your server logs. "Validation failed" with a 400 and nothing else means the other team pings you on Slack instead of fixing it themselves. Zod's error.issues and Pydantic's ValidationError.errors() both give you a list of objects with a field path, an error code, and a message — { path: ["items", 0, "quantity"], message: "Number must be greater than 0" } — and the API layer's job is to pass that structure through, not collapse it into a single string. Include the field path so a client can highlight the right form input. Avoid leaking internal schema details that don't help the consumer (a raw Zod issue sometimes includes the exact expected/received type in a phrasing that assumes the reader knows Zod's type system) — a thin mapping layer between your validator's native error format and your API's public error format is worth the extra few lines, because your validation library's internals are not a public contract you want to be stuck supporting.

Wrapping up

The choice between JSON Schema/OpenAPI and a runtime library isn't really either/or — a contract without enforcement drifts, and enforcement without a shareable contract leaves API consumers guessing. Pick a runtime validator that matches your language's ecosystem (Zod for TypeScript, Pydantic for Python, Joi if you've already got it and migrating isn't worth the churn), generate the contract from it rather than hand-maintaining two sources of truth, and spend real effort on the shape of your error responses — that's the part consumers of your API actually interact with. If you're weighing this for a specific stack, reach out or browse the rest of the blog.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.