JSON Toolkit

When JSON Schema Errors Make No Sense: Ajv Error Location and Message Governance

Bottom line: A validator emits machine errors; users want to know which input box is wrong. Covers the Ajv errors array, instancePath versus schemaPath, grouping and wording.

Advanced~10 min readPublished 2026-10-31Updated 2026-10-31
Diagram of Ajv validation error location and message governance

Bottom line: read instancePath first to locate the failing data, then schemaPath to locate the rule that fired; for required and additionalProperties, pull the real key name from params before choosing the message.

The validator's job is to say which rule failed. Translating that into "this input box is wrong" is your job.

1. What the errors array actually gives you

Ajv does not throw on validation failure; it leaves the result in validate.errors. The default configuration keeps only the first error, and allErrors is what produces a full list. A single error object has five fields that matter, and understanding them is the premise for everything downstream:

Field Meaning Example
instancePath JSON Pointer to the failing value /email
schemaPath Location of the failing keyword in the schema #/properties/email/format
keyword Name of the violated constraint format
params Machine-readable parameters of that constraint { "format": "email" }
message English prose that changes between versions must match format "email"

A real error looks like this:

[
  {
    "instancePath": "/email",
    "schemaPath": "#/properties/email/format",
    "keyword": "format",
    "params": { "format": "email" },
    "message": "must match format \"email\""
  }
]

Note that message is prose written for humans, not a stable interface. Ajv may reword it between versions, and it cannot serve a localized product. Never parse it with a regular expression, and never return it verbatim to a client.

2. instancePath and schemaPath answer different questions

instancePath says which slice of the data failed; schemaPath says which rule fired. They are two coordinate systems, and they are not symmetric:

  • instancePath is an RFC 6901 JSON Pointer. An empty string means the root; /items/0/price means the price field of the first array element.
  • schemaPath starts with # and points to the keyword inside the schema document. Across a $ref, it points inside the referenced schema, so you need the $id to know which file it came from.
  • A key containing / or ~ is escaped to ~1 and ~0 respectively. Undo that before display, or the UI will show puzzling symbols.

For example, instancePath: "/email" together with schemaPath: "#/properties/email/format" means the email field on the root object did not pass the format check.

The most common trap is required and additionalProperties: their instancePath points at the parent object, not at the offending key.

Keyword instancePath points to Where the real key lives
required The parent object (empty string at root) params.missingProperty
additionalProperties The parent object params.additionalProperty
type The failing value itself —
enum The failing value itself params.allowedValues

When mapping errors onto form fields, handle these two cases first. Otherwise "email is missing" becomes "root object error", and the user has no idea where to type.

3. allErrors and verbose: completeness versus cost

The default allErrors: false makes Ajv stop at the first failure. That is fine for a command-line tool and disastrous for a form: the user fixes one field, submits, sees the next error, and repeats three times before the form passes.

Setting Behaviour Benefit Cost
allErrors: false Only the first error Fastest, smallest memory Repeated submissions
allErrors: true Collect every error All problems in one pass Error count explodes on complex schemas
verbose: true Attach schema and data to each error Easier server-side diagnosis Greatly increased size; never expose it
messages: false Drop the message field Smaller payloads Lose the English debugging clue

Recommended split: enable allErrors: true for server validation and keep the full array in internal logs, while the public response carries only whitelisted fields. Turn on verbose only while investigating; it embeds the matched schema fragment and the user's data into every error, which costs memory and can leak sensitive values into logs.

Error volume is the other common problem. A failed anyOf branch emits several child errors plus one summary error, and oneOf with many branches is worse. Show the array as-is and users see five near-duplicate messages for one field. Grouping has to happen before rendering.

4. Group errors by field

Grouping does three things: turn the path back into a field name, correct the offset of required and additionalProperties, and merge multiple errors on the same field into the single most actionable message.

/** One validation error ready for display */
interface FieldError {
  field: string;
  keyword: string;
  params: Record<string, unknown>;
}

/**
 * Group Ajv errors by field.
 * @param errors Error array produced by Ajv
 * @returns Map keyed by field path, each value listing its errors
 */
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
  const grouped: Record<string, FieldError[]> = {};

  for (const errorItem of errors) {
    const keyword: string = errorItem.keyword;
    let field: string = errorItem.instancePath;

    if (keyword === 'required') {
      const parentPath: string = errorItem.instancePath;
      field = `${parentPath}/${errorItem.params.missingProperty}`;
    } else if (keyword === 'additionalProperties') {
      field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
    }

    const normalizedField = field === '' ? '(root)' : field;
    (grouped[normalizedField] ??= []).push({
      field: normalizedField,
      keyword,
      params: errorItem.params ?? {},
    });
  }

  return grouped;
}

Merge with a priority order: required (missing) first, then type, then enum/const, then format/pattern, then length and range. Show only the highest-priority message per field and keep the rest in logs. The reason is straightforward: when the type is wrong, a length message is noise, and fixing the type is the only sensible next step.

Summary errors for oneOf and anyOf usually carry no actionable information. Skip them during rendering and keep only the branch errors that point at a concrete field.

5. Custom messages: the plugin route and the template route

Ajv ships no localized wording, so there are two practical options.

Option one: the ajv-errors plugin. It adds an errorMessage keyword so messages live next to the constraints:

