Using JSON Schema Enums Formats and Patterns Correctly
Where Python and Node disagree on JSON Schema formats and patterns: email, uri, hostname, digits, trailing newlines, plus exact enum and const behavior.

Three keywords in JSON Schema look like they describe a fixed vocabulary of checks: enum for a list of allowed values, pattern for a regular expression and format for well-known kinds of string. In practice only enum is fully pinned down. The other two depend on the regular expression engine and on which format checkers are installed, and the same schema can accept a value in one language and reject it in another. I ran the same documents through Python's jsonschema and Node's Ajv and recorded where they disagree, so you know which checks to trust across a language boundary.
enum and const
enum lists the allowed values and a document must equal one of them. Equality is by JSON value and type:
{"enum": ["a"]} "A" rejected: 'A' is not one of ['a']
{"enum": [1]} true rejected: True is not one of [1]
{"enum": [1, "1", null, true]} 1.0 acceptedCase matters, a boolean is not the number 1, and 1.0 equals 1 because JSON Schema treats numbers by value. A string "1" and the number 1 are different, so an enum of numbers does not accept form data that arrived as text. const is a one-value enum and can hold an object: {"const": {"a": [1, 2]}} accepted {"a": [1, 2]} and rejected {"a": [2, 1]}, because array order is part of the value. Object key order is not.
Two practical points. An enum is a closed list, so adding a value later is a breaking change for any consumer that validates with the old copy (versioning a JSON Schema without breaking consumers). And a schema that must say "one of these, or something else we will log" is better written as a type: string plus a description than as an enum that rejects the unknown; the difference decides whether a new event type from a producer is dropped or flows through.
pattern: the engine decides
pattern takes a regular expression and succeeds if it matches anywhere in the string. {"pattern": "[a-z]+"} accepted "ABC1x" because the x matches. Anchor the pattern with ^ and $ to say "the whole string": ^[a-z]+$ rejected it. Forgetting anchors is the commonest pattern bug, and it makes the check pass on garbage that merely contains a valid fragment.
The specification says patterns use ECMA-262 syntax. Validators written in other languages translate or reuse their own engine, and the details differ. Three differences I measured:
| Test | Python jsonschema | Ajv |
|---|---|---|
^\d+$ on ٣٢١ (Arabic-Indic digits) | accepted | rejected |
^[a-z]+$ on "abc\n" | accepted | rejected |
(?<n>a) named group | error: unknown extension ?<n | accepted |
The first row is a real validation hole. Python's \d matches any Unicode decimal digit, so a field meant to hold an ASCII number accepts digits from other scripts, which then fail when a database or a parser expects 0 to 9. In JavaScript \d is exactly [0-9]. Write [0-9] when you mean ASCII digits and the schema behaves the same everywhere. The second row is Python's $, which matches before a trailing newline. A value like "abc\n" passes ^[a-z]+$, and the newline then ends up in a log line, a filename or a header. In ECMAScript, without the multiline flag, $ matches only at the very end. The third row is syntax: Python spells named groups (?P<n>a), and the ECMAScript form is a compile error in Python's re. Both validators read the same schema, and one stops with an exception.
The fix for all three is to keep patterns in the common subset: anchored, with explicit character classes like [0-9] and [A-Za-z], no named groups, no lookbehind unless you have tested both engines. If the schema is used by several languages, run the same list of valid and invalid strings through each in CI. Lookbehind (?<=a)b happened to work in both of mine, which is not a promise for every engine.
An invalid pattern is itself a schema error: check_schema({"pattern": "("}) raised '(' is not a 'regex'. Compile your schema at startup so a bad pattern fails the deploy and not the first request.
format: a hint with gaps
format names a kind of string, and in drafts 2019-09 and 2020-12 validators may treat it as an annotation and skip it. Libraries differ in what they check. With every optional package for formats installed in Python, and ajv-formats in Node, these were the results for a good value and a bad one:
| Format | Value | Python | Ajv |
|---|---|---|---|
ada@ | accepted | rejected | |
ada@localhost | accepted | rejected | |
| date | 2026-13-01 | rejected | rejected |
| date-time | 2026-10-26 10:00:00 (space) | rejected | rejected |
| date-time | 2026-10-26T10:00:00 (no zone) | rejected | rejected |
| uuid | not-a-uuid | rejected | rejected |
| ipv4 | 300.1.1.1 | rejected | rejected |
| uri | not a uri | accepted | rejected |
| hostname | -bad- | accepted | rejected |
Python agreed with Ajv on the dates, the UUID and the IP address, and was lax on email, URI and hostname. The Python email check in jsonschema only looks for an @, and the uri and hostname checkers need additional packages (rfc3987 or rfc3986-validator, and fqdn) that I had not installed, so those formats were skipped. After pip install fqdn rfc3986-validator, Python rejected not a uri and -bad- too; the email check stayed lax. That is the silent-skip trap described in the JSON Schema guide: the schema says format: uri and nothing is checked. Ajv works the other way and refuses to compile a schema that uses a format it does not know.
Whatever library you use, test each format you rely on with one value that must fail. The cases to include are the ones above: an email without a domain, a timestamp without a time zone (RFC 3339 requires one, so 2026-10-26T10:00:00 is not a valid date-time), and a date with an impossible month. For anything with business meaning, like an email you will send to, validation is only a typo filter; the real check is sending a message.
Choosing a type for identifiers and codes
A short list of patterns that I use repeatedly, all within the common subset:
| Kind of value | Constraint |
|---|---|
| Slug | {"type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"} |
| Country code | {"type": "string", "pattern": "^[A-Z]{2}$"} |
| Digits-only code | {"type": "string", "pattern": "^[0-9]{5}$"} |
| One of a few | {"enum": [...]} |
| Date (no time) | {"type": "string", "format": "date"} |
| Money amount | {"type": "string", "pattern": "^-?[0-9]+(\\.[0-9]{1,2})?$"} |
Identifiers and money are strings on purpose. A JSON number goes through a double in JavaScript, and 12345678901234567890 is read as 12345678901234567000; an enum or pattern over strings is exact (how JSON handles big numbers). Dates are strings too, because JSON has no date type (why JSON has no date type), and format: date-time or date is how you ask for ISO 8601.
When a pattern is the wrong constraint
The rule is semantic. A regular expression can check that a string looks like a date and cannot know that February 30 does not exist. Use format, and a real parser downstream.
The set of values changes often. A pattern or an enum for country names or product codes goes stale. Validate the shape and check membership against a table in the application.
The pattern is long. A 200-character expression is a program nobody can review. Split the rule or move it into code and keep the schema to a length bound and a character class.
Untrusted patterns or inputs. A pathological regular expression can take exponential time on crafted input. Bound the string with maxLength before the pattern runs, and do not accept schemas from users without limits.
The keyword definitions are in the string type reference, which lists the built-in formats, and in the validation vocabulary of the specification. Ajv's format options are described at ajv.js.org. To see how a value is escaped inside JSON, use the JSON formatter.


