Skip to main content

Using pattern and format Keywords Safely

Symptom: a schema declares format: email and a payload containing "not-an-email" sails through validation. Or the opposite: a schema with a plausible-looking pattern turns one request into a thirty-second CPU spin that takes the whole process with it. Both are string-constraint problems, and both are avoidable. This guide is part of JSON Schema Keywords for API Payloads.

Root Cause 1: format Is an Annotation

The JSON Schema specification classifies format as annotation-only. A validator is permitted to assert it and is not required to, and the common default is not to. Nothing in the document tells you which mode you are in — you have to look at how the validator was constructed.

// This validator IGNORES every format keyword in every schema.
const lax = new Ajv2020({ strict: true });

// This one asserts them.
import addFormats from "ajv-formats";
const strict = addFormats(new Ajv2020({ strict: true }), { mode: "full" });
const schema = { type: "string", format: "email" };
lax.compile(schema)("definitely not an email");     // true  ← silently accepted
strict.compile(schema)("definitely not an email");  // false
One schema, two verdicts An identical schema and payload are evaluated by two validators. Without the formats package the format keyword is ignored and the value is accepted; with it registered the same value is rejected. {"type":"string", "format":"email"} value: "nonsense" no formats package format is decoration ajv-formats registered format asserts accepted rejected

Even with assertion on, format checks are deliberately forgiving. format: email accepts addresses no mail server would deliver to, and format: uri accepts strings that are technically URIs and useless as endpoints. Treat it as a documented intent plus a coarse filter, never as proof.

Root Cause 2: A Pattern Can Be a Denial of Service

Regular expression engines backtrack. A pattern with nested quantifiers over overlapping alternatives can be driven into exponential time by an input crafted for it — and the input does not have to be long.

# DANGEROUS: nested quantifier over an overlapping class.
sku:
  type: string
  pattern: '^([A-Z]+)+$'      # "AAAAAAAAAAAAAAAAAAAAAAAAAAAAA!" hangs the matcher
# SAFE: one quantifier, anchored, with a hard length ceiling.
sku:
  type: string
  maxLength: 32
  pattern: '^[A-Z0-9-]{3,32}$'

Three habits remove the class of problem entirely:

  • Anchor with ^ and $. An unanchored pattern makes the engine try every starting offset.
  • Never nest quantifiers. (x+)+, (x*)* and (a|a)+ are the shapes to recognise.
  • Always pair with maxLength. Even a well-formed pattern is linear in input length; a ceiling bounds the worst case regardless of what the pattern does.

The maxLength habit is the same defensive reflex as bounding arrays in validating deeply nested JSON payloads — a cheap ceiling on how much work one request can demand.

What backtracking costs Two curves against input length: the nested-quantifier pattern's matching time rises sharply and leaves the chart, while the anchored single-quantifier pattern stays flat and linear. input length (characters) match time ^([A-Z]+)+$ 30 characters is enough ^[A-Z0-9-]{3,32}$ with maxLength flat, and bounded by construction

Pair Every format With Something Enforceable

The reliable pattern is to keep format for its documentation and tooling value, and put a constraint next to it that asserts whatever the validator is configured to do.

components:
  schemas:
    Subscriber:
      type: object
      required: [email, website, joined_at, plan_code]
      additionalProperties: false
      properties:
        email:
          type: string
          format: email          # intent, plus assertion when enabled
          maxLength: 254         # RFC ceiling — enforced unconditionally
        website:
          type: string
          format: uri
          maxLength: 2048
          pattern: '^https://'   # our actual rule: TLS only. Enforced always.
        joined_at:
          type: string
          format: date-time
          # date-time is one of the few formats worth relying on, because the
          # grammar is unambiguous and every implementation agrees on it.
        plan_code:
          type: string
          maxLength: 24
          pattern: '^[a-z]{2,10}_[0-9]{2,6}$'   # no format exists for this — pattern only

website shows the useful division: format: uri says what kind of thing it is, and pattern: '^https://' encodes the rule you actually enforce. A reviewer reading the schema learns both.

Formats Worth Trusting, and Formats to Treat With Care

Format Assertion quality Use it
date-time strong — unambiguous grammar yes, freely
date, time strong yes
uuid strong — fixed shape yes
ipv4, ipv6 strong yes
uri moderate — accepts more than you mean plus a pattern
email weak — permissive by design plus maxLength; verify by sending
hostname moderate plus a pattern if you need a real host
regex strong, but validates the pattern, not a value rarely
duration moderate, unevenly implemented test your validator
custom values none — silently ignored use pattern instead

The last row is worth dwelling on: an unknown format value is ignored rather than rejected, so format: sku-code looks like a constraint and does nothing at all. If no standard format fits, write a pattern.

