Skip to content

Use validate_default=True for structured union defaults#3050

Merged
koxudaxi merged 6 commits intokoxudaxi:mainfrom
butvinm:fix/validate-default-union
Mar 14, 2026
Merged

Use validate_default=True for structured union defaults#3050
koxudaxi merged 6 commits intokoxudaxi:mainfrom
butvinm:fix/validate-default-union

Conversation

@butvinm
Copy link
Copy Markdown
Contributor

@butvinm butvinm commented Mar 13, 2026

Alternative to #3040. Fixes #3034, fixes #3035

Instead of wrapping defaults in TypeAdapter().validate_python(), model_validate(), or default_factory=lambda: RootModel(value), use Pydantic's native validate_default=True Field option. This lets Pydantic handle union branch selection, dict-to-model coercion, and RootModel wrapping at runtime, producing simpler generated code with no extra imports.

Example

Given this schema (covers all affected cases):

{
  "$defs": {
    "A": { "properties": { "type": { "const": "a" } } },
    "B": { "properties": { "type": { "const": "b" } } },
    "U": { "anyOf": [{ "$ref": "#/$defs/A" }, { "$ref": "#/$defs/B" }] },
    "Pet": { "properties": { "name": { "type": "string" } } },
    "CountType": { "type": "integer", "minimum": 0, "maximum": 100 }
  },
  "properties": {
    "union_default": {
      "default": { "type": "b" },
      "anyOf": [{ "$ref": "#/$defs/A" }, { "$ref": "#/$defs/B" }, { "type": "null" }]
    },
    "alias_union_default": {
      "default": { "type": "b" },
      "anyOf": [{ "$ref": "#/$defs/U" }, { "type": "null" }]
    },
    "single_model_default": {
      "default": { "type": "a" },
      "anyOf": [{ "$ref": "#/$defs/A" }, { "type": "null" }]
    },
    "list_of_models": {
      "default": [{ "name": "taro" }],
      "type": "array",
      "items": { "$ref": "#/$defs/Pet" }
    },
    "dict_of_models": {
      "default": { "k": { "name": "taro" } },
      "type": "object",
      "additionalProperties": { "$ref": "#/$defs/Pet" }
    },
    "empty_list": {
      "default": [],
      "type": "array",
      "items": { "$ref": "#/$defs/Pet" }
    },
    "count": {
      "default": 10,
      "anyOf": [{ "$ref": "#/$defs/CountType" }, { "type": "null" }]
    }
  }
}

