Skip to content

fix(pydantic): avoid returning stale cached models on custom config, validators, or module - #2325

Merged
waketzheng merged 8 commits into
tortoise:developfrom
Fire162:fix/pydantic-creator-cache-args
Oct 24, 2026
Merged

waketzheng merged 8 commits into
tortoise:developfrom
Fire162:fix/pydantic-creator-cache-args

Conversation

@Fire162

@Fire162 Fire162 commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Description

pydantic_model_creator caches generated pydantic models so repeat calls for the same Tortoise model reuse the created class.

However, _is_default only checked parameters like exclude, include, computed, allow_cycles, meta_override, and exclude_readonly. When model_config, validators, or custom module were supplied without overriding other arguments, _is_default evaluated to True and returned the default model from cls._pydantic_model. Furthermore, the non-default hash calculation did not incorporate model_config, validators, or module, so differing configurations with the same subset of fields produced identical cache keys.

This patch:

  • Updates _is_default to ensure model_config is None, validators is None, and module == __name__.
  • Appends non-empty model_config, validators, and non-default module to the hash calculation so distinct models are keyed independently while preserving legacy hash stability for default models.
  • Adds regression tests in tests/contrib/test_pydantic.py covering custom configs, custom validators, and custom modules.
  • Adds changelog entry.

Motivation and Context

Fixes #2310

How Has This Been Tested?

Ran the test suite locally with pytest tests/contrib/test_pydantic.py (50 tests passing) and checked formatting with ruff check and ruff format --check.

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added the changelog accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@codspeed

codspeed Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 21 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 24 untouched benchmarks


Comparing Fire162:fix/pydantic-creator-cache-args (54463cf) with develop (b991687)

Open in CodSpeed

@waketzheng

Copy link
Copy Markdown
Contributor

@Fire162 Looks OK. Cloud you update the changelog?

BTW: Currently, the Pydantic creator does not yet support mypy checking well:

from tortoise.contrib.pydantic.creator import PydanticMeta

if TYPE_CHECKING:
    class Event_Pydantic(Event, PydanticMeta): ...
else:
    Event_Pydantic = pydantic_model_creator(Event)

Is there any way to make pydantic_model_creator better support mypy?

Comment thread CHANGELOG.rst Resolved
@waketzheng

Copy link
Copy Markdown
Contributor

Thanks for working on this — the _is_default fix (requiring model_config, validators, and module to all be defaults) is the right direction and directly addresses #2310.

We've decided to go with the approach in #2312, and I want to explain why so it doesn't look like the work here is being dismissed.

The core difference is how validators are handled. In #2312, non-empty validators bypass the cache entirely; model_config and module still participate in the hash and produce stable keys. The reason is that hashing validators reliably is harder than it looks:

  • co_code (bytecode) is not stable across Python versions or implementations. The same validator function compiles to different bytecode on 3.10 vs 3.11 vs 3.12, and differently again on PyPy. A cache key derived from it would change depending on the interpreter, so the same call could miss the cache in one environment and hit it in another.
  • co_consts can contain nested function objects, large literals, or values whose repr/identity varies.
  • repr(dec_info) will include a memory address if decorator_info's repr isn't overridden, which changes every run.
  • Even a best-effort hash can't reliably distinguish two closures with the same __qualname__ and bytecode but different captured state, so two semantically different validators could share a cache entry.

Bypassing the cache for validators trades a small perf win for a hard guarantee that a model never reuses another caller's validators. Validators are usually passed once at definition time, not in a hot loop, so the rebuild cost is rarely visible.

So we'll go with #2312 for the cache strategy. Thanks again for the analysis here — the _is_default reasoning carried over directly.

@waketzheng

Copy link
Copy Markdown
Contributor

Separate note on the tests — I've carried them over to #2312 with one adjustment, and also bumped the pydantic dependency while I was there.

test_pydantic_creator_cache_model_config and test_pydantic_creator_cache_module are implementation-agnostic, so they went in as-is. They assert that different configs don't share a cached class and that the same config does, which holds regardless of the implementation.

test_pydantic_creator_cache_validators needed its second half rewritten for the new semantics:

cached_custom = pydantic_model_creator(
    Tournament, validators={"val": field_validator("name")(validate_name)}
)
assert cached_custom is custom_model

With validators bypassing the cache, two calls with the same validators return different model objects, so the assertion is now assert cached_custom is not custom_model, and the test is renamed to test_pydantic_creator_bypasses_cache_for_validators.

I also upgraded the pinned pydantic version as part of this — the new tests exercise ConfigDict and field_validator behavior more directly than the existing suite did, and the older pin had some gaps around that.

Thanks again for the tests — they cover behavior neither PR's original suite locked in, and the credit for that coverage belongs here.

@waketzheng
waketzheng merged commit cec625e into tortoise:develop Oct 24, 2026
25 checks passed
d3vyce pushed a commit to d3vyce/tortoise-orm that referenced this pull request Oct 24, 2026
- Add more regression tests covering model_config, validators, and module
  combinations.
- Switch read-only cases in tests/contrib/test_pydantic.py from db to
  db_simple — they don't need the transaction/rollback behavior.
- Bump pydantic (2.13.5 on Python 3.10, 2.14.1 on 3.11+).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pydantic_model_creator returns a cached class that ignores model_config, validators and module

2 participants