Repository: pydantic/pydantic
Pull request: Properly validate parameterized mappings
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->

## Change Summary

Fixes https://github.com/pydantic/pydantic/issues/11650.

Regression in https://github.com/pydantic/pydantic/pull/11247, the logic was somehow tweaked when moved to the `GenerateSchema` class, so it was restored to how it was before.

<!-- Please give a short summary of the changes. -->

## Related issue number

<!-- WARNING: please use "fix #123" style references so the issue is closed when this PR is merged. -->

## Checklist

* [ ] The pull request title is a good summary of the changes - it will be used in the changelog
* [ ] Unit tests for the changes exist
* [ ] Tests pass on CI
* [ ] Documentation reflects the changes where applicable
* [ ] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Problem statement:
Regression 2.10: Not properly validating disjunction of dataclasses (or TypedDict) that embed Mapping
### Initial Checks

- [x] I confirm that I'm using Pydantic V2

### Description

Pydantic does not properly validate minimal example below (whereas it was in 2.10.6)


### Example Code

```Python
from collections.abc import Mapping

from pydantic import TypeAdapter, RootModel
from pydantic.dataclasses import dataclass


@dataclass
class Unused:
    anything: int


@dataclass  # same buggy outcome with TypedDict here
class MyClass:
    root: Mapping[str, int]  # would work with dict


invalid = {"root": {"valid": 1, "invalid": {}}}

# both should raise but do not
a = TypeAdapter(MyClass | Unused).validate_python(invalid)
b = RootModel[MyClass | Unused].model_validate(invalid)
```

### Python, Pydantic & OS Version

```Text
pydantic version: 2.11.1
        pydantic-core version: 2.33.0
          pydantic-core build: profile=release pgo=false
               python version: 3.13.2 (main) [GCC 14.2.0]
                     platform: Linux-5.15.167.4-x86_64-with-glibc2.40
             related packages: pyright-1.1.396 mypy-1.15.0 typing_extensions-4.13.0
```

Hints from issue discussion:

Commit message: Properly validate parameterized mappings

Patch to review:
diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py
index c7efbf5b209..8a7f72f7d47 100644
--- a/pydantic/_internal/_generate_schema.py
+++ b/pydantic/_internal/_generate_schema.py
@@ -566,32 +566,38 @@ def _deque_schema(self, items_type: Any) -> CoreSchema:
     def _mapping_schema(self, tp: Any, keys_type: Any, values_type: Any) -> CoreSchema:
         from ._validators import MAPPING_ORIGIN_MAP, defaultdict_validator, get_defaultdict_default_default_factory
 
+        mapped_origin = MAPPING_ORIGIN_MAP[tp]
         keys_schema = self.generate_schema(keys_type)
         values_schema = self.generate_schema(values_type)
+        dict_schema = core_schema.dict_schema(keys_schema, values_schema, strict=False)
 
-        dict_schema = core_schema.dict_schema(keys_schema, values_schema)
-        check_instance = core_schema.json_or_python_schema(
-            json_schema=dict_schema,
-            python_schema=core_schema.is_instance_schema(tp),
-        )
-
-        if tp is collections.defaultdict:
-            default_default_factory = get_defaultdict_default_default_factory(values_type)
-            coerce_instance_wrap = partial(
-                core_schema.no_info_wrap_validator_function,
-                partial(defaultdict_validator, default_default_factory=default_default_factory),
-            )
+        if mapped_origin is dict:
+            schema = dict_schema
         else:
-            coerce_instance_wrap = partial(core_schema.no_info_after_validator_function, MAPPING_ORIGIN_MAP[tp])
+            check_instance = core_schema.json_or_python_schema(
+                json_schema=dict_schema,
+                python_schema=core_schema.is_instance_schema(mapped_origin),
+            )
 
-        lax_schema = coerce_instance_wrap(dict_schema)
-        schema = core_schema.lax_or_strict_schema(
-            lax_schema=lax_schema,
-            strict_schema=core_schema.union_schema([check_instance, lax_schema]),
-            serialization=core_schema.wrap_serializer_function_ser_schema(
-                lambda v, h: h(v), schema=dict_schema, info_arg=False
-            ),
-        )
+            if tp is collections.defaultdict:
+                default_default_factory = get_defaultdict_default_default_factory(values_type)
+                coerce_instance_wrap = partial(
+                    core_schema.no_info_wrap_validator_function,
+                    partial(defaultdict_validator, default_default_factory=default_default_factory),
+                )
+            else:
+                coerce_instance_wrap = partial(core_schema.no_info_after_validator_function, mapped_origin)
+
+            lax_schema = coerce_instance_wrap(dict_schema)
+            strict_schema = core_schema.chain_schema([check_instance, lax_schema])
+
+            schema = core_schema.lax_or_strict_schema(
+                lax_schema=lax_schema,
+                strict_schema=strict_schema,
+                serialization=core_schema.wrap_serializer_function_ser_schema(
+                    lambda v, h: h(v), schema=dict_schema, info_arg=False
+                ),
+            )
 
         return schema
 
diff --git a/tests/test_types.py b/tests/test_types.py
index 25b9cab3e14..2e3ea0f1e93 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -9,8 +9,8 @@
 import sys
 import typing
 import uuid
-from collections import Counter, OrderedDict, defaultdict, deque
-from collections.abc import Iterable, Sequence
+from collections import Counter, OrderedDict, UserDict, defaultdict, deque
+from collections.abc import Iterable, Mapping, MutableMapping, Sequence
 from dataclasses import dataclass
 from datetime import date, datetime, time, timedelta, timezone
 from decimal import Decimal
@@ -6951,17 +6951,23 @@ class Foo(BaseModel):
     assert exc_info.value.errors(include_url=False) == errors
 
 
-def test_mutable_mapping() -> None:
+def test_mutable_mapping_userdict_subclass() -> None:
     """Addresses https://github.com/pydantic/pydantic/issues/9549.
 
     Note - we still don't do a good job of handling subclasses, as we convert the input to a dict
     via the MappingValidator annotation's schema.
     """
-    import collections.abc
+    adapter = TypeAdapter(MutableMapping, config=ConfigDict(strict=True))
 
-    adapter = TypeAdapter(collections.abc.MutableMapping, config=ConfigDict(strict=True))
+    assert isinstance(adapter.validate_python(UserDict()), MutableMapping)
 
-    assert isinstance(adapter.validate_python(collections.UserDict()), collections.abc.MutableMapping)
+
+def test_mapping_parameterized() -> None:
+    """https://github.com/pydantic/pydantic/issues/11650"""
+    adapter = TypeAdapter(Mapping[str, int])
+
+    with pytest.raises(ValidationError):
+        adapter.validate_python({'valid': 1, 'invalid': {}})
 
 
 def test_ser_ip_with_union() -> None:


Code review comments:
diff_hunk: @@ -6951,17 +6951,23 @@ class Foo(BaseModel):
     assert exc_info.value.errors(include_url=False) == errors
 
 
-def test_mutable_mapping() -> None:
+def test_mutable_mapping_userdict_subclass() -> None:
     """Addresses https://github.com/pydantic/pydantic/issues/9549.
 
     Note - we still don't do a good job of handling subclasses, as we convert the input to a dict
     via the MappingValidator annotation's schema.
path: tests/test_types.py
text: @user1:
Is this comment now out of date?

@author:
Good catch -- well the `MappingValidator` is not a thing anymore but I think the comment is still relevant.