Before (main with #3040)

from pydantic import BaseModel, Field, TypeAdapter

class Model(BaseModel):
    union_default: A | B | None = Field(
        default_factory=lambda: TypeAdapter(A | B).validate_python({'type': 'b'})
    )
    alias_union_default: U | None = Field(
        default_factory=lambda: TypeAdapter(U).validate_python({'type': 'b'})
    )
    single_model_default: A | None = Field(
        default_factory=lambda: A.model_validate({'type': 'a'})
    )
    list_of_models: list[Pet] | None = Field(
        default_factory=lambda: [Pet.model_validate(v) for v in [{'name': 'taro'}]]
    )
    dict_of_models: dict[str, Pet] | None = Field(
        default_factory=lambda: {
            k: Pet.model_validate(v) for k, v in {'k': {'name': 'taro'}}.items()
        }
    )
    empty_list: list[Pet] | None = Field(default_factory=list)
    count: Annotated[CountType | None, Field(default_factory=lambda: CountType(10))]

After (this PR)

from pydantic import BaseModel, Field

class Model(BaseModel):
    union_default: A | B | None = Field({'type': 'b'}, validate_default=True)
    alias_union_default: U | None = Field({'type': 'b'}, validate_default=True)
    single_model_default: A | None = Field({'type': 'a'}, validate_default=True)
    list_of_models: list[Pet] | None = Field([{'name': 'taro'}], validate_default=True)
    dict_of_models: dict[str, Pet] | None = Field(
        {'k': {'name': 'taro'}}, validate_default=True
    )
    empty_list: list[Pet] | None = Field([], validate_default=True)
    count: Annotated[CountType | None, Field(validate_default=True)] = 10

What changed in source

Removed:

  • WrappedDefault class, ValidatedDefault class, and _types.py module
  • SUPPORTS_VALIDATED_DEFAULT and SUPPORTS_WRAPPED_DEFAULT capability flags
  • IMPORT_TYPE_ADAPTER — no TypeAdapter in generated code
  • _get_default_as_pydantic_model() method (entire branch-matching loop)
  • __wrap_root_model_default_values() method
  • Helper functions: _supports_validated_default_model, _uses_existing_model_factory_path, _default_matches_plain_container_branch, _get_validated_default_type_name

Added:

  • _needs_validate_default() — checks if the field type contains a model reference
  • __set_validate_default_on_fields() — sets extras["validate_default"] = True on fields needing validation

Test plan

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Simplified default handling in generated Pydantic models: many Field defaults moved from factory-based constructions to direct literal defaults with validate_default=True, producing clearer, more readable declarations.
    • Cleaner imports in generated code (fewer runtime adapters), reducing noise in model files.
  • Tests

    • Updated tests and expected outputs to reflect the new default/value validation behavior.

…tructured defaults

Replace the ValidatedDefault/TypeAdapter mechanism from koxudaxi#3040 and the
original model_validate() codegen with Pydantic's native validate_default=True.

Instead of generating complex default_factory lambdas like:
  default_factory=lambda: A.model_validate({'type': 'b'})
  default_factory=lambda: TypeAdapter(A | B).validate_python({'type': 'b'})

Now generates:
  Field({'type': 'b'}, validate_default=True)

This lets Pydantic handle union branch selection, dict-to-model coercion,
and RootModel wrapping at runtime. Fixes koxudaxi#3034, fixes koxudaxi#3035.

Removed:
- ValidatedDefault class and SUPPORTS_VALIDATED_DEFAULT flag
- TypeAdapter import in generated code
- 70-line branch-matching loop in _get_default_as_pydantic_model
- Helper functions: _supports_validated_default_model,
  _uses_existing_model_factory_path, _default_matches_plain_container_branch,
  _get_validated_default_type_name

Added:
- _needs_validate_default: simple check (is default dict/list/scalar
  and does the type contain a model reference?)
- __set_validate_default_on_fields: sets extras["validate_default"] = True

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 13, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a3d6912b-1699-4406-9ed3-9230085713b8

📥 Commits

Reviewing files that changed from the base of the PR and between c599a5a and 8e08747.

📒 Files selected for processing (1)
  • tests/parser/test_base.py

📝 Walkthrough

Walkthrough

Removed custom WrappedDefault/ValidatedDefault wrappers and their flags; default handling now marks fields with Pydantic's validate_default=True instead of emitting wrapper-based default factories. Parser and model generation logic updated; many test expectations changed from default_factory to explicit defaults with validate_default=True.

Changes

Cohort / File(s) Summary
Removed wrapper types
src/datamodel_code_generator/model/_types.py, src/datamodel_code_generator/model/base.py
Deleted WrappedDefault and ValidatedDefault dataclasses, removed their imports/exports and capability flags from public modules.
Pydantic field/default handling
src/datamodel_code_generator/model/pydantic_base.py, src/datamodel_code_generator/model/pydantic_v2/base_model.py
Removed _get_default_as_pydantic_model, TypeAdapter usage, and support flags; simplified DataModelField.imports and altered default/default_factory serialization to rely on validate_default.
Parser default-validation refactor
src/datamodel_code_generator/parser/base.py
Removed multiple helper functions and wrapping flows; added _needs_validate_default(data_type) and __set_validate_default_on_fields usage to mark fields for validate_default=True instead of wrapping defaults.
Type/base adjustments
src/datamodel_code_generator/model/scalar.py
Changed _DataTypeScalarBase inheritance from DataModel to TypeAliasBase and adjusted TYPE_CHECKING imports.
Test expectations and fixtures
tests/data/expected/... (many files under tests/data/expected/main/jsonschema/*, .../openapi/*, .../graphql/*), tests/parser/test_base.py
Updated 30+ expected outputs: replaced Field(default_factory=...) with explicit defaults plus validate_default=True; removed TypeAdapter imports; updated tests to use _needs_validate_default.

Sequence Diagram(s)

sequenceDiagram
    participant Parser as Parser
    participant Generator as CodeGenerator
    participant Pydantic as PydanticModels
    Parser->>Generator: analyze schema, detect defaults
    alt default needs validation
        Parser->>Parser: _needs_validate_default(data_type) -> true
        Parser->>Generator: mark field extras["validate_default"]=True
    end
    Generator->>Pydantic: emit Field(default=literal, validate_default=True)
    Pydantic->>Runtime: validate default at model initialization
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • #3046: Modifies similar default-handling paths in pydantic_base.py (overlaps on _get_default_as_pydantic_model and related logic).
  • #2963: Changes how model/field defaults are propagated and handled; touches complementary default-generation paths.
  • #3040: Introduced wrapper types and TypeAdapter-based factories that this PR removes—directly related at code-level.

Suggested labels

breaking-change-analyzed, breaking-change

Suggested reviewers

  • koxudaxi
  • ilovelinux

Poem

🐰 I hopped through code, unwrapped the nests,
Dropped tiny factories, gave defaults a rest.
Now Pydantic checks them, tidy and bright,
No wrappers at dusk, just validated light.
Hooray — simpler defaults, snug and right! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: replacing default-wrapping mechanisms with Pydantic's validate_default flag for structured union defaults.
Linked Issues check ✅ Passed The PR fully addresses the coding objectives from both linked issues: #3034 (type-alias unions) and #3035 (inline union branch selection) are resolved by using validate_default=True instead of hardcoded validation paths.
Out of Scope Changes check ✅ Passed All changes directly support the core objective of replacing default-wrapping logic with validate_default=True; no unrelated modifications detected across source code, test files, and expected outputs.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/datamodel_code_generator/model/pydantic_base.py Dismissed
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Mar 13, 2026

Merging this PR will not alter performance

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

✅ 11 untouched benchmarks
⏩ 98 skipped benchmarks1


Comparing butvinm:fix/validate-default-union (8e08747) with main (76439c3)

Open in CodSpeed

Footnotes

  1. 98 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (76439c3) to head (8e08747).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##              main     #3050    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           86        85     -1     
  Lines        18031     17911   -120     
  Branches      2112      2074    -38     
==========================================
- Hits         18031     17911   -120     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

butvinm and others added 2 commits March 13, 2026 10:53
The caller already filters None/UNDEFINED defaults before calling this
function, so the None check was unreachable. Removed the unused `default`
parameter entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… defaults

WrappedDefault wrapped RootModel scalar defaults (e.g., CountType(10)) in
default_factory lambdas. Since validate_default=True handles this natively,
the entire mechanism is redundant.

Removed: WrappedDefault class, _types.py module, SUPPORTS_WRAPPED_DEFAULT
flag, __wrap_root_model_default_values method, _get_default_as_pydantic_model
method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
_DataTypeScalarBase generated the same type alias syntax as TypeAliasBase
but didn't inherit from it, causing _unwrap_type_alias to not see through
scalar references. This led to unnecessary validate_default=True on fields
like `String | None = 'default_user'`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/datamodel_code_generator/model/scalar.py
When a required field had validate_default=True set (e.g., empty list
default on list[Container]), the rendering logic prepended ... instead
of the actual default value. Now skips the ... override when
validate_default is set, since the default is needed for Pydantic to
validate at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@butvinm butvinm marked this pull request as ready for review March 13, 2026 13:31
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/datamodel_code_generator/model/pydantic_base.py (1)

212-218: ⚠️ Potential issue | 🟠 Major

use_annotated branch emits type-incompatible validated defaults that static type checkers won't understand.

When rendering with --use-annotated, the code generates patterns like Annotated[ModelSettingB | None, Field(validate_default=True)] = 5, placing the raw default on the right-hand side while the type annotation remains Annotated[Type | None, ...]. Pydantic v2 documentation explicitly warns that static type checkers do not understand defaults embedded in Annotated[..., Field(...)] metadata—they only recognize defaults from class-body assignments. However, when the raw default value type (e.g., 5) doesn't match the annotated type (e.g., ModelSettingB | None), type checkers will flag these assignments as incompatible.

To fix this, either render Field(default=..., validate_default=True) as the RHS assignment value (letting Pydantic handle the type), or fall back to the non-annotated form for fields with validate_default=True.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/datamodel_code_generator/model/pydantic_base.py` around lines 212 - 218,
The current use_annotated branch emits raw defaults that can conflict with the
Annotated type and static checkers when validate_default=True; change the logic
in the use_annotated branch (the code path guarded by self.use_annotated and the
helper _process_annotated_field_arguments) so that if
self.extras.get("validate_default") is truthy you output a RHS of
Field(default=<default_repr>, validate_default=True, ...) instead of the raw
default literal, or alternatively fall back to the non-annotated branch for that
case; update construction of field_arguments and the call site that consumes
field_arguments so the default is represented via Field(default=...) when
validate_default=True to keep the Annotated type and the assignment compatible
with type checkers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/parser/test_base.py`:
- Line 710: The docstring for the test currently says "Test
_needs_validate_default returns True for A | None with dict default." but the
test does not set a default; update the docstring to accurately describe the
assertion (e.g., "Test _needs_validate_default returns True for A | None.") so
it reflects that the test checks union composition involving None for the
_needs_validate_default behavior.

---

Outside diff comments:
In `@src/datamodel_code_generator/model/pydantic_base.py`:
- Around line 212-218: The current use_annotated branch emits raw defaults that
can conflict with the Annotated type and static checkers when
validate_default=True; change the logic in the use_annotated branch (the code
path guarded by self.use_annotated and the helper
_process_annotated_field_arguments) so that if
self.extras.get("validate_default") is truthy you output a RHS of
Field(default=<default_repr>, validate_default=True, ...) instead of the raw
default literal, or alternatively fall back to the non-annotated branch for that
case; update construction of field_arguments and the call site that consumes
field_arguments so the default is represented via Field(default=...) when
validate_default=True to keep the Annotated type and the assignment compatible
with type checkers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c3fa025d-169c-488b-8401-a832126aeda9

📥 Commits

Reviewing files that changed from the base of the PR and between 76439c3 and c599a5a.

📒 Files selected for processing (54)
  • src/datamodel_code_generator/model/_types.py
  • src/datamodel_code_generator/model/base.py
  • src/datamodel_code_generator/model/pydantic_base.py
  • src/datamodel_code_generator/model/pydantic_v2/base_model.py
  • src/datamodel_code_generator/model/scalar.py
  • src/datamodel_code_generator/parser/base.py
  • tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py
  • tests/data/expected/main/jsonschema/all_of_any_of_base_class_ref.py
  • tests/data/expected/main/jsonschema/has_default_value_pydantic_v2.py
  • tests/data/expected/main/jsonschema/jsonschema_root_model_ordering.py
  • tests/data/expected/main/jsonschema/jsonschema_root_model_ordering_keep_model_order.py
  • tests/data/expected/main/jsonschema/pydantic_v2_model_default_dict_empty.py
  • tests/data/expected/main/jsonschema/pydantic_v2_model_default_dict_non_empty.py
  • tests/data/expected/main/jsonschema/pydantic_v2_model_default_nullable_dict_empty.py
  • tests/data/expected/main/jsonschema/pydantic_v2_model_default_nullable_dict_non_empty.py
  • tests/data/expected/main/jsonschema/root_model_default_value.py
  • tests/data/expected/main/jsonschema/root_model_default_value_branches.py
  • tests/data/expected/main/jsonschema/root_model_default_value_no_annotated.py
  • tests/data/expected/main/jsonschema/root_model_default_value_non_root.py
  • tests/data/expected/main/jsonschema/titles.py
  • tests/data/expected/main/jsonschema/titles_use_title_as_name.py
  • tests/data/expected/main/jsonschema/type_alias_chain_model_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_chain_union_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_dict_union_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_inline_dict_union_default_object.py
  • tests/data/expected/main/jsonschema/type_alias_inline_list_union_default_object.py
  • tests/data/expected/main/jsonschema/type_alias_inline_union_default_object.py
  • tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_import_collision.py
  • tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_one_of.py
  • tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.py
  • tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_type_override.py
  • tests/data/expected/main/jsonschema/type_alias_list_model_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_list_union_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_model_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_nullable_list_model_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_nullable_model_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_union_default_object_ref.py
  • tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_any_of.py
  • tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.py
  • tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_mixed_scalar.py
  • tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_one_of.py
  • tests/data/expected/main/jsonschema/without_titles_use_title_as_name.py
  • tests/data/expected/main/openapi/default_values_parameters_use_default.py
  • tests/data/expected/main/openapi/enum_models/one.py
  • tests/data/expected/main/openapi/enum_models/one_literal_as_default.py
  • tests/data/expected/main/openapi/pydantic_v2_default_object/Another.py
  • tests/data/expected/main/openapi/pydantic_v2_default_object/Nested.py
  • tests/data/expected/main/openapi/pydantic_v2_default_object/__init__.py
  • tests/data/expected/main/openapi/pydantic_v2_empty_list_default.py
  • tests/data/expected/main/openapi/referenced_default.py
  • tests/data/expected/main/openapi/referenced_default_use_annotated.py
  • tests/data/expected/main/openapi/root_model_default_primitive.py
  • tests/data/expected/parser/openapi/openapi_parser_parse_enum_models/output.py
  • tests/parser/test_base.py
💤 Files with no reviewable changes (3)
  • src/datamodel_code_generator/model/pydantic_v2/base_model.py
  • src/datamodel_code_generator/model/base.py
  • src/datamodel_code_generator/model/_types.py

Comment thread tests/parser/test_base.py Outdated
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@koxudaxi koxudaxi merged commit df834dc into koxudaxi:main Mar 14, 2026
37 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

Breaking Change Analysis

Result: Breaking changes detected

Reasoning: This PR changes how default values are generated for fields containing model references. The generated code format changes from complex default_factory lambdas to simpler Field() calls with validate_default=True. Additionally, internal types (ValidatedDefault, WrappedDefault) that were exported in all and class variables (SUPPORTS_WRAPPED_DEFAULT, SUPPORTS_VALIDATED_DEFAULT) on DataModel base class were removed. While these were implementation details, they were technically public API that could be imported or overridden by advanced users.

Content for Release Notes

Code Generation Changes

  • Generated default field syntax changed - Fields with structured defaults (dicts, lists, model references) now use Field(default_value, validate_default=True) instead of default_factory=lambda: TypeAdapter(...).validate_python(...) or default_factory=lambda: Model.model_validate(...). This produces simpler, more readable code but changes the generated output format. (Use validate_default=True for structured union defaults #3050)

  • TypeAdapter import removed from generated code - Generated models no longer import TypeAdapter from pydantic since validate_default=True handles validation natively. (Use validate_default=True for structured union defaults #3050)

API/CLI Changes

  • ValidatedDefault and WrappedDefault classes removed - These internal classes were exported from datamodel_code_generator.model.base and have been removed. Code importing these types will break:

    # Before (broken)
    from datamodel_code_generator.model.base import ValidatedDefault, WrappedDefault

    (Use validate_default=True for structured union defaults #3050)

  • SUPPORTS_WRAPPED_DEFAULT and SUPPORTS_VALIDATED_DEFAULT class variables removed - These flags were removed from the DataModel base class. Custom model classes that override these variables will see attribute errors. (Use validate_default=True for structured union defaults #3050)


This analysis was performed by Claude Code Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 4, 2026

🎉 Released in 0.56.0

This PR is now available in the latest release. See the release notes for details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants