Tutorial · JSON Schema

JSON Schema email validation example: format email with Ajv and Pydantic

Adding format: email to a schema doesn’t make most validators check anything. Here is a JSON Schema email validation example that does, tested with Ajv, Python’s jsonschema and Pydantic.

Updated October 10, 2026 · 8 min read

JSON Schema format: email is an annotation: without a format checker every string passes, and with one only “not an email” fails

Here is a JSON Schema email validation example in its shortest form, and the catch that comes with it:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "email": { "type": "string", "format": "email", "maxLength": 254 }
  },
  "required": ["email"]
}

Whether "not an email" passes this schema depends on your validator and its settings, not on the schema. This guide shows why, and how to get a real check in Ajv (Node.js) and in Python, followed by the check no schema can do: whether the mailbox exists.

Tested with Ajv 8.20.0 and ajv-formats 3.0.1 on Node.js 22, and with jsonschema 4.26.0, Pydantic 2.13.5 and email-validator 2.3.0 on Python, October 10, 2026.

Email validation in JSON Schema: format is an annotation

The JSON Schema 2020-12 validation specification splits format into two vocabularies:

For email, the specification defines two formats:

FormatDefined asExample it allows
emailThe “Mailbox” ABNF rule in RFC 5321, section 4.1.2jane@example.com
idn-emailThe extended “Mailbox” rule in RFC 6531, section 3.3josé@example.com

The email address format guide explains what these RFC rules allow in practice. By the specification, "format": "email" alone is a label. What happened with the schema above and four different setups in our test:

Setup"not an email""@"
Ajv 2020 without ajv-formatsSchema doesn’t compile: unknown format "email" ignored in schema at path "#/properties/email"–
Ajv 2020 with ajv-formatsRejectedRejected
Python jsonschema, no format checkerPassesPasses
Python jsonschema with FORMAT_CHECKERRejectedPasses

Ajv’s strict mode turns an unknown format into an error, which is helpful: you notice the missing plugin. With strict: false it only logs a warning and lets every string through.

JSON Schema email validation in Node.js with Ajv

Ajv dropped its built-in formats in version 7; they now live in the ajv-formats plugin. For 2020-12 schemas, import the Ajv2020 class:

npm install ajv ajv-formats
import Ajv2020 from "ajv/dist/2020.js";
import addFormats from "ajv-formats";

const schema = {
  $schema: "https://json-schema.org/draft/2020-12/schema",
  type: "object",
  properties: {
    email: { type: "string", format: "email", maxLength: 254 },
  },
  required: ["email"],
  additionalProperties: false,
};

const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);

for (const email of ["jane@example.com", "jane@example", "jane..doe@example.com"]) {
  const ok = validate({ email });
  console.log(email, ok, ok ? "" : JSON.stringify(validate.errors));
}

Output:

jane@example.com true
jane@example false [{"instancePath":"/email","schemaPath":"#/properties/email/format","keyword":"format","params":{"format":"email"},"message":"must match format \"email\""}]
jane..doe@example.com false [{"instancePath":"/email","schemaPath":"#/properties/email/format","keyword":"format","params":{"format":"email"},"message":"must match format \"email\""}]

In CommonJS, const Ajv2020 = require("ajv/dist/2020") works the same way. The schema checks data on the server; for instant feedback in the browser and typo hints, see JavaScript email validation.

ajv-formats has two modes. "full" is the default; addFormats(ajv, { mode: "fast" }) uses simplified regular expressions. For email, the difference is real:

Addressfullfast
jane@example.com✓✓
jane+news@example.com✓✓
JANE@EXAMPLE.COM✓✓
jane@example✗✓
.jane@example.com✗✓
jane..doe@example.com✗✓
jane@example.c✓✓
65 characters before the @✓✓
jane@exa_mple.com✗✗
jane doe@example.com✗✗
"jane doe"@example.com✗✗
jane@[192.0.2.10]✗✗
josé@example.com✗✗
jane@example.com (leading space)✗✗