How much of the rule each format actually asserts date-time and uuid have unambiguous grammars and assert almost the whole rule; uri and hostname assert a fraction; email asserts very little; a custom format asserts nothing at all. Share of the real constraint the format keyword covers date-time uuid uri email a custom value nothing — unknown formats are ignored

Verification

The tests that matter here are the rejection tests, because every failure mode in this guide manifests as something being accepted that should not be.

// string-constraints.test.ts
const validate = ajv.compile(subscriberSchema);

const base = { email: "a@b.com", website: "https://example.com",
               joined_at: "2026-07-29T10:00:00Z", plan_code: "pro_2026" };

test("format assertion is switched on", () => {
  // Fails loudly if someone constructs the validator without ajv-formats.
  expect(validate({ ...base, email: "not-an-email" })).toBe(false);
  expect(validate.errors![0].keyword).toBe("format");
});

test("the https rule is enforced regardless of format support", () => {
  expect(validate({ ...base, website: "http://example.com" })).toBe(false);
  expect(validate.errors![0].keyword).toBe("pattern");
});

test("oversized input is rejected before the pattern runs", () => {
  expect(validate({ ...base, plan_code: "x".repeat(5000) })).toBe(false);
  expect(validate.errors!.map((e) => e.keyword)).toContain("maxLength");
});

test("a pathological input completes quickly", () => {
  const started = Date.now();
  validate({ ...base, plan_code: "A".repeat(64) + "!" });
  expect(Date.now() - started).toBeLessThan(50);      // linear, not exponential
});

That first test is the one to keep forever. It is the only thing standing between you and a silent downgrade the day someone refactors the validator construction.

Edge Cases and Caveats

  • Unicode and maxLength. JSON Schema counts characters, not bytes, so a 254-character limit can still be a kilobyte of UTF-8. If a downstream column is byte-limited, enforce bytes in application code as well.
  • Case sensitivity. pattern is case-sensitive and JSON Schema has no inline flags, so ^[a-z]+$ genuinely rejects uppercase. Encode both cases explicitly rather than assuming a flag you cannot set.
  • Regex dialect drift. JSON Schema specifies ECMA-262. Validators in other languages approximate it, so lookbehind, named groups and unicode property escapes may behave differently in a Python or Go validator reading the same document.
  • Generated clients ignore both keywords. Neither pattern nor format narrows a TypeScript type — a constrained string is still string. Runtime validation is the only enforcement on the consumer side, which is one more reason to derive Zod schemas from the same document.
  • Error messages leak the pattern. A validation error containing the full regex is fine internally and noisy for API consumers. Map pattern failures to a human sentence in the error contract rather than echoing the expression.

Constraining Strings Without Over-Constraining Them

The opposite failure to an unenforced format is a pattern so specific that it rejects legitimate values. It is less common and harder to fix, because by the time it surfaces a caller has already been turned away.

Names, addresses and cities should almost never carry a character pattern. Apostrophes, hyphens, non-Latin scripts and single-character names are all real, and a pattern written from one locale’s assumptions excludes real customers. Bound the length and stop there.

Postcodes and phone numbers vary by country in ways no single expression captures. Validate them per country if you must validate them at all, and prefer a length bound plus a downstream check over a regex that silently excludes one market.

Identifiers you issue are the case where a strict pattern is right: you control the format, the pattern documents it, and a value that does not match is genuinely invalid.

The useful test is whether a rejected value would be a mistake by the caller or a limitation in your expression. For an identifier, always the former. For a human name, almost always the latter.

The Two Habits Worth Keeping

Everything above reduces to two habits. Enable format assertion deliberately and keep a test that proves it is on, because the alternative is a schema that documents a constraint it does not enforce. And put a maxLength on every string, because it costs one line and bounds the worst case of any pattern you write today or later.

Frequently Asked Questions

Is format enforced by default?

No. In JSON Schema, format is an annotation unless the validator is configured to assert it. Ajv needs the ajv-formats package registered before format checks anything at all, and several other validators behave the same way.

Should I use format or pattern for an email address?

Both, for different reasons. format: email documents the intent and drives tooling; a maxLength keeps the input bounded whether or not format assertion is on. Neither proves an address exists — only sending mail to it does that.

What makes a regular expression dangerous in a schema?

Nested quantifiers over overlapping alternatives, such as (a+)+ or (\s*)*. On a crafted input the matcher explores exponentially many paths and the request stops returning. Anchor the pattern, avoid nested quantifiers, and always pair it with maxLength.

Which regex dialect does JSON Schema use?

ECMA-262, so lookbehind and named groups may or may not be available depending on the runtime. Anything beyond basic character classes, anchors and simple quantifiers is worth testing in the actual validator you deploy.

Can I define my own format value?

You can, and validators ignore unknown formats by default rather than failing. That makes a custom format documentation-only unless you register an implementation, so a pattern is usually the more honest choice.