2025, Oct 30 21:00

How to keep dynamic defaults out of Pydantic 2 JSON Schema for nested models using BaseModel.construct

Prevent dynamic defaults in Pydantic 2 JSON Schema for nested models. Use BaseModel.construct to skip default_factory and keep stable defaults reliably.

When generating JSON Schema from nested Pydantic models in version 2.11.7, you may notice that dynamic defaults unexpectedly get baked into the schema. This becomes a problem when a nested model has a time-based field with a default_factory, but you still want a predictable default for another field in the same model.

The minimal example

Consider a nested configuration where one field is a time-derived seed and another has a simple, stable boolean default. The goal is to have the schema include the boolean default while avoiding a fixed numeric seed in the schema.

import json
import time
from pydantic import BaseModel, Field
class RandomSeedConfig(BaseModel):
    rand: int = Field(default_factory=lambda _: int(time.time() * 1000))
    use_safe_default: bool = False
class AppConfig(BaseModel):
    options: RandomSeedConfig = RandomSeedConfig()
schema_map = AppConfig.model_json_schema()
print(json.dumps(schema_map, indent=2))

Running this produces a schema where the default for the nested field is a fully realized object, including a concrete integer value for the time-based seed. In other words, the default for the nested model is captured as a literal instance, so the schema ends up with both the stable boolean and a specific number for the seed.

Why the schema bakes in the seed

The nested field default is created by instantiating the nested model directly. That means the default_factory for the time-based field is executed at definition time for that default instance. When the schema is generated, Pydantic can only reflect the default it sees, which is the fully constructed object, so the seed appears as a fixed integer in the JSON Schema. This is not desirable if you want the schema to convey only stable defaults while leaving dynamic values unspecified.

The fix

To control what the schema includes as a default without evaluating dynamic factories, initialize the nested default via BaseModel.construct and pass only the fields you want serialized into the schema. This approach lets you preserve the stable default while avoiding the time-derived seed in the default section of the schema.

from pydantic import BaseModel, Field
import time
import json
class RandomSeedConfig(BaseModel):
    rand: int = Field(default_factory=lambda: int(time.time() * 1000))
    use_safe_default: bool = False
class AppConfig(BaseModel):
    options: RandomSeedConfig = RandomSeedConfig.construct(use_safe_default=False)
schema_map = AppConfig.model_json_schema()
print(json.dumps(schema_map, indent=2))

With this approach, the schema’s default for the nested field only includes the stable boolean. The seed is not materialized in the schema, which is exactly what you want for a value intended to be generated at runtime.

It’s also helpful to understand what happens conceptually: setting parameters via BaseModel.construct gives you explicit control over what gets included in the generated JSON Schema for defaults. A practical side effect of this approach is that construct does not perform runtime validation, which keeps the dynamic factory from executing but also means you’re trading validation for schema control in that particular place.

Why it matters

JSON Schema often feeds documentation, code generation, and external validation. Baking in a non-deterministic value such as a timestamp-based seed can be misleading, make downstream diffs noisy, and suggest that a specific numeric seed is a canonical default. By leaving dynamic values out of the default while retaining stable defaults, you keep the contract clear and the schema deterministic.

Takeaways

If a nested model combines dynamic and stable defaults, initializing the parent’s default with BaseModel.construct allows you to export a clean JSON Schema: stable defaults are preserved, while runtime-only values are kept out. Use this deliberately where schema clarity is more important than having runtime validation for the default instance, and let the dynamic field be produced when the model is instantiated during normal execution.

The article is based on a question from StackOverflow by Tristan F.-R. and an answer by Jone.