Neither mode trims whitespace, so trim the value before validating, and keep maxLength: 254 in the schema, since neither mode limits the length of the part before the @.

idn-email isn’t in ajv-formats. In our test, "format": "idn-email" failed to compile with the same unknown-format error. The ajv-formats README points to the separate ajv-formats-draft2019 plugin for internationalized formats.

The Ajv formats guide adds a warning: “It is recommended NOT to use “format” keyword implementations with untrusted data”, because they may rely on regular expressions that are unsafe with crafted input. If you validate request bodies from the internet, cap the body size and string lengths before the data reaches the validator.

JSON Schema email validation in Python

The jsonschema package follows the specification: “By default, as per the specification, no validation is enforced.” Pass a format checker to turn it on:

from jsonschema import Draft202012Validator

schema = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "properties": {"email": {"type": "string", "format": "email"}},
    "required": ["email"],
}

annotating = Draft202012Validator(schema)
asserting = Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)

for email in ["jane@example.com", "jane@example", "not an email", "@"]:
    print(repr(email), annotating.is_valid({"email": email}), asserting.is_valid({"email": email}))
'jane@example.com' True True
'jane@example' True True
'not an email' True False
'@' True True

The documentation is open about it: the email check “only provides a sanity check”. In the source, the checker for both email and idn-email returns "@" in instance. Every string with an @ passes, including jane doe@example.com and a lone @.

Pydantic EmailStr

If your Python schema comes from Pydantic models, EmailStr gives you a much stricter check and still writes format: email into the generated JSON Schema. It needs the email-validator package:

pip install pydantic email-validator
from pydantic import BaseModel, EmailStr, ValidationError

class Signup(BaseModel):
    email: EmailStr

print(Signup(email="Jane.Doe@Example.COM").email)  # Jane.Doe@example.com

try:
    Signup(email="jane@example")
except ValidationError as e:
    print(e.errors()[0]["msg"])

print(Signup.model_json_schema())
Jane.Doe@example.com
value is not a valid email address: The part after the @-sign is not valid. It should have a period.
{'properties': {'email': {'format': 'email', 'title': 'Email', 'type': 'string'}}, 'required': ['email'], 'title': 'Signup', 'type': 'object'}

EmailStr normalizes as it validates: it lowercases the domain, keeps the local part as typed, and strips surrounding whitespace. Its error messages come straight from email-validator and say what is wrong, for example “An email address cannot have two periods in a row.” In Pydantic’s source, the call is email_validator.validate_email(email, check_deliverability=False), so no DNS lookup happens. Some results worth knowing:

AddressEmailStrNote
jane@example✗“It should have a period.”
jane@example.test✗Special-use domain
jane@localhost✗
jane@exa_mple.com✗
josé@example.com✓Internationalized addresses are allowed
jane@bücher.example✓Same
jane@example.c✓
65 characters before the @✓email-validator checks this length only with strict=True

To reject internationalized addresses, call email-validator yourself with allow_smtputf8=False; the Python email validation guide shows the library on its own.

After the schema: check that the mailbox exists

Every validator above answers one question: is this string shaped like an email address? jane.doe@example.com passes all of them and still bounces, because example.com accepts no mail. The schema is the right place for the shape; the existence check belongs in the handler after it. In Node.js:

import Ajv2020 from "ajv/dist/2020.js";
import addFormats from "ajv-formats";

const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validateSignup = ajv.compile({
  $schema: "https://json-schema.org/draft/2020-12/schema",
  type: "object",
  properties: { email: { type: "string", format: "email", maxLength: 254 } },
  required: ["email"],
});