{
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "errorMessage": {
        "format": "Please enter a valid email address",
        "type": "Email must be a string"
      }
    }
  }
}

The advantage is that wording travels with the field and the schema doubles as documentation. The drawback is that the text is frozen in one language, so localization means maintaining several schemas. The plugin also only replaces every message when allErrors: true is set; forget that flag and you get a mix of custom and default English.

Option two: a keyword-to-template table. The validator supplies only keyword and params; the application produces the wording:

Keyword Template Available parameters
required Missing required field: {field} missingProperty
type Expected type: {type} type
minLength Enter at least {limit} characters limit
maximum Must not exceed {limit} limit
format Invalid format: {format} format
enum Must be one of the allowed options allowedValues
pattern Value does not match the expected format pattern (do not echo it)

Option two suits products that need multiple languages and a consistent voice. It separates validation from presentation, keeps translation out of the schema, and lets the frontend reuse the same templates.

6. Localization: messages do not belong to the validator

Moving wording into language packs is the starting point of message governance. A language pack stores templates and keys, never field names; field labels come from the form configuration or the API contract, and the two are combined at render time.

/** Validation message templates keyed by Ajv keyword */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
  required: (params) => `Missing required field: ${params.missingProperty}`,
  type: (params) => `Expected type: ${params.type}`,
  minLength: (params) => `Enter at least ${params.limit} characters`,
  maximum: (params) => `Must not exceed ${params.limit}`,
  format: () => 'Invalid format',
};

/**
 * Build one user-facing validation message.
 * @param keyword Name of the failed constraint
 * @param params Constraint parameters
 * @returns Localized message text
 */
function buildMessage(keyword: string, params: Record<string, any>): string {
  const template = VALIDATION_TEMPLATES[keyword];
  return template ? template(params) : 'The value is invalid';
}

Three rules matter here:

  1. Do not concatenate sentences. "Email format is invalid" and its Chinese equivalent do not share word order, and string assembly will eventually produce nonsense in one of them.
  2. Delegate plurals and measure words to the i18n library. Never hardcode "characters" in a template; 1 character and 3 characters differ.
  3. Translate field labels too. params.missingProperty is email, while the UI should say "Email address". Keep that mapping in form configuration, not in the language pack.

7. Do not return internal structure to clients

Returning schemaPath, params, and message together is convenient during development and an information leak in production. allowedValues reveals internal enums, pattern reveals validation regexes, and schemaPath reveals file layout and field naming — in some cases handing attackers a map of your weak spots.

The public response keeps exactly three things: a field path (or form field name), a stable error code, and user-facing text. Use short stable codes instead of the raw keyword so you retain room to change validators later.

{
  "type": "about:blank",
  "title": "Request validation failed",
  "status": 422,
  "errors": [
    { "field": "email", "code": "invalid_format", "message": "Please enter a valid email address" },
    { "field": "items[0].price", "code": "invalid_pattern", "message": "Amount must have exactly two decimals" }
  ]
}

Server logs should hold the opposite: the full error array, the request id, and the schema version, so an investigation can answer "which rule blocked this". Avoid dumping whole request bodies into logs; keep the failing paths and keywords instead.

8. Frontend presentation: from field highlight to editor markers

Errors eventually land in a UI, and the presentation depends on the context:

Context Presentation Key action
Form Red text under the field, focus the first error Map paths to form names; keep array indices
Bulk table import Row number, column name, summary, exportable Turn /items/3/price into row 4, price column
JSON editor Inline markers and a problems panel Use a parser to map JSON Pointer to line and column
API debugging tool Structured error list retaining schemaPath Developer-facing, so more detail is acceptable

Three implementation notes:

  • Show only the first error per field, collapsing the rest into "2 more issues", so the form does not drown in red.
  • Keep array indices for targeting but drop them in labels. "Row 4, price" reads better than /items/3/price.
  • Editor scenarios need position mapping. A plain string search is unreliable when a field name repeats; the parser should provide offsets for each node, which you then convert to line and column.

If the JSON comes from a user typing rather than a form, confirm the document is syntactically valid first. Syntax errors happen during parsing and never reach schema validation. JSON Parse & Format reports the exact line and column, which is much faster than chasing schema errors. For how over-strict schemas arise when they are inferred from samples, see Why Auto-Generated JSON Schema Keeps Rejecting Real Data; for validator configuration and the keyword system, see JSON Schema: From Basics to Production.

9. Frequently asked questions

Why do I only get one error?

allErrors is false by default, so Ajv returns at the first failure. Turn it on explicitly for form validation; otherwise users must submit repeatedly to discover every problem.

Why is the path on a required error empty?

instancePath for required points at the parent object, which is the empty string at the root; the missing key name sits in params.missingProperty. Concatenate the two to get the full path. Showing instancePath alone yields the useless "root object error".

Can I show message directly to users?

Not advisable. It is English prose whose wording changes between versions, and it has no localization capability. Generate text from keyword and params instead and reserve message for logs.

Why is my custom error message ignored?

First check that allErrors: true is enabled; ajv-errors can only replace all messages with that flag. Then confirm errorMessage sits at the same level as the failing keyword — one level off and it will not be recognized.

Which fields are safe to return in production?

Field path, stable error code, and user-facing text. Keep schemaPath, params, and message in server logs: that supports debugging without exposing internal enums, regexes, or file layout.