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
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.
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.
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.
patternis 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
patternnorformatnarrows a TypeScript type — a constrained string is stillstring. 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.