How to validate JSON

Syntactic vs structural validation, with recipes for TypeScript (Zod), Python (Pydantic), Go, and language-agnostic JSON Schema.

6 min read

"Valid JSON" means two very different things depending on who's asking. Sometimes the question is syntactic — does this string parse? Sometimes it's structural — does this object have the fields my code expects? Getting these confused is one of the most common sources of production bugs. Here's how to handle both, in whichever language you're using.

Level 1: is it parseable?

The cheapest check. If JSON.parse (or your language's equivalent) throws, the string isn't JSON. That's it.

// JavaScript / TypeScript
function isJsonSyntax(s: string): boolean {
  try { JSON.parse(s); return true; }
  catch { return false; }
}

For a quick eyeball check while debugging, paste it into the JSON formatter — you'll get a pretty-printed version if it parses and a clear error message pointing at the offending character if it doesn't.

Level 2: does it match a shape?

This is what people usually mean by "validate". Syntax says nothing about whether email is a string or age is a positive integer. For that you need a schema.

TypeScript / JavaScript — Zod

import { z } from "zod";

const User = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  roles: z.array(z.enum(["admin", "user"])).min(1),
});

// Throws on invalid input, returns typed value on success.
const user = User.parse(await res.json());

// Non-throwing variant returns a discriminated union.
const result = User.safeParse(data);
if (!result.success) console.error(result.error.flatten());

Zod is the modern default. Valibot and ArkType are lighter-weight alternatives with similar APIs. All three give you a runtime validator and a TypeScript type from a single definition.

Language-agnostic — JSON Schema + Ajv

import Ajv from "ajv";

const schema = {
  type: "object",
  properties: {
    id: { type: "string", format: "uuid" },
    email: { type: "string", format: "email" },
    age: { type: "integer", minimum: 0, maximum: 150 },
  },
  required: ["id", "email"],
  additionalProperties: false,
};

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
if (!validate(data)) console.error(validate.errors);

Use JSON Schema when the schema needs to be portable across languages — an OpenAPI spec, a shared config format, or a document validated by both a JS frontend and a Python backend. Bootstrap one from a sample with the JSON to JSON Schema tool.

Python — Pydantic

from pydantic import BaseModel, EmailStr, Field
from uuid import UUID

class User(BaseModel):
    id: UUID
    email: EmailStr
    age: int = Field(ge=0, le=150)
    roles: list[str]

user = User.model_validate(payload)  # raises ValidationError on bad data

Go — encoding/json + validator

type User struct {
    ID    string `json:"id" validate:"required,uuid4"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"gte=0,lte=150"`
}

var u User
if err := json.Unmarshal(body, &u); err != nil { /* syntax error */ }
if err := validate.Struct(u); err != nil { /* semantic error */ }

Skeleton generated by the JSON to Go struct tool; add the validate tags by hand.

Where to validate — at the boundary, once

Validate exactly once, as early as possible: at the HTTP handler, queue consumer, or config loader. After that boundary, trust the parsed type. Repeating validation in every function that touches the data is noise and creates opportunities for drift.

Errors your users can act on

  • Return the path to the offending field (body.user.email), not just "invalid".
  • Return the expected shape — "expected string, got number".
  • Report all errors on one pass (allErrors: true in Ajv, abortEarly: false in Yup) so users don't fix one and hit the next.
  • Never leak the raw stack trace to clients — log it, return a stable error code.

Common pitfalls

  • Trusting Content-Type: application/json. Anyone can send that header with any payload. Always parse.
  • Casting instead of parsing. data as User in TypeScript is not validation — it's a promise to the compiler that you already validated. If you didn't, the compiler can't help.
  • Forgetting additionalProperties: false. Without it, JSON Schema silently accepts extra fields. Usually fine, sometimes a security issue (mass assignment).
  • Validating query strings with a JSON validator. Query strings are always strings — coerce first, or use a validator with coercion built in.

TL;DR

  1. Parse the string. If it throws, it's not JSON.
  2. Run it through a schema (Zod, Pydantic, Ajv). If it fails, reject with a useful error.
  3. Trust the resulting typed value inside your app.

Try the tools

Related reading