async function checkMailbox(email) {
  const url = new URL("https://api.emailvalidation.io/v1/info");
  url.searchParams.set("email", email);
  const res = await fetch(url, {
    headers: { apikey: process.env.EMAILVALIDATION_API_KEY },
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) throw new Error(`emailvalidation.io answered ${res.status}`);
  return res.json();
}

export async function handleSignup(body) {
  if (!validateSignup(body)) {
    return { status: 422, errors: validateSignup.errors };
  }
  try {
    const result = await checkMailbox(body.email);
    if (result.state === "undeliverable") {
      return { status: 422, errors: [{ instancePath: "/email", message: `mailbox check failed: ${result.reason}` }] };
    }
  } catch {
    // API unreachable or timed out: accept the address and check it later
  }
  return { status: 201 };
}

The mailbox errors use the same shape as Ajv’s, so your client can show them the same way. Run against a local mock of the email validation API with its documented response format, handleSignup returned 201 for a deliverable address, 422 with “mailbox check failed: invalid_mailbox” for an undeliverable one, 422 with Ajv’s errors for jane@example and a missing field, and 201 when the API was unreachable.

The API result has 15 fields: state is deliverable, undeliverable or unknown; reason explains it; disposable, role and free flag throwaway, role and free-provider addresses. Addresses on catch-all domains come back as deliverable with the reason low_deliverability, because such domains accept every address. The same API call in Python, with requests, is in the Python email validation guide.

If you’d rather keep a regular expression in the schema itself, use pattern instead of format. The specification lists pattern among the keywords that are “all assertions without any annotation behavior”, so every validator applies it. It also notes that “regular expressions are not implicitly anchored”: start the pattern with ^ and end it with $. Here is the tested pattern from the email regex guide as a schema, with its 254-character lookahead replaced by maxLength and every backslash doubled for JSON:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "string",
  "maxLength": 254,
  "pattern": "^(?=.{1,64}@)[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})$"
}

Ajv and Python’s jsonschema both accepted jane@example.com and o'brien@example.ie with it, and rejected jane@example, jane..doe@example.com and a 65-character local part, without any format plugin. Lists of addresses that are already in a database are better checked in one job with the bulk email verifier than one request at a time.

Sources

All checked October 10, 2026:

Validate email in other languages

Frequently asked questions

How do I validate an email in JSON Schema?

Use {"type": "string", "format": "email"} and make sure your validator asserts formats: Ajv needs the ajv-formats plugin, and Python’s jsonschema needs a format_checker. Without that, JSON Schema 2020-12 treats format as an annotation and every string passes.

Is format: email validated by default?

Not under JSON Schema 2020-12. The specification says format is collected as an annotation, and that an implementation which can also assert it must keep that option disabled by default. Ajv is the exception in practice: once ajv-formats is added, it validates formats unless you set validateFormats: false.

What is the difference between email and idn-email in JSON Schema?

email follows the Mailbox rule of RFC 5321, which is ASCII only. idn-email follows the extended Mailbox rule of RFC 6531 and allows internationalized addresses such as josé@example.com. ajv-formats doesn’t include idn-email.

Does Pydantic EmailStr check that the email exists?

No. EmailStr uses the email-validator package with check_deliverability=False, so it checks syntax only and makes no DNS query. To know whether the mailbox exists, check the address against the mail server, for example with an email validation API.

Email validation API

Validate emails in your app

emailvalidation.io checks syntax, MX records and the mailbox over SMTP, flags disposable, role and free addresses and returns a quality score, in one request.

/v1/info Email validation API Read the documentation

100 free validations every month. No credit card required.

GET https://api.emailvalidation.io/v1/info?email=support@emailvalidation.io

{
  "email": "support@emailvalidation.io",
  "user": "support",
  "tag": "",
  "domain": "emailvalidation.io",
  "format_valid": true,
  "mx_found": true,
  "smtp_check": true,
  "catch_all": null,
  "role": true,
  "disposable": false,
  "free": false,
  "score": 0.64,
  "state": "deliverable",
  "reason": "valid_mailbox",
  "did_you_mean": ""
}

Keep reading

Start using our email validation software today!

Get 100 validations per month for free