model_construct and When to Skip Validation in Pydantic
Key takeaways:
model_construct()skips coercion, constraints and every validator; only defaults are applied.- Invalid data passes through silently and the result is still an
isinstanceof the model. - Omitting a required field produces an object that raises
AttributeErrorwhen you touch it. - Serializing bad data warns rather than raises, so the corruption travels downstream.
- Use it only for provably-trusted data in a path you have measured.
This guide sits under Performance Optimization for Models, and it is the one page in that topic that is mostly a warning.
The Problem This Solves
You are loading fifty thousand rows out of a database into Pydantic models for a report. The rows came from a table whose columns mirror the model exactly, and were written by the same application that defines the model. Revalidating every field is provably redundant work, and a profile confirms it is a real fraction of the request.
model_construct() is the escape hatch. It builds the model without running any of the validation machinery. And it will do that whether or not your assumption about the data holds — which is the part that has to be understood before it is used, because Pydantic will not tell you when the assumption breaks.
Why It Happens
A BaseModel instance is, underneath, a __dict__ of field values plus a __pydantic_fields_set__ set recording which fields were explicitly supplied. Validation is not a property of the instance; it is a step that happens on the way in.
model_validate runs the compiled core validator: coerce each value to its annotated type, enforce constraints, run field_validator and model_validator functions, fill defaults, and only then populate __dict__. model_construct writes to __dict__ directly. It walks the model's fields to fill defaults for anything you omitted, records the fields you did pass, and returns.
Because validation was never an instance property, nothing about the resulting object records which route it took. isinstance(obj, Account) is True. Type checkers see an Account. Every function downstream that accepts an Account accepts it. The type annotation has become a claim nobody checked.
Serialization does notice, but only just. Pydantic's serializer is compiled from the same schema, so when it meets a str where the schema promised an int it emits a PydanticSerializationUnexpectedValue warning — and then serializes the value anyway. A warning, in a codebase that has almost certainly configured its warning filters for something else, on a code path that then sends the result to a client.
The Fix
There is no fix, in the sense of making this safe. What follows is a demonstration of exactly what gets through, so the decision to use it can be made with the facts.
"""Show exactly what model_construct() lets through: no coercion, no constraints, no validators."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, ValidationError, field_validator
class Account(BaseModel):
id: int
email: str = Field(pattern=r"^[^@]+@[^@]+$")
balance_cents: int = Field(ge=0)
created_at: datetime
tier: str = "free"
@field_validator("email")
@classmethod
def lowercase(cls, v: str) -> str:
return v.lower()
# The same shape as a real row, but wrong in every way a constraint would normally catch.
POISONED_ROW: dict[str, Any] = {
"id": "not-an-int",
"email": "NOT AN EMAIL AT ALL",
"balance_cents": -9999,
"created_at": "definitely not a datetime",
"tier": ["unexpected", "list"],
}
That dictionary violates the type of every field, a regex pattern, a ge=0 constraint, and would have been lowercased by a validator. Here is what Account.model_construct(**POISONED_ROW) produces. Real output:
$ GET /poisoned
200 OK
{
"id": "not-an-int",
"id_python_type": "str",
"email": "NOT AN EMAIL AT ALL",
"email_validator_ran": false,
"balance_cents": -9999,
"tier": [
"unexpected",
"list"
],
"created_at_python_type": "str",
"isinstance_of_Account": true,
"serialization": {
"dumped": {
"id": "not-an-int",
"email": "NOT AN EMAIL AT ALL",
"balance_cents": -9999,
"created_at": "definitely not a datetime",
"tier": [
"unexpected",
"list"
]
},
"warnings": [
"Pydantic serializer warnings:",
" PydanticSerializationUnexpectedValue(Expected `int` - serialized value may not be as expected [field_name='id', input_value='not-an-int', input_type=str])",
" PydanticSerializationUnexpectedValue(Expected `datetime` - serialized value may not be as expected [field_name='created_at', input_value='definitely not a datetime', input_type=str])",
" PydanticSerializationUnexpectedValue(Expected `str` - serialized value may not be as expected [field_name='tier', input_value=['unexpected', 'list'], input_type=list])"
]
}
}
No exception. Status 200. account.id is the string "not-an-int" on a field annotated int. balance_cents is negative despite ge=0. The email validator did not run, so the value is still upper case. tier is a list on a str field. And isinstance_of_Account is true, so nothing downstream — no type checker, no runtime check, no function signature — will notice.
The dumped block is the part that should decide the question. model_dump(mode="json") produced a JSON-shaped dictionary containing every bad value. The three warnings were captured here deliberately; in a normal process they go wherever your warning configuration sends them, which for most services is nowhere. Return that from an endpoint and you have served a response that violates your own published schema, with a 200 status code.
For contrast, the same dictionary through model_validate:
$ GET /compare
200 OK
{
"model_validate_raised": 5,
"errors": [
{
"loc": [
"id"
],
"type": "int_parsing",
"msg": "Input should be a valid integer, unable to parse string as an integer"
},
{
"loc": [
"email"
],
"type": "string_pattern_mismatch",
"msg": "String should match pattern '^[^@]+@[^@]+$'"
},
{
"loc": [
"balance_cents"
],
"type": "greater_than_equal",
"msg": "Input should be greater than or equal to 0"
},
{
"loc": [
"created_at"
],
"type": "datetime_from_date_parsing",
"msg": "Input should be a valid datetime or date, invalid character in year"
},
{
"loc": [
"tier"
],
"type": "string_type",
"msg": "Input should be a valid string"
}
]
}
Five errors, one per field. That is the difference the two method names hide: identical input, identical model, and one path reports every problem while the other reports none.
Missing fields are worse than wrong ones
Wrong values at least exist. Omitted required fields produce an object that is not merely wrong but structurally incomplete:
$ GET /missing-field
200 OK
{
"constructed_without_balance_or_created_at": true,
"tier": "free",
"has_balance_cents_attribute": false,
"attribute_error_on_access": "AttributeError: 'Account' object has no attribute 'balance_cents'"
}
Account.model_construct(id=2, email="grace@example.com") returned happily. The tier default was applied, because defaults are the one thing model_construct does. But balance_cents and created_at simply are not there, and the failure surfaces as a bare AttributeError at whatever unrelated point in your code first reads the field — a stack trace with no connection to the line that built the object.
The narrow case where it is fine
$ GET /trusted
200 OK
{
"id": 1,
"email": "ada@example.com",
"tier_default_applied": "free",
"fields_set": [
"balance_cents",
"created_at",
"email",
"id"
]
}
This is the intended use: every field present, every type already correct — created_at was a real datetime object, not a string — and no coercion needed because the source system produces the right types. Note fields_set lists the four fields that were passed and not tier, which came from its default. That set is what drives exclude_unset, and it is tracked correctly, so a constructed model still serializes with the same include and exclude semantics as a validated one.
Verification
If you use model_construct at all, make the assumption it rests on into a test. Validate the trusted source once, in CI, against real data:
def test_database_rows_would_pass_validation(sample_rows):
"""The premise of using model_construct on this query, asserted."""
for row in sample_rows:
Account.model_validate(row) # must never raise
That test is the entire safety argument. It says the fast path is safe because the slow path succeeds on the same shape. When a migration changes a column type, this fails in CI instead of silently producing wrong objects in production.
Second, turn the serializer warning into something you would actually notice, at least in tests:
def test_constructed_models_serialize_cleanly():
with warnings.catch_warnings():
warnings.simplefilter("error") # promote the warning to an exception
build_report_rows()
Third, make the usage greppable. Wrap it in one function with a name that says what it assumes — from_trusted_row rather than a bare model_construct call scattered across the codebase — so a reviewer can see every site at once and a newcomer cannot copy the idiom by accident.
Trade-offs and When Not To
Never on anything that crossed a boundary. Request bodies, webhook payloads, queue messages, third-party API responses, files, and anything a user could influence. Validation is the boundary. Skipping it there does not make the code faster in any way that matters; it removes the reason the model exists.
Cache entries are not trusted data. A serialized model in Redis was written by a previous deploy, with a previous version of the model. That is a schema-migration problem wearing a trusted-data costume, and it is one of the more common ways model_construct causes an incident.
Measure before, not after. In an ordinary endpoint the time goes to database round trips, serialization and network, not to validation. model_construct is worth considering when a profile shows validation itself is significant — bulk loads, batch jobs, large report queries — and is otherwise a risk taken for no measured gain. If the pressure is on serialization rather than validation, Pydantic Model Serialization Performance is the more useful page.
Consider the alternatives first. Fetching fewer columns, avoiding the model entirely for a bulk path, or using a TypeAdapter over a list[Model] — which validates the whole batch in one Rust pass rather than looping in Python — all reduce cost without giving up correctness. Exhaust those before giving up the guarantees.
Response models re-validate anyway. If a constructed object is returned from a route with a response_model, FastAPI validates it on the way out, so you have paid the cost you were avoiding and moved the failure to a place that produces a 500 rather than a clear error. The optimisation only pays inside code that never crosses the response boundary.
FAQ
What exactly does model_construct skip? Everything except default application. No type coercion, no field constraints, no field or model validators, and no check that required fields are present. It sets the attributes you pass, fills defaults for fields you omit, and returns.
Is model_construct actually faster? It does strictly less work, so yes, but the saving is per-object and only matters when you are building very many objects from data you have already validated. Profile before reaching for it — in most endpoints serialization and I/O dominate, not validation.
What happens if I omit a required field?
Nothing, until you touch it. A real run constructed an Account without balance_cents, hasattr returned False, and reading the attribute raised AttributeError rather than any Pydantic error. The object is silently incomplete.
Will serialization catch the bad data?
Only as a warning. Dumping a model built from wrong types emits PydanticSerializationUnexpectedValue warnings and still produces output containing the bad values, so a warning filter set to ignore turns it into silent corruption downstream.
When is skipping validation genuinely safe? When the data provably cannot be wrong and you are in a measured hot path — rows from a database whose schema mirrors the model, or objects your own code validated moments earlier. Never for anything that crossed a network or a user boundary.
Related Reading
- Up to the topic: Performance Optimization for Models.
- Validate a whole batch in one pass instead of skipping validation: TypeAdapter for Non-Model Types.
- Where the time usually actually goes: Pydantic Model Serialization Performance.
- The validators
model_constructsilently skips: Creating Reusable Custom Validators in Pydantic. - Loading rows efficiently in the first place: Async SQLAlchemy Session Per Request.