Pydantic v2 Async Custom Validator: What to Do Instead
Key takeaways:
- An
async defvalidator does not error — validation succeeds and stores a coroutine. - A field declared
strwill happily hold acoroutineobject; the validator body never runs. - An async
model_validator(mode="after")makesmodel_validatereturn a coroutine, not a model. - Put async rules in a dependency or the service layer, where awaiting is legal.
- Keep shape errors as 422 and state conflicts as 409 — they are different failures.
This page corrects a common expectation around custom validators and field constraints. The short version is that Pydantic will not stop you, which is exactly what makes this worth a page.
The Problem This Solves
The requirement is completely reasonable: reject a signup whose email is already registered, and do it in the same place as every other rule about that field. So you write the obvious thing:
@field_validator("email")
@classmethod
async def check_unique(cls, v: str) -> str:
if await email_exists(v):
raise ValueError("email already registered")
return v
It imports. It runs. Requests return 200. Duplicate emails get created. There is no exception, no warning at class definition time, and nothing in the logs — and because the endpoint keeps working, the bug is usually found in the database rather than in the code.
Why It Happens
Two ordinary Python behaviours combine into something that looks like a Pydantic bug and is not.
The first: calling a coroutine function does not execute its body. It builds a coroutine object and returns it immediately. Every line inside check_unique — including the raise — is inert until something awaits it.
The second: pydantic-core runs validation synchronously. It is a Rust engine with no event loop, so it cannot await anything, and it makes no attempt to detect that the function it just called handed back an awaitable. It calls your validator, receives a value, and stores it.
The part that surprises people is that the type system does not catch the mismatch either. An after validator's return value is taken on trust — the whole point of running after coercion is that the value has already been checked, so Pydantic does not re-validate what you return. A validator on a str field is free to return a dict, an object, or in this case a coroutine, and the model will hold it.
So the rule never runs, the field holds the wrong type, and every layer that could have caught it deliberately does not look.
Prerequisites
- Pydantic 2.13.4 and FastAPI 0.139.2 — the versions the transcripts below came from.
- An async session available through dependency injection.
Proof: What Actually Happens
This model is deliberately wrong. The endpoint validates a payload through it and reports what came back:
"""What actually happens when a Pydantic v2 validator is declared `async def` — and the fix."""
class AsyncFieldValidator(BaseModel):
"""An `async def` field validator. Pydantic never awaits it."""
email: str
@field_validator("email")
@classmethod
async def check_unique(cls, v: str) -> str:
await asyncio.sleep(0)
if v in KNOWN_EMAILS:
raise ValueError("email already registered")
return v
class AsyncModelValidator(BaseModel):
"""Same mistake at model level."""
email: str
@model_validator(mode="after")
async def check(self) -> "AsyncModelValidator":
await asyncio.sleep(0)
return self
KNOWN_EMAILS = {"ada@example.com"}
The payload sent is ada@example.com, which is in KNOWN_EMAILS. The validator should reject it. Real output from _verify/output/up-pyd-async-validator.txt:
$ POST /async-field-validator {"email": "ada@example.com"}
200 OK
{
"outcome": "validation SUCCEEDED",
"declared_type": "str",
"actual_field_type": "coroutine",
"coroutine_name": "AsyncFieldValidator.check_unique",
"uniqueness_rule_enforced": false,
"warnings": []
}
declared_type: str, actual_field_type: coroutine. The field annotated email: str contains a coroutine object named after the validator that was supposed to run. warnings is empty — Pydantic said nothing.
Everything downstream of this model now has a str-annotated attribute holding an un-awaitable object. email.lower() raises AttributeError. Writing it to a database raises an adapter error. Serializing it fails. Each of those surfaces somewhere unrelated to the validator that caused it.
The model-level version is worse:
$ POST /async-model-validator {"email": "ada@example.com"}
200 OK
{
"outcome": "validation SUCCEEDED",
"expected_type": "AsyncModelValidator",
"actual_returned_type": "coroutine",
"warnings": []
}
model_validate returned a coroutine, not a model. isinstance(result, AsyncModelValidator) is false. A function annotated -> AsyncModelValidator just returned something else entirely, and neither Pydantic nor the annotation objected.
The Fix: Move the Await to Where Awaiting Is Legal
Split the two questions. Is this input well-formed? is a property of the payload and belongs in the model, synchronously. Is this email already taken? is a question about the state of the database and belongs in a dependency.
class SignupRequest(BaseModel):
"""Synchronous, pure: shape and normalization only."""
email: str
@field_validator("email")
@classmethod
def normalize(cls, v: str) -> str:
v = v.strip().lower()
if "@" not in v:
raise ValueError("not a valid email address")
return v
async def email_exists(email: str) -> bool:
"""Stands in for an awaited database round-trip."""
await asyncio.sleep(0)
return email in KNOWN_EMAILS
async def unique_email(body: SignupRequest) -> SignupRequest:
"""The async rule, expressed where awaiting is legal — a dependency."""
if await email_exists(body.email):
raise HTTPException(status_code=409, detail="email already registered")
return body
@app.post("/signup")
async def signup(body: Annotated[SignupRequest, Depends(unique_email)]) -> dict[str, str]:
return {"registered": body.email}
The dependency takes SignupRequest as a parameter, so FastAPI validates the body first and hands the dependency an already-normalised model. The handler then depends on unique_email rather than on the body directly, which means the uniqueness check has already passed by the time the handler runs.
Three requests through it — one valid, one duplicate, one malformed:
$ POST /signup {"email": " GRACE@Example.com "}
200 OK
{
"registered": "grace@example.com"
}
$ POST /signup {"email": "ada@example.com"}
409 Conflict
{
"detail": "email already registered"
}
$ POST /signup {"email": "not-an-email"}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"email"
],
"msg": "Value error, not a valid email address",
"input": "not-an-email",
"ctx": {
"error": {}
}
}
]
}
Note that the synchronous validator did run — the input " GRACE@Example.com " came back as grace@example.com, trimmed and lowercased, and the normalised form is what the uniqueness check compared against. That ordering is not incidental; checking uniqueness against un-normalised input is how you end up with two accounts differing only in capitalisation.
The status codes also separate cleanly. A malformed address is a 422 with a loc pointing at the field. A well-formed address that collides with existing state is a 409. Clients can distinguish "fix your input" from "pick a different email", which a single 422 for both would not allow.
Verification
Two tests, because there are two distinct failure modes:
import pytest
from fastapi import HTTPException
def test_no_field_holds_a_coroutine():
# The direct assertion against the bug: types must match their annotations.
model = SignupRequest.model_validate({"email": "Ada@Example.com "})
assert isinstance(model.email, str)
assert model.email == "ada@example.com"
async def test_duplicate_email_is_a_409(client):
await insert_user("ada@example.com")
response = await client.post("/signup", json={"email": "ADA@example.com"})
assert response.status_code == 409
The first test is the one worth adding to a shared conftest for every request model you own. A coroutine in a str field passes any test that only checks the status code, so assert on the type of the validated value, not just that validation succeeded.
A grep also works and costs nothing: search for async def within a few lines of @field_validator or @model_validator. There is no legitimate match.
Trade-offs and When Not To
The dependency approach reads well and keeps the rule declarative, but be honest about what it guarantees. Checking uniqueness before an insert is a check-then-act race: two concurrent signups can both pass the check and both attempt the insert. The dependency improves the error message; it does not make the operation safe. A unique constraint in the database is what actually enforces uniqueness, and you should still handle the integrity error it raises. Treat the pre-check as UX and the constraint as correctness.
The dependency also costs a round-trip on every request, including the overwhelming majority that will not collide. For a hot endpoint, skipping the pre-check and translating the database's integrity error into a 409 is one query cheaper and strictly more correct — at the cost of a less specific error when a request violates several constraints at once. See async database sessions for managing the session either way, and transaction management and rollback for handling the failure cleanly.
Finally, some checks that look like validation are authorization — "does this user own the project they are posting to?" belongs in a dependency for a different reason, and returns 403 rather than 409. Keeping the model synchronous forces that distinction to be made explicitly, which is a real if accidental benefit of the constraint this page describes.
FAQ
What happens if I write an async def Pydantic v2 validator?
Validation succeeds and the field is set to the un-awaited coroutine object. On Pydantic 2.13.4 a field declared str ends up holding a coroutine, the validator body never executes, and no error or warning is raised at class definition or validation time.
Why does an async validator not raise an error?
Because calling a coroutine function returns a coroutine object without running the body, and pydantic-core treats that returned object as the validated value. An after validator's return value is taken on trust and is not re-checked against the declared type, so nothing detects the mismatch.
Does an async model_validator behave any differently?
No, it is worse. An async model_validator with mode="after" makes model_validate return a coroutine instead of a model instance, so the object you get back is not an instance of your model class at all.
Where should an async uniqueness check live in FastAPI?
In a dependency or the service layer, not the model. A dependency receives the already-validated body, can await a database session, and raises HTTPException 409, which keeps shape errors as 422 and state conflicts as 409.
Is a uniqueness check even a validation concern? Not really. Validation asks whether the input is well-formed, which is a property of the payload alone. Uniqueness asks about the state of the database at this instant, which can change between the check and the insert, so it needs a unique constraint behind it regardless.
Related Reading
- Up to the topic: custom validators and field constraints.
- For what validators can do synchronously, see before, after and wrap validators and cross-field validation patterns.
- The dependency mechanics are covered in dependency injection strategies and annotated dependencies and reusable types.
- For the session the check awaits, see async SQLAlchemy session per request.
- To keep 409 and 422 consistent across the API, see HTTPException vs custom exception classes.