Skip to content

When EmailStr Rejects .test Fixtures Under Pydantic

Adityo Guni Waluyo

email-validator rejects reserved test domains at the syntax layer; here is how to pick fixture domains that pass Pydantic validation.

TL;DR

CI failures on register and login endpoints came from Pydantic's EmailStr rejecting reserved domains like .test and .local at the syntax layer, before any DNS lookup. The simplest fix is swapping fixtures to example.com, example.net, or example.org, which pass validation. Alternatively, pass test_environment=True to email-validator, though Pydantic needs a custom wrapper for that.

# When EmailStr Rejects myapp.test: Fixing Fixtures That Fail Pydantic Validation

The first CI run for the FastAPI backend returned 422 on the register and login endpoints. The response body carried the same message for every test case: "The part after the @-sign is a special-use or reserved name that cannot be used with email."[2] All fixtures used addresses like [email protected] and user@myapp.local. The endpoints were reachable, the database was seeded, and the payload shape matched the schema.

The initial assumption pointed to a regular expression that was too strict. That guess did not hold. The schema used EmailStr from Pydantic, which delegates validation to email-validator[1]. The rejection occurred at the syntax validation layer, before any DNS lookup.

Why Syntax Validation Rejects Special-Use Domains

email-validator maintains a constant named SPECIAL_USE_DOMAIN_NAMES in the package init module[1]. The list includes invalid, local, localhost, onion, test, and arpa with its subdomains. Any domain that equals one of these names or ends with "." + name is treated as non-routable[2]. The check is implemented in the syntax module[2] and produces the exact error string seen in CI.

This means myapp.test fails because it ends with .test. myapp.local fails for the same reason with .local. The match covers subdomains deliberately, so a prefix does not make a reserved suffix acceptable.

The library permits test and *.test only when the caller sets test_environment set to True[1]. Without that flag, both are rejected by default. The rationale documented in the README is that validate_email assumes publicly routable addresses to reduce abuse risk, and reserved names cannot be used for real mail delivery[1].

Pydantic inherits this behavior. EmailStr in the networks module calls validate_email with check_deliverability set to False[3]. Deliverability checking is therefore disabled, which explains why documentation domains can still pass. The syntax layer remains active. A reserved suffix will still be rejected even when no DNS query is performed.

The distinction can be verified directly in a Python REPL. The snippet below reproduces the CI result without starting the API server.

from email_validator import validate_email, EmailSyntaxError

try:
    validate_email("[email protected]", check_deliverability=False)
except EmailSyntaxError as e:
    print(str(e))
    # The part after the @-sign is a special-use or reserved name...

Reserved names are defined by the IANA Special-Use Domain Names registry, last updated 2026-05-22, which lists test, localhost, invalid, the example domains, onion, and arpa among others[4]. Not every name in that registry is rejected. example.com, example.net, and example.org are reserved for documentation but are not part of the SPECIAL_USE_DOMAIN_NAMES rejection list[1]. That is why they pass syntax validation in email-validator[1] and, by extension, in EmailStr[3].

A related edge case involves deliverability. With check_deliverability set to True, example.com will fail for a different reason: IANA publishes a NULL MX for the example domains to signal that they never accept mail[4]. Under Pydantic this path is not triggered, because check_deliverability stays False[3].

Switching Fixtures to Documentation Domains

The fix keeps validation strict in production and adjusts test data to satisfy the same contract. Replace fixtures that use .test or .local with example.com, example.net, or example.org[4]. These domains are explicitly reserved for examples, remain stable, and are not rejected at the syntax layer[1].

Run a repository-wide search first so no fixture is missed:

from pydantic import BaseModel, EmailStr, ValidationError

class UserIn(BaseModel):
    email: EmailStr  # validation runs with check_deliverability=False [3]

# before: rejected at the SUDN gate
try:
    UserIn(email="[email protected]")
except ValidationError as e:
    print(e.errors()[0]["ctx"]["reason"])

# after: accepted
print(UserIn(email="[email protected]").email)
print(UserIn(email="[email protected]").email)
print(UserIn(email="[email protected]").email)

Then switch the fixture payloads to a documentation domain:

grep -R "@.*\.test" tests/fixtures/
grep -R "@.*\.local" tests/fixtures/

Run the suite again and confirm that POST /auth/register returns 201 or 200 where expected instead of 422. Also run the login flow with the same addresses to ensure both paths share the corrected domain. The package itself is the widely used email-validator library on PyPI[5], so the local REPL check mirrors CI exactly when versions match.

Handling the Exception for Test Environments

In some test setups the team prefers to keep *.test addresses because they read as test data. The library provides an explicit escape hatch for that case. Pass test_environment set to True to validate_email[1]. The flag disables the special-use rejection for test and *.test only. Other reserved names such as .local or .invalid remain rejected.

from email_validator import validate_email

validate_email("[email protected]", check_deliverability=False, test_environment=True)
print("lolos dalam mode uji")

Pydantic does not expose test_environment through EmailStr directly. When validation flows through Pydantic and the test suite must accept *.test, wrap validation in a custom type that forwards the flag to email-validator[1][3].

from pydantic import BaseModel
from email_validator import validate_email

class UserTest(BaseModel):
    email: str

    @classmethod
    def with_test_domain(cls, addr: str):
        info = validate_email(addr, check_deliverability=False, test_environment=True)
        return cls(email=info.normalized)

This wrapper is intentionally scoped to test code. Production schemas should keep EmailStr unchanged so that reserved names remain rejected by default[1][3]. The documentation domains example.com, example.net, and example.org[4] therefore remain the default choice for most fixtures, because they require no custom type and work with stock EmailStr under check_deliverability set to False[3].

Fixtures are part of the validation contract. When they use domains that production validation rejects, the suite reports a product failure that does not exist. Aligning fixture data with the same syntax rules that EmailStr enforces makes the CI signal reliable again.

Sources:

[1] python-email-validator README and SPECIAL_USE_DOMAIN_NAMES in email_validator/__init__.py

[2] email_validator/syntax.py — error string and subdomain match via endswith("." + d)

[3] pydantic networks.py — EmailStr calls validate_email with check_deliverability=False

[4] IANA Special-Use Domain Names registry (updated 2026-05-22)

[5] email-validator on PyPI

Related articles