How to Validate JSON API Responses in JavaScript, Python, and Node.js
Stop your app crashing on bad API responses. Validate JSON with try/catch, JSON Schema, and Zod — copy-paste code for JavaScript, Python, TypeScript, and Node.js.
Have broken JSON right now? Fix it free in under 1 second — no signup.
Fix My JSON →Parsing an API response without validation is a ticking time bomb. The API changes a field name, returns null instead of a string, or sends back malformed JSON — and your application crashes in a confusing, hard-to-debug way. This guide covers how to validate JSON API responses properly in every major language, from basic syntax checking to full schema validation.
Two Levels of JSON Validation
There's an important distinction:
- Syntax validation — Is this valid JSON? Can I parse it at all?
- Schema validation — Is this the shape I expect? Does it have the right keys and types?
Both matter. Syntax validation prevents crashes. Schema validation catches data contract violations.
JavaScript / Node.js
Basic Syntax Validation
function safeParse(raw) {
try {
return { data: JSON.parse(raw), error: null };
} catch (e) {
return { data: null, error: e.message };
}
}
const { data, error } = safeParse(responseBody);
if (error) {
console.error('Invalid JSON:', error);
return;
}
Schema Validation with Zod (TypeScript / JavaScript)
Zod is the most ergonomic schema validation library for TypeScript. It lets you define the expected shape and get type-safe parsed output:import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
tags: z.array(z.string()).optional(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: number): Promise<User> {
const res = await fetch(/api/users/${id});
const raw = await res.json();
const result = UserSchema.safeParse(raw);
if (!result.success) {
throw new Error(Invalid user response: ${result.error.message});
}
return result.data; // fully typed as User
}
The key advantage: result.data is typed. TypeScript knows its shape. No casting required.
Schema Validation with JSON Schema
For environments where Zod isn't available, use ajv with standard JSON Schema:
import Ajv from 'ajv';
const ajv = new Ajv();
const schema = {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['id', 'name', 'email'],
};
const validate = ajv.compile(schema);
const data = JSON.parse(rawResponse);
if (!validate(data)) {
console.error(validate.errors);
throw new Error('API response does not match expected schema');
}
Python
Basic Syntax Validation
import json
def safe_parse(raw: str) -> dict | None:
try:
return json.loads(raw)
except json.JSONDecodeError as e:
print(f"Invalid JSON at line {e.lineno}, col {e.colno}: {e.msg}")
return None
data = safe_parse(response_text)
if data is None:
return # handle error
Python's JSONDecodeError gives you lineno, colno, and msg — far more useful than JavaScript's generic "SyntaxError at position N".
Schema Validation with Pydantic
Pydantic is the Python equivalent of Zod:from pydantic import BaseModel, EmailStr, ValidationError
class User(BaseModel):
id: int
name: str
email: EmailStr
tags: list[str] = []
try:
user = User.model_validate(response_json)
print(user.name) # type-safe access
except ValidationError as e:
print(e.errors())
Pydantic handles type coercion (e.g., "42" → 42 for an int field) and gives detailed error messages with field paths.
Schema Validation with jsonschema
import jsonschema
schema = {
"type": "object",
"properties": {
"id": {"type": "number"},
"name": {"type": "string"},
},
"required": ["id", "name"],
}
try:
jsonschema.validate(data, schema)
except jsonschema.ValidationError as e:
print(f"Schema error at {'.'.join(str(p) for p in e.path)}: {e.message}")
Fetch + Validation Pattern (Full Example)
Here's a complete, production-ready pattern for fetching and validating a JSON API response in TypeScript:
import { z } from 'zod';
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
price: z.number().positive(),
inStock: z.boolean(),
categories: z.array(z.string()),
});
type Product = z.infer<typeof ProductSchema>;
async function getProduct(id: string): Promise<Product> {
const res = await fetch(/api/products/${id}, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(HTTP ${res.status}: ${res.statusText});
}
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
throw new Error(Expected JSON, got ${contentType});
}
let raw: unknown;
try {
raw = await res.json();
} catch (e) {
throw new Error(Malformed JSON response: ${(e as Error).message});
}
const result = ProductSchema.safeParse(raw);
if (!result.success) {
const issues = result.error.issues.map((i) => ${i.path.join('.')}: ${i.message}).join(', ');
throw new Error(Invalid product response — ${issues});
}
return result.data;
}
This pattern:
- Handles non-2xx HTTP status codes
- Checks the Content-Type header before parsing
- Catches malformed JSON separately from schema errors
- Gives specific field-level error messages
When Validation Fails: Repair vs. Reject
When you receive malformed JSON from an API, you have two options:
Reject: Log the error, return a fallback value, and alert the API owner. This is the right approach when the API is under your control or has a defined contract. Repair: Use a JSON repair library to fix the malformed JSON and attempt a parse. This is the right approach for AI-generated JSON, user-submitted data, or third-party APIs with poor reliability.For the repair approach, you can use AI JSONMedic's JSON repair tool to repair and validate in one step before applying your schema validator.
Summary
| Language | Syntax Check | Schema Validation |
|---|---|---|
| JavaScript | try { JSON.parse(s) } | Zod, ajv |
| TypeScript | try { JSON.parse(s) } | Zod (recommended) |
| Python | json.loads(s) | Pydantic, jsonschema |
| Go | json.Unmarshal | go-playground/validator |
| Rust | serde_json::from_str | serde with derives |
The pattern is always the same: parse first (catch syntax errors), then validate the shape (catch contract violations). Never assume an API response will always match its documented schema — validate every time.
Go: Validating JSON API Responses
Go's encoding/json package handles both parsing and basic type checking through struct unmarshaling. For richer validation, combine it with go-playground/validator.
Basic Struct Unmarshaling
package main
import (
"encoding/json"
"fmt"
)
type User struct {
ID int json:"id"
Name string json:"name"
Email string json:"email"
}
func parseUser(body []byte) (*User, error) {
var user User
if err := json.Unmarshal(body, &user); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if user.ID == 0 || user.Name == "" || user.Email == "" {
return nil, fmt.Errorf("missing required fields")
}
return &user, nil
}
Go's unmarshaling silently ignores unknown fields by default. To reject unexpected keys, use json.Decoder with DisallowUnknownFields():
import (
"bytes"
"encoding/json"
)
func strictParse(body []byte, target interface{}) error {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
return dec.Decode(target)
}
Field-Level Validation with go-playground/validator
import (
"encoding/json"
"github.com/go-playground/validator/v10"
)
type Product struct {
ID string json:"id" validate:"required,uuid"
Name string json:"name" validate:"required,min=1,max=200"
Price float64 json:"price" validate:"required,gt=0"
Email string json:"email" validate:"omitempty,email"
}
var validate = validator.New()
func parseProduct(body []byte) (*Product, error) {
var p Product
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
if err := validate.Struct(p); err != nil {
return nil, err // returns field-level validation errors
}
return &p, nil
}
This pattern gives you Go's type safety for parsing plus expressive field-level constraints through struct tags.
Testing API Validation: Jest and pytest
Validation code that you never test is validation code you don't trust. Here's how to write tests for your API validation logic in both JavaScript and Python.
Jest (JavaScript / TypeScript)
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
describe('UserSchema validation', () => {
test('accepts a valid user', () => {
const result = UserSchema.safeParse({ id: 1, name: 'Alice', email: '[email protected]' });
expect(result.success).toBe(true);
});
test('rejects missing required field', () => {
const result = UserSchema.safeParse({ id: 1, name: 'Alice' }); // no email
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['email']);
});
test('rejects invalid email format', () => {
const result = UserSchema.safeParse({ id: 1, name: 'Alice', email: 'not-an-email' });
expect(result.success).toBe(false);
});
test('rejects non-number id', () => {
const result = UserSchema.safeParse({ id: 'abc', name: 'Alice', email: '[email protected]' });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['id']);
});
});
A useful pattern: test your schema against real API fixture files. Save a fixtures/user.json from an actual API call, then assert your schema accepts it. If the API changes, your fixture test catches the contract violation before production does.
import fixture from './fixtures/user.json';
test('real API fixture passes schema', () => {
const result = UserSchema.safeParse(fixture);
expect(result.success).toBe(true);
});
pytest (Python)
import pytest
from pydantic import BaseModel, EmailStr, ValidationError
class User(BaseModel):
id: int
name: str
email: EmailStr
def test_valid_user():
user = User(id=1, name="Alice", email="[email protected]")
assert user.id == 1
def test_missing_field_raises():
with pytest.raises(ValidationError) as exc_info:
User(id=1, name="Alice") # missing email
errors = exc_info.value.errors()
assert any(e["loc"] == ("email",) for e in errors)
def test_invalid_email_raises():
with pytest.raises(ValidationError):
User(id=1, name="Alice", email="not-an-email")
@pytest.mark.parametrize("payload,should_pass", [
({"id": 1, "name": "Alice", "email": "[email protected]"}, True),
({"id": 0, "name": "Alice", "email": "[email protected]"}, True), # 0 is valid int
({"id": "x", "name": "Alice", "email": "[email protected]"}, False),
({}, False),
])
def test_parametrized(payload, should_pass):
try:
User(**payload)
assert should_pass
except ValidationError:
assert not should_pass
Common Validation Pitfalls
Even with schema validation in place, these mistakes cause silent failures that are hard to debug:
1. Validating the Wrapper, Not the Items
// Only validates the array exists — not what's inside
const schema = z.object({ data: z.array(z.unknown()) });
// Correct — validates each item in the array
const schema = z.object({ data: z.array(UserSchema) });
A response with { "data": [null, null, null] } passes the first schema but will crash when you try to access item.id.
2. Trusting Optional Fields After Narrowing
const schema = z.object({
user: z.object({ name: z.string() }).optional(),
});
const result = schema.parse(data);
// TypeScript knows user might be undefined
// But developers often do: result.user.name — crash if user is absent
After parsing, treat optional fields as optional in your consuming code too.
3. Not Logging the Raw Response on Failure
// Bad — you can't diagnose what went wrong
const result = UserSchema.safeParse(await res.json());
if (!result.success) throw new Error('Invalid response');
// Good — log raw for debugging
const raw = await res.text();
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(Malformed JSON: ${raw.slice(0, 200)});
}
const result = UserSchema.safeParse(parsed);
if (!result.success) {
console.error('Schema failure, raw was:', raw.slice(0, 500));
throw new Error(Validation failed: ${result.error.message});
}
4. Using Strict Mode Without Planning for It
additionalProperties: false in JSON Schema (or .strict() in Zod) rejects any extra fields. This sounds good but breaks your validation every time the API adds a new field — which happens frequently during API evolution. Use strict mode only on your own internal contracts, not on third-party API responses.
5. Coercion Hiding Type Problems
Pydantic (Python) and some Zod modes coerce types by default: "42" becomes 42 for an int field. This is convenient but can hide upstream bugs where an API starts returning string IDs instead of numeric ones. Use strict parsing modes when you want to catch type drift early:
class StrictUser(BaseModel):
model_config = {"strict": True}
id: int
name: str
Handling API Versioning Changes
APIs change. Fields get renamed, types shift from string to object, required fields become optional. Here's how to build validation that survives versioning:
Version Detection via Response Headers
async function fetchWithVersionCheck(url: string) {
const res = await fetch(url);
const apiVersion = res.headers.get('X-API-Version') ?? '1';
const schema = apiVersion.startsWith('2') ? UserSchemaV2 : UserSchemaV1;
const raw = await res.json();
return schema.safeParse(raw);
}
Graceful Degradation for Non-Breaking Additions
When an API adds new optional fields, .passthrough() in Zod (or removing additionalProperties: false in JSON Schema) lets your validation pass while preserving the new data:
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
}).passthrough(); // allow and preserve unknown keys
Detecting Breaking Changes in CI
Add an integration test that fetches from your staging API and validates against your schema. Run it on every deploy. When the API changes in a breaking way, the CI test catches it before the change reaches production.
// In your CI integration test suite
test('staging API matches UserSchema', async () => {
const res = await fetch('https://staging-api.example.com/users/1', {
headers: { Authorization: Bearer ${process.env.TEST_TOKEN} },
});
const data = await res.json();
const result = UserSchema.safeParse(data);
expect(result.success).toBe(true);
});
Migration Strategy for Breaking Changes
When a breaking API change is unavoidable, use a union schema that accepts both old and new shapes during the transition period:
const UserV1 = z.object({ id: z.number(), fullName: z.string() });
const UserV2 = z.object({ id: z.number(), firstName: z.string(), lastName: z.string() });
const UserSchema = z.union([UserV2, UserV1]);
// Helper to normalize to V2 shape
function normalizeUser(raw: unknown) {
const result = UserSchema.safeParse(raw);
if (!result.success) throw new Error('Invalid user');
const data = result.data;
if ('fullName' in data) {
const [firstName, ...rest] = data.fullName.split(' ');
return { id: data.id, firstName, lastName: rest.join(' ') };
}
return data;
}
FAQ
How do I validate JSON API responses in JavaScript?
Use Zod for TypeScript projects (schema.safeParse(data)) or Ajv for pure JavaScript with JSON Schema. Always parse the raw response text first with JSON.parse() inside a try/catch, then apply schema validation as a second step.
What's the difference between JSON.parse() and schema validation?
JSON.parse() only checks syntax — it tells you if the string is valid JSON. Schema validation checks the shape: whether the right fields are present, whether types are correct, whether values are within expected ranges. You need both.
Should I reject or repair malformed JSON from an API?
Reject by default — log the error, return a safe fallback, and alert the API owner. Only repair (using a library like json-repair) for AI-generated JSON or legacy APIs with poor reliability where you control the consuming code and have tolerance for partial data.
How do I handle APIs that add new fields over time?
Use .passthrough() in Zod or avoid additionalProperties: false in JSON Schema for third-party API responses. This lets validation pass when new fields are added without breaking your code. Only use strict mode for your own internal API contracts.
What is the best JSON schema validation library for Node.js?
Ajv is the fastest and most feature-complete JSON Schema validator for Node.js. For TypeScript projects, Zod is more ergonomic because it generates TypeScript types from the same schema definition, giving you both runtime validation and compile-time type safety.
How do I test API validation logic in Jest?
Write unit tests for your schema directly using .safeParse() and assert on result.success. Also save real API response fixtures as JSON files and test that your schema accepts them — this catches API drift when the fixture is refreshed.
Still dealing with broken JSON?
Paste it in and get it fixed in under 1 second — free, no signup, no install. Works with ChatGPT, Claude, n8n, and any AI output.
Fix My JSON Free →Related Articles