Repository: pydantic/pydantic
Pull request: Add eval_type_backport to handle union operator and builtin generic subscripting in older Pythons
Closes https://github.com/pydantic/pydantic/issues/7873

(previously #7958)

Replaces all calls (outside v1) to `typing._eval_type` with the new `typing_extra.eval_type_backport` which falls back to https://github.com/alexmojaki/eval_type_backport if installed.

Problem statement:
future annotations with Python 3.8 cause TypeError
### Initial Checks

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

### Description

Using `__future__ import annotations` with Python3.8 and Pydantic 2.4.2 cause:

```
TypeError: unsupported operand type(s) for |: 'type' and 'type'
```


### Example Code

```Python
from __future__ import annotations
from pydantic import BaseModel

class Bios(BaseModel):
    value: int | str

def p(d: int | str | Bios) -> None:
    print(d)

if __name__ == '__main__':
    p('a')
    p(1)
    p(Bios(value=2))
    p(Bios(value='s'))
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.4.2
pydantic-core version: 2.10.1
pydantic-core build: profile=release pgo=false
install path: C:\Users\mplic\Projects\venvs\dcspy_38\Lib\site-packages\pydantic
python version: 3.8.10 (tags/v3.8.10:3d8993a, May  3 2021, 11:48:03) [MSC v.1928 64 bit (AMD64)]
platform: Windows-10-10.0.19045-SP0
related packages: mypy-1.6.1 typing_extensions-4.7.1
```

Hints from issue discussion:
Hi @emcek,

Thanks for reporting this! I was able to replicate this issue. Indeed seems like a bug 🐛. We will look into a fix!
I can confirm I have the same bug when using Python 3.9.

**Python, Pydantic & OS Version**

```
pydantic version: 2.4.2
pydantic-core version: 2.10.1
pydantic-core build: profile=release pgo=false
install path: /Users/benroland/python_projects/mltoolbox/.venv/lib/python3.9/site-packages/pydantic
python version: 3.9.18 (main, Oct 20 2023, 11:16:30)  [Clang 15.0.0 (clang-1500.0.40.1)]
platform: macOS-13.6-arm64-arm-64bit
related packages: mypy-1.6.1 typing_extensions-4.8.0
```
I feel like I'm missing something...is this just expected to work? Even with `__future__ import annotations`, pydantic needs to actually evaluate `int | str` at runtime, and it's completely normal that before Python 3.10 this raises an error. This is a limitation of older versions of Python for any program that needs type hints at runtime, not just pydantic. Same for using `list[str]` instead of `typing.List[str]`.

**Maybe** this *could* be made to work, but it seems more like a feature request than a bug.
The solution I'm imagining is modifying the AST to replace `|` with `Union` before evaluating. I think it'll work, I'm just not sure if it's worth the complexity to make it slightly more convenient to use older Python versions.
> The solution I'm imagining is modifying the AST to replace `|` with `Union` before evaluating. I think it'll work, I'm just not sure if it's worth the complexity to make it slightly more convenient to use older Python versions.

Yes, `Union` will work. This is a way I discover this issue try migrate from `Union` to `|` for Python 3.8 using `future` import.
@alexmojaki @emcek,

I'm inclined to think this is a bug with Pydantic due to [PEP 563](https://peps.python.org/pep-0563/) and the pydantic docs on [postponed annotations](https://docs.pydantic.dev/latest/concepts/postponed_annotations/).
OK, then I'll take this one.
@alexmojaki Awesome! I'll take a look at your PRs when I have a chance today / tmrw. Thanks for all of the contributions 😄.
@alexmojaki ping me when PR is ready I would love to check solution...
> I feel like I'm missing something...is this just expected to work? Even with `__future__ import annotations`, pydantic needs to actually evaluate `int | str` at runtime, and it's completely normal that before Python 3.10 this raises an error. This is a limitation of older versions of Python for any program that needs type hints at runtime, not just pydantic. Same for using `list[str]` instead of `typing.List[str]`.
> 
> **Maybe** this _could_ be made to work, but it seems more like a feature request than a bug.

I agree. This is why I made a POC+ in https://github.com/PrettyWood/future-typing but it still needs some work and IMO is not worth the time. I don't see how it should work without that

Commit message: Add test dependency

Patch to review:
diff --git a/pdm.lock b/pdm.lock
index d37751aac08..a09de9af52f 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
 groups = ["default", "docs", "email", "linting", "memray", "mypy", "testing", "testing-extra"]
 strategy = ["cross_platform"]
 lock_version = "4.4"
-content_hash = "sha256:bfa326e0a46425e1cdaf0d38b8442a10a0a70771a0a7251918eb4b83fecb2298"
+content_hash = "sha256:b7cfab0c90511e67f931fe8b7ee13ea82e396405edbd11057bde7f0f41fa0082"
 
 [[package]]
 name = "annotated-types"
@@ -461,6 +461,16 @@ files = [
     {file = "email_validator-2.1.0.post1.tar.gz", hash = "sha256:a4b0bd1cf55f073b924258d19321b1f3aa74b4b5a71a42c305575dba920e1a44"},
 ]
 
+[[package]]
+name = "eval-type-backport"
+version = "0.0.1"
+requires_python = ">=3.7"
+summary = "Like `typing._eval_type`, but lets older Python versions use newer typing features."
+files = [
+    {file = "eval_type_backport-0.0.1-py3-none-any.whl", hash = "sha256:3aa0d990e642af9d8f9480c84dcaeabaaef83207bdcf5cb018706db6bb77d692"},
+    {file = "eval_type_backport-0.0.1.tar.gz", hash = "sha256:3328c532f0d159f84756b11bd932ff2b5f93a7c605a603af02de0e38566c86df"},
+]
+
 [[package]]
 name = "exceptiongroup"
 version = "1.1.3"
diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py
index 84f8a637dbd..d8a4aa57856 100644
--- a/pydantic/_internal/_generate_schema.py
+++ b/pydantic/_internal/_generate_schema.py
@@ -638,7 +638,7 @@ def _resolve_forward_ref(self, obj: Any) -> Any:
         # class Model(BaseModel):
         #   x: SomeImportedTypeAliasWithAForwardReference
         try:
-            obj = _typing_extra.evaluate_fwd_ref(obj, globalns=self._types_namespace)
+            obj = _typing_extra.eval_type_backport(obj, globalns=self._types_namespace)
         except NameError as e:
             raise PydanticUndefinedAnnotation.from_name_error(e) from e
 
diff --git a/pydantic/_internal/_typing_extra.py b/pydantic/_internal/_typing_extra.py
index d40d91d78c3..f2de562c853 100644
--- a/pydantic/_internal/_typing_extra.py
+++ b/pydantic/_internal/_typing_extra.py
@@ -8,7 +8,7 @@
 from collections.abc import Callable
 from functools import partial
 from types import GetSetDescriptorType
-from typing import TYPE_CHECKING, Any, Final, ForwardRef
+from typing import TYPE_CHECKING, Any, Final
 
 from typing_extensions import Annotated, Literal, TypeAliasType, TypeGuard, get_args, get_origin
 
@@ -221,12 +221,36 @@ def eval_type_lenient(value: Any, globalns: dict[str, Any] | None, localns: dict
         value = _make_forward_ref(value, is_argument=False, is_class=True)
 
     try:
-        return typing._eval_type(value, globalns, localns)  # type: ignore
+        return eval_type_backport(value, globalns, localns)
     except NameError:
         # the point of this function is to be tolerant to this case
         return value
 
 
+def is_unsupported_types_for_union_error(e: TypeError) -> bool:
+    return str(e).startswith('unsupported operand type(s) for |: ')
+
+
+def eval_type_backport(
+    value: Any, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
+) -> Any:
+    """Like `typing._eval_type`, but falls back to the `eval_type_backport` package if it's
+    installed to let older Python versions use newer typing features.
+    Currently this just means that `X | Y` is converted to `Union[X, Y]` if `X | Y` is not supported.
+    This would also be the place to add support for `list[int]` instead of `List[int]` etc.
+    """
+    try:
+        return typing._eval_type(  # type: ignore
+            value, globalns, localns
+        )
+    except TypeError as e:
+        if not (isinstance(value, typing.ForwardRef) and is_unsupported_types_for_union_error(e)):
+            raise
+        from eval_type_backport import eval_type_backport
+
+        return eval_type_backport(value, globalns, localns, try_default=False)
+
+
 def get_function_type_hints(
     function: Callable[..., Any], *, include_keys: set[str] | None = None, types_namespace: dict[str, Any] | None = None
 ) -> dict[str, Any]:
@@ -248,7 +272,7 @@ def get_function_type_hints(
         elif isinstance(value, str):
             value = _make_forward_ref(value)
 
-        type_hints[name] = typing._eval_type(value, globalns, types_namespace)  # type: ignore
+        type_hints[name] = eval_type_backport(value, globalns, types_namespace)
 
     return type_hints
 
@@ -363,7 +387,7 @@ def get_type_hints(  # noqa: C901
                     if isinstance(value, str):
                         value = _make_forward_ref(value, is_argument=False, is_class=True)
 
-                    value = typing._eval_type(value, base_globals, base_locals)  # type: ignore
+                    value = eval_type_backport(value, base_globals, base_locals)
                     hints[name] = value
             return (
                 hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()}  # type: ignore
@@ -403,28 +427,13 @@ def get_type_hints(  # noqa: C901
                     is_argument=not isinstance(obj, types.ModuleType),
                     is_class=False,
                 )
-            value = typing._eval_type(value, globalns, localns)  # type: ignore
+            value = eval_type_backport(value, globalns, localns)
             if name in defaults and defaults[name] is None:
                 value = typing.Optional[value]
             hints[name] = value
         return hints if include_extras else {k: typing._strip_annotations(t) for k, t in hints.items()}  # type: ignore
 
 
-if sys.version_info < (3, 9):
-
-    def evaluate_fwd_ref(
-        ref: ForwardRef, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
-    ) -> Any:
-        return ref._evaluate(globalns=globalns, localns=localns)
-
-else:
-
-    def evaluate_fwd_ref(
-        ref: ForwardRef, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
-    ) -> Any:
-        return ref._evaluate(globalns=globalns, localns=localns, recursive_guard=frozenset())
-
-
 def is_dataclass(_cls: type[Any]) -> TypeGuard[type[StandardDataclass]]:
     # The dataclasses.is_dataclass function doesn't seem to provide TypeGuard functionality,
     # so I created this convenience function
diff --git a/pyproject.toml b/pyproject.toml
index 9e50d85c195..50af2fc4bcb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,57 +20,6 @@ include = [
     '/requirements',
 ]
 
-[project]
-name = 'pydantic'
-description = 'Data validation using Python type hints'
-authors = [
-    {name = 'Samuel Colvin', email = 's@muelcolvin.com'},
-    {name = 'Eric Jolibois', email = 'em.jolibois@gmail.com'},
-    {name = 'Hasan Ramezani', email = 'hasan.r67@gmail.com'},
-    {name = 'Adrian Garcia Badaracco', email = '1755071+adriangb@users.noreply.github.com'},
-    {name = 'Terrence Dorsey', email = 'terry@pydantic.dev'},
-    {name = 'David Montague', email = 'david@pydantic.dev'},
-    {name = 'Serge Matveenko', email = 'lig@countzero.co'},
-    {name = 'Marcelo Trylesinski', email = 'marcelotryle@gmail.com'},
-    {name = 'Sydney Runkle', email = 'sydneymarierunkle@gmail.com'},
-    {name = 'David Hewitt', email = 'mail@davidhewitt.io'},
-]
-license = 'MIT'
-classifiers = [
-    'Development Status :: 5 - Production/Stable',
-    'Programming Language :: Python',
-    'Programming Language :: Python :: Implementation :: CPython',
-    'Programming Language :: Python :: Implementation :: PyPy',
-    'Programming Language :: Python :: 3',
-    'Programming Language :: Python :: 3 :: Only',
-    'Programming Language :: Python :: 3.8',
-    'Programming Language :: Python :: 3.9',
-    'Programming Language :: Python :: 3.10',
-    'Programming Language :: Python :: 3.11',
-    'Programming Language :: Python :: 3.12',
-    'Intended Audience :: Developers',
-    'Intended Audience :: Information Technology',
-    'Intended Audience :: System Administrators',
-    'License :: OSI Approved :: MIT License',
-    'Operating System :: Unix',
-    'Operating System :: POSIX :: Linux',
-    'Environment :: Console',
-    'Environment :: MacOS X',
-    'Framework :: Hypothesis',
-    'Framework :: Pydantic',
-    'Topic :: Software Development :: Libraries :: Python Modules',
-    'Topic :: Internet',
-]
-requires-python = '>=3.8'
-dependencies = [
-    'typing-extensions>=4.6.1',
-    'annotated-types>=0.4.0',
-    "pydantic-core==2.14.3",
-]
-dynamic = ['version', 'readme']
-
-[project.optional-dependencies]
-email = ['email-validator>=2.0.0']
 
 [tool.pdm.dev-dependencies]
 docs = [
@@ -105,6 +54,7 @@ testing = [
     "faker>=18.13.0",
     "pytest-benchmark>=4.0.0",
     "pytest-codspeed~=2.2.0",
+    "eval-type-backport>=0.0.1",
 ]
 testing-extra = [
     # used when generate devtools docs example
@@ -126,6 +76,58 @@ memray = [
 # requires Python > 3.8, we only test with 3.8 in CI but because of it won't lock properly
 pytest-memray = "1.5.0"
 
+[project]
+name = 'pydantic'
+description = 'Data validation using Python type hints'
+authors = [
+    {name = 'Samuel Colvin', email = 's@muelcolvin.com'},
+    {name = 'Eric Jolibois', email = 'em.jolibois@gmail.com'},
+    {name = 'Hasan Ramezani', email = 'hasan.r67@gmail.com'},
+    {name = 'Adrian Garcia Badaracco', email = '1755071+adriangb@users.noreply.github.com'},
+    {name = 'Terrence Dorsey', email = 'terry@pydantic.dev'},
+    {name = 'David Montague', email = 'david@pydantic.dev'},
+    {name = 'Serge Matveenko', email = 'lig@countzero.co'},
+    {name = 'Marcelo Trylesinski', email = 'marcelotryle@gmail.com'},
+    {name = 'Sydney Runkle', email = 'sydneymarierunkle@gmail.com'},
+    {name = 'David Hewitt', email = 'mail@davidhewitt.io'},
+]
+license = 'MIT'
+classifiers = [
+    'Development Status :: 5 - Production/Stable',
+    'Programming Language :: Python',
+    'Programming Language :: Python :: Implementation :: CPython',
+    'Programming Language :: Python :: Implementation :: PyPy',
+    'Programming Language :: Python :: 3',
+    'Programming Language :: Python :: 3 :: Only',
+    'Programming Language :: Python :: 3.8',
+    'Programming Language :: Python :: 3.9',
+    'Programming Language :: Python :: 3.10',
+    'Programming Language :: Python :: 3.11',
+    'Programming Language :: Python :: 3.12',
+    'Intended Audience :: Developers',
+    'Intended Audience :: Information Technology',
+    'Intended Audience :: System Administrators',
+    'License :: OSI Approved :: MIT License',
+    'Operating System :: Unix',
+    'Operating System :: POSIX :: Linux',
+    'Environment :: Console',
+    'Environment :: MacOS X',
+    'Framework :: Hypothesis',
+    'Framework :: Pydantic',
+    'Topic :: Software Development :: Libraries :: Python Modules',
+    'Topic :: Internet',
+]
+requires-python = '>=3.8'
+dependencies = [
+    'typing-extensions>=4.6.1',
+    'annotated-types>=0.4.0',
+    "pydantic-core==2.14.3",
+]
+dynamic = ['version', 'readme']
+
+[project.optional-dependencies]
+email = ['email-validator>=2.0.0']
+
 [project.urls]
 Homepage = 'https://github.com/pydantic/pydantic'
 Documentation = 'https://docs.pydantic.dev'
@@ -133,6 +135,7 @@ Funding = 'https://github.com/sponsors/samuelcolvin'
 Source = 'https://github.com/pydantic/pydantic'
 Changelog = 'https://docs.pydantic.dev/latest/changelog/'
 
+
 [tool.hatch.metadata.hooks.fancy-pypi-readme]
 content-type = 'text/markdown'
 # construct the PyPI readme from README.md and HISTORY.md
diff --git a/tests/test_config.py b/tests/test_config.py
index 8111a0d2610..b2e34883502 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -4,7 +4,7 @@
 from contextlib import nullcontext as does_not_raise
 from decimal import Decimal
 from inspect import signature
-from typing import Any, ContextManager, Iterable, NamedTuple, Optional, Type, Union, get_type_hints
+from typing import Any, ContextManager, Iterable, NamedTuple, Optional, Type, Union
 
 from dirty_equals import HasRepr, IsPartialDict
 from pydantic_core import SchemaError, SchemaSerializer, SchemaValidator
@@ -24,6 +24,7 @@
 )
 from pydantic._internal._config import ConfigWrapper, config_defaults
 from pydantic._internal._mock_val_ser import MockValSer
+from pydantic._internal._typing_extra import get_type_hints
 from pydantic.config import ConfigDict, JsonValue
 from pydantic.dataclasses import dataclass as pydantic_dataclass
 from pydantic.errors import PydanticUserError
@@ -523,7 +524,7 @@ class Child(Mixin, Parent):
     assert Child.model_config.get('use_enum_values') is True
 
 
-@pytest.mark.skipif(sys.version_info < (3, 10), reason='different on older versions')
+@pytest.mark.skipif(sys.version_info < (3, 9), reason='different on older versions')
 def test_config_wrapper_match():
     localns = {'_GenerateSchema': GenerateSchema, 'GenerateSchema': GenerateSchema, 'JsonValue': JsonValue}
     config_dict_annotations = [(k, str(v)) for k, v in get_type_hints(ConfigDict, localns=localns).items()]
@@ -567,7 +568,7 @@ def check_foo(cls, v):
     assert src_exc.__notes__[0] == '\nPydantic: cause of loc: foo'
 
 
-@pytest.mark.skipif(sys.version_info < (3, 10), reason='different on older versions')
+@pytest.mark.skipif(sys.version_info < (3, 9), reason='different on older versions')
 def test_config_defaults_match():
     localns = {'_GenerateSchema': GenerateSchema, 'GenerateSchema': GenerateSchema}
     config_dict_keys = sorted(list(get_type_hints(ConfigDict, localns=localns).keys()))
diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py
index a1e4291a44e..f8ef00197c6 100644
--- a/tests/test_dataclasses.py
+++ b/tests/test_dataclasses.py
@@ -8,7 +8,7 @@
 from dataclasses import InitVar
 from datetime import date, datetime
 from pathlib import Path
-from typing import Any, Callable, ClassVar, Dict, FrozenSet, Generic, List, Optional, Set, TypeVar, Union
+from typing import Any, Callable, ClassVar, Dict, FrozenSet, Generic, List, Optional, Set, TypeVar
 
 import pytest
 from dirty_equals import HasRepr
@@ -1346,7 +1346,7 @@ class B:
 
     @pydantic.dataclasses.dataclass
     class Top:
-        sub: Union[A, B] = dataclasses.field(metadata=dict(discriminator='l'))
+        sub: 'A | B' = dataclasses.field(metadata=dict(discriminator='l'))
 
     t = Top(sub=A(l='a'))
     assert isinstance(t, Top)
diff --git a/tests/test_discriminated_union.py b/tests/test_discriminated_union.py
index 251fee3d31c..8406f382190 100644
--- a/tests/test_discriminated_union.py
+++ b/tests/test_discriminated_union.py
@@ -66,7 +66,7 @@ def test_discriminated_union_invalid_type():
     ):
 
         class Model(BaseModel):
-            x: Union[str, int] = Field(..., discriminator='qwe')
+            x: 'str | int' = Field(..., discriminator='qwe')
 
 
 def test_discriminated_union_defined_discriminator():
@@ -80,7 +80,7 @@ class Dog(BaseModel):
     with pytest.raises(PydanticUserError, match="Model 'Cat' needs a discriminator field for key 'pet_type'"):
 
         class Model(BaseModel):
-            pet: Union[Cat, Dog] = Field(..., discriminator='pet_type')
+            pet: 'Cat | Dog' = Field(..., discriminator='pet_type')
             number: int
 
 
@@ -96,7 +96,7 @@ class Dog(BaseModel):
     with pytest.raises(PydanticUserError, match="Model 'Cat' needs field 'pet_type' to be of type `Literal`"):
 
         class Model(BaseModel):
-            pet: Union[Cat, Dog] = Field(..., discriminator='pet_type')
+            pet: 'Cat | Dog' = Field(..., discriminator='pet_type')
             number: int
 
 
@@ -331,7 +331,7 @@ class B(BaseModel):
         foo: Literal['b']
 
     class Top(BaseModel):
-        sub: Union[A, B] = Field(..., discriminator='foo')
+        sub: 'A | B' = Field(..., discriminator='foo')
 
     t = Top(sub=A(foo='a'))
     assert isinstance(t, Top)
@@ -346,7 +346,7 @@ class B(BaseModel):
         literal: Literal['b'] = Field(alias='lit')
 
     class Top(BaseModel):
-        sub: Union[A, B] = Field(..., discriminator='literal')
+        sub: 'A | B' = Field(..., discriminator='literal')
 
     with pytest.raises(ValidationError) as exc_info:
         Top(sub=A(literal='a'))
@@ -367,7 +367,7 @@ class B(BaseModel):
         m: Literal[2]
 
     class Top(BaseModel):
-        sub: Union[A, B] = Field(..., discriminator='m')
+        sub: 'A | B' = Field(..., discriminator='m')
 
     assert isinstance(Top.model_validate({'sub': {'m': 2}}).sub, B)
     with pytest.raises(ValidationError) as exc_info:
@@ -416,7 +416,7 @@ class B(BaseModel):
         m: Literal[EnumValue.b]
 
     class Top(BaseModel):
-        sub: Union[A, B] = Field(..., discriminator='m')
+        sub: 'A | B' = Field(..., discriminator='m')
 
     assert isinstance(Top.model_validate({'sub': {'m': EnumValue.b}}).sub, B)
     if isinstance(EnumValue.b, (int, str)):
@@ -449,7 +449,7 @@ class Dog(BaseModel):
     with pytest.raises(TypeError, match=re.escape("Aliases for discriminator 'pet_type' must be the same (got T, U)")):
 
         class Model(BaseModel):
-            pet: Union[Cat, Dog] = Field(discriminator='pet_type')
+            pet: 'Cat | Dog' = Field(discriminator='pet_type')
 
 
 def test_alias_same():
@@ -462,7 +462,7 @@ class Dog(BaseModel):
         d: str
 
     class Model(BaseModel):
-        pet: Union[Cat, Dog] = Field(discriminator='pet_type')
+        pet: 'Cat | Dog' = Field(discriminator='pet_type')
 
     assert Model(**{'pet': {'typeOfPet': 'dog', 'd': 'milou'}}).pet.pet_type == 'dog'
 
@@ -483,7 +483,7 @@ class Lizard(BaseModel):
         name: str
 
     class Model(BaseModel):
-        pet: Union[CommonPet, Lizard] = Field(..., discriminator='pet_type')
+        pet: 'CommonPet | Lizard' = Field(..., discriminator='pet_type')
         n: int
 
     assert isinstance(Model(**{'pet': {'pet_type': 'dog', 'name': 'Milou'}, 'n': 5}).pet, Dog)
@@ -501,7 +501,7 @@ class Failure(BaseModel):
         error_message: str
 
     class Container(BaseModel, Generic[T]):
-        result: Union[Success[T], Failure] = Field(discriminator='type')
+        result: 'Success[T] | Failure' = Field(discriminator='type')
 
     with pytest.raises(ValidationError, match="Unable to extract tag using discriminator 'type'"):
         Container[str].model_validate({'result': {}})
@@ -545,7 +545,7 @@ class Dog(BaseModel):
         name: str
 
     class Pet(BaseModel):
-        pet: Optional[Union[Cat, Dog]] = Field(discriminator='pet_type')
+        pet: 'Cat | Dog | None' = Field(discriminator='pet_type')
 
     assert Pet(pet={'pet_type': 'cat', 'name': 'Milo'}).model_dump() == {'pet': {'name': 'Milo', 'pet_type': 'cat'}}
     assert Pet(pet={'pet_type': 'dog', 'name': 'Otis'}).model_dump() == {'pet': {'name': 'Otis', 'pet_type': 'dog'}}
@@ -592,7 +592,7 @@ class Dog(BaseModel):
         name: str
 
     class Pet(BaseModel):
-        pet: Optional[Union[Cat, Dog]] = Field(default=None, discriminator='pet_type')
+        pet: 'Cat | Dog | None' = Field(default=None, discriminator='pet_type')
 
     assert Pet(pet={'pet_type': 'cat', 'name': 'Milo'}).model_dump() == {'pet': {'name': 'Milo', 'pet_type': 'cat'}}
     assert Pet(pet={'pet_type': 'dog', 'name': 'Otis'}).model_dump() == {'pet': {'name': 'Otis', 'pet_type': 'dog'}}
@@ -634,7 +634,7 @@ class Case2(BaseModel):
     with pytest.raises(PydanticUserError, match="Model 'Case1' needs a discriminator field for key 'kind'"):
 
         class TaggedParent(BaseModel):
-            tagged: Union[Case1, Case2] = Field(discriminator='kind')
+            tagged: 'Case1 | Case2' = Field(discriminator='kind')
 
 
 def test_nested_optional_unions() -> None:
@@ -651,7 +651,7 @@ class Lizard(BaseModel):
     MaybeDogLizard = Annotated[Union[Dog, Lizard, None], Field(discriminator='pet_type')]
 
     class Pet(BaseModel):
-        pet: Union[MaybeCatDog, MaybeDogLizard] = Field(discriminator='pet_type')
+        pet: 'MaybeCatDog | MaybeDogLizard' = Field(discriminator='pet_type')
 
     Pet.model_validate({'pet': {'pet_type': 'dog'}})
     Pet.model_validate({'pet': {'pet_type': 'cat'}})
@@ -740,7 +740,7 @@ class Lizard(BaseModel):
     MaybeDogLizard = Annotated[Optional[Union[Dog, Lizard]], 'some other annotation']
 
     class Model(BaseModel):
-        maybe_pet: Union[MaybeCat, MaybeDogLizard] = Field(discriminator='pet_type')
+        maybe_pet: 'MaybeCat | MaybeDogLizard' = Field(discriminator='pet_type')
 
     assert Model(**{'maybe_pet': None}).maybe_pet is None
     assert Model(**{'maybe_pet': {'typeOfPet': 'dog', 'd': 'milou'}}).maybe_pet.pet_type == 'dog'
diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py
index 500c911080f..373ddafd8c0 100644
--- a/tests/test_edge_cases.py
+++ b/tests/test_edge_cases.py
@@ -53,7 +53,7 @@
 
 def test_str_bytes():
     class Model(BaseModel):
-        v: Union[str, bytes]
+        v: 'str | bytes'
 
     m = Model(v='s')
     assert m.v == 's'
@@ -73,7 +73,7 @@ class Model(BaseModel):
 
 def test_str_bytes_none():
     class Model(BaseModel):
-        v: Union[None, str, bytes] = ...
+        v: 'None | str | bytes' = ...
 
     m = Model(v='s')
     assert m.v == 's'
@@ -497,7 +497,7 @@ class Model(BaseModel):
 
 def test_list_unions():
     class Model(BaseModel):
-        v: List[Union[int, str]] = ...
+        v: 'List[int | str]' = ...
 
     assert Model(v=[123, '456', 'foobar']).v == [123, '456', 'foobar']
 
@@ -512,7 +512,7 @@ class Model(BaseModel):
 
 def test_recursive_lists():
     class Model(BaseModel):
-        v: List[List[Union[int, float]]] = ...
+        v: 'List[List[int | float]]' = ...
 
     assert Model(v=[[1, 2], [3, '4', '4.1']]).v == [[1, 2], [3, 4, 4.1]]
     assert Model.model_fields['v'].annotation == List[List[Union[int, float]]]
@@ -1210,7 +1210,7 @@ class InvalidDefinitionModel(BaseModel):
 
 def test_multiple_errors():
     class Model(BaseModel):
-        a: Union[None, int, float, Decimal]
+        a: 'None | int | float | Decimal'
 
     with pytest.raises(ValidationError) as exc_info:
         Model(a='foobar')
@@ -1382,8 +1382,8 @@ class Model(BaseModel):
         e: Type[FooBar]
         f: Type[FooBar] = FooBar
         g: Sequence[Type[FooBar]] = [FooBar]
-        h: Union[Type[FooBar], Sequence[Type[FooBar]]] = FooBar
-        i: Union[Type[FooBar], Sequence[Type[FooBar]]] = [FooBar]
+        h: 'Type[FooBar] | Sequence[Type[FooBar]]' = FooBar
+        i: 'Type[FooBar] | Sequence[Type[FooBar]]' = [FooBar]
 
         model_config = dict(arbitrary_types_allowed=True)
 
@@ -2564,8 +2564,8 @@ class Model(BaseModel):
 
 def test_type_union():
     class Model(BaseModel):
-        a: Type[Union[str, bytes]]
-        b: Type[Union[Any, str]]
+        a: 'Type[str | bytes]'
+        b: 'Type[Any | str]'
 
     m = Model(a=bytes, b=int)
     assert m.model_dump() == {'a': bytes, 'b': int}
diff --git a/tests/test_forward_ref.py b/tests/test_forward_ref.py
index dff4d7c4f16..63592bd2cc2 100644
--- a/tests/test_forward_ref.py
+++ b/tests/test_forward_ref.py
@@ -711,7 +711,6 @@ class Foobar(BaseModel):
     assert f.y.model_fields_set == {'x'}
 
 
-@pytest.mark.skipif(sys.version_info < (3, 10), reason='needs 3.10 or newer')
 def test_recursive_models_union(create_module):
     module = create_module(
         # language=Python
diff --git a/tests/test_generics.py b/tests/test_generics.py
index 001e818b1a8..521cf5a19e8 100644
--- a/tests/test_generics.py
+++ b/tests/test_generics.py
@@ -595,7 +595,7 @@ def test_complex_nesting():
     T = TypeVar('T')
 
     class MyModel(BaseModel, Generic[T]):
-        item: List[Dict[Union[int, T], str]]
+        item: 'List[Dict[int | T, str]]'
 
     item = [{1: 'a', 'a': 'a'}]
     model = MyModel[str](item=item)
@@ -1869,7 +1869,7 @@ class M1(BaseModel, Generic[V1, V2]):
             m: 'M2[V1]'
 
         class M2(BaseModel, Generic[V3]):
-            m: Union[M1[V3, int], V3]
+            m: 'M1[V3, int] | V3'
 
         M1.model_rebuild()
 
diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py
index 675b52a4a41..741f6eaad0a 100644
--- a/tests/test_json_schema.py
+++ b/tests/test_json_schema.py
@@ -1278,7 +1278,7 @@ class ModelWithOverride(BaseModel):
 )
 def test_callable_type_with_fallback(default_value, properties):
     class Model(BaseModel):
-        callback: Union[int, Callable[[int], int]] = default_value
+        callback: 'int | Callable[[int], int]' = default_value
 
     class MyGenerator(GenerateJsonSchema):
         ignored_warning_kinds = ()
@@ -1331,7 +1331,7 @@ class Model(BaseModel):
 )
 def test_callable_fallback_with_non_serializable_default(warning_match):
     class Model(BaseModel):
-        callback: Union[int, Callable[[int], int]] = lambda x: x  # noqa E731
+        callback: 'int | Callable[[int], int]' = lambda x: x  # noqa E731
 
     class MyGenerator(GenerateJsonSchema):
         ignored_warning_kinds = ()
diff --git a/tests/test_main.py b/tests/test_main.py
index 9f775fc65a7..6506092a4cc 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,7 +1,6 @@
 import json
 import platform
 import re
-import sys
 from collections import defaultdict
 from copy import deepcopy
 from dataclasses import dataclass
@@ -1886,22 +1885,21 @@ class Model(BaseModel, some_config='new_value'):
             a: int
 
 
-@pytest.mark.skipif(sys.version_info < (3, 10), reason='need 3.10 version')
 def test_new_union_origin():
     """On 3.10+, origin of `int | str` is `types.UnionType`, not `typing.Union`"""
 
     class Model(BaseModel):
-        x: int | str
+        x: 'int | str'
 
     assert Model(x=3).x == 3
     assert Model(x='3').x == '3'
     assert Model(x='pika').x == 'pika'
-    # assert Model.model_json_schema() == {
-    #     'title': 'Model',
-    #     'type': 'object',
-    #     'properties': {'x': {'title': 'X', 'anyOf': [{'type': 'integer'}, {'type': 'string'}]}},
-    #     'required': ['x'],
-    # }
+    assert Model.model_json_schema() == {
+        'title': 'Model',
+        'type': 'object',
+        'properties': {'x': {'title': 'X', 'anyOf': [{'type': 'integer'}, {'type': 'string'}]}},
+        'required': ['x'],
+    }
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_model_validator.py b/tests/test_model_validator.py
index e34d18c6933..4d152051d09 100644
--- a/tests/test_model_validator.py
+++ b/tests/test_model_validator.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import Any, Dict, Union, cast
+from typing import Any, Dict, cast
 
 import pytest
 
@@ -116,7 +116,7 @@ def test_nested_models() -> None:
     calls: list[str] = []
 
     class Model(BaseModel):
-        inner: Union[Model, None]  # noqa
+        inner: Model | None
 
         @model_validator(mode='before')
         @classmethod
diff --git a/tests/test_root_model.py b/tests/test_root_model.py
index b3916ca31bc..bdd0926e76f 100644
--- a/tests/test_root_model.py
+++ b/tests/test_root_model.py
@@ -474,12 +474,12 @@ class RModel(RootModel):
     if order == 'BR':
 
         class Model(RootModel):
-            root: List[Union[BModel, RModel]]
+            root: 'List[BModel | RModel]'
 
     elif order == 'RB':
 
         class Model(RootModel):
-            root: List[Union[RModel, BModel]]
+            root: 'List[RModel | BModel]'
 
     m = Model([1, 2, {'value': 'abc'}])
 
@@ -508,7 +508,7 @@ class SModel(BaseModel):
         str_value: str
 
     class Model(RootModel):
-        root: Union[SModel, RModel] = Field(discriminator='kind')
+        root: 'SModel | RModel' = Field(discriminator='kind')
 
     assert Model(data).model_dump() == data
     assert Model(**data).model_dump() == data
diff --git a/tests/test_types.py b/tests/test_types.py
index 19745f1f90a..42304802723 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -4824,7 +4824,7 @@ class Model(BaseModel):
 
 def test_default_union_types():
     class DefaultModel(BaseModel):
-        v: Union[int, bool, str]
+        v: 'int | bool | str'
 
     # do it this way since `1 == True`
     assert repr(DefaultModel(v=True).v) == 'True'
@@ -4905,7 +4905,7 @@ class B(BaseModel):
         x: str
 
     class Model(BaseModel):
-        y: Union[A, B]
+        y: 'A | B'
 
     assert isinstance(Model(y=A(x='a')).y, A)
     assert isinstance(Model(y=B(x='b')).y, B)
@@ -4917,7 +4917,7 @@ class MyStr(str):
         ...
 
     class Model(BaseModel):
-        x: Union[int, Annotated[str, Field(max_length=max_length)]]
+        x: 'int | Annotated[str, Field(max_length=max_length)]'
 
     v = Model(x=MyStr('1')).x
     assert type(v) is str
@@ -4926,7 +4926,7 @@ class Model(BaseModel):
 
 def test_union_compound_types():
     class Model(BaseModel):
-        values: Union[Dict[str, str], List[str], Dict[str, List[str]]]
+        values: 'Dict[str, str] | List[str] | Dict[str, List[str]]'
 
     assert Model(values={'L': '1'}).model_dump() == {'values': {'L': '1'}}
     assert Model(values=['L1']).model_dump() == {'values': ['L1']}
@@ -4960,7 +4960,7 @@ class Model(BaseModel):
 
 def test_smart_union_compounded_types_edge_case():
     class Model(BaseModel):
-        x: Union[List[str], List[int]]
+        x: 'List[str] | List[int]'
 
     assert Model(x=[1, 2]).x == [1, 2]
     assert Model(x=['1', '2']).x == ['1', '2']
@@ -4975,7 +4975,7 @@ class Dict2(TypedDict):
         bar: str
 
     class M(BaseModel):
-        d: Union[Dict2, Dict1]
+        d: 'Dict2 | Dict1'
 
     assert M(d=dict(foo='baz')).d == {'foo': 'baz'}
 
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 9f86e6d38e3..b6be6157d76 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -1,3 +1,4 @@
+import sys
 import typing
 from collections import namedtuple
 from typing import Callable, ClassVar, ForwardRef, NamedTuple
@@ -8,6 +9,7 @@
 from pydantic import Field  # noqa: F401
 from pydantic._internal._typing_extra import (
     NoneType,
+    eval_type_backport,
     get_function_type_hints,
     is_classvar,
     is_literal_type,
@@ -64,15 +66,17 @@ def test_is_none_type():
     assert is_none_type(Callable) is False
 
 
-@pytest.mark.parametrize('union_gen', [lambda: typing.Union[int, str], lambda: int | str])
-def test_is_union(union_gen):
-    try:
-        union = union_gen()
-    except TypeError:
-        pytest.skip('not supported in this python version')
-    else:
-        origin = get_origin(union)
-        assert origin_is_union(origin)
+@pytest.mark.parametrize(
+    'union',
+    [
+        typing.Union[int, str],
+        eval_type_backport(typing.ForwardRef('int | str')),
+        *([int | str] if sys.version_info >= (3, 10) else []),
+    ],
+)
+def test_is_union(union):
+    origin = get_origin(union)
+    assert origin_is_union(origin)
 
 
 def test_is_literal_with_typing_extension_literal():
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 96c7941d5db..f38891c376f 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -7,7 +7,7 @@
 from enum import Enum
 from functools import partial, partialmethod
 from itertools import product
-from typing import Any, Callable, Deque, Dict, FrozenSet, List, NamedTuple, Optional, Tuple, Union
+from typing import Any, Callable, Deque, Dict, FrozenSet, List, NamedTuple, Optional, Tuple
 from unittest.mock import MagicMock
 
 import pytest
@@ -1670,7 +1670,7 @@ class Model(BaseModel):
 
 def test_union_literal_with_constraints():
     class Model(BaseModel, validate_assignment=True):
-        x: Union[Literal[42], Literal['pika']] = Field(frozen=True)
+        x: "Literal[42] | Literal['pika']" = Field(frozen=True)
 
     m = Model(x=42)
     with pytest.raises(ValidationError) as exc_info:


Code review comments:
diff_hunk: @@ -221,12 +221,36 @@ def eval_type_lenient(value: Any, globalns: dict[str, Any] | None, localns: dict
         value = _make_forward_ref(value, is_argument=False, is_class=True)
 
     try:
-        return typing._eval_type(value, globalns, localns)  # type: ignore
+        return eval_type_backport(value, globalns, localns)
     except NameError:
         # the point of this function is to be tolerant to this case
         return value
 
 
+def is_unsupported_types_for_union_error(e: TypeError) -> bool:
+    return str(e).startswith('unsupported operand type(s) for |: ')
+
+
+def eval_type_backport(
+    value: Any, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
+) -> Any:
+    """Like `typing._eval_type`, but falls back to the `eval_type_backport` package if it's
+    installed to let older Python versions use newer typing features.
+    Currently this just means that `X | Y` is converted to `Union[X, Y]` if `X | Y` is not supported.
+    This would also be the place to add support for `list[int]` instead of `List[int]` etc.
+    """
+    try:
+        return typing._eval_type(  # type: ignore
+            value, globalns, localns
+        )
+    except TypeError as e:
+        if not (isinstance(value, typing.ForwardRef) and is_unsupported_types_for_union_error(e)):
+            raise
+        from eval_type_backport import eval_type_backport
path: pydantic/_internal/_typing_extra.py
text: @user1:
It seems to me that right now if you don't have the `eval_type_backport` package installed, you'll get a `ModuleNotFoundError` if you hit this code branch. I think that's more confusing than it needs to be; I think it would be better if you could add a `try: except ImportError:` to provide a nicer error message explaining to the user what they've done wrong (use the `|` notation for unions in older python version) and how to resolve it (use `typing.Union` or install the `eval_type_backport` package).

@author:
Right, this was my plan.

> This PR isn't fully ready (it just assumes that the package is installed) but I want to check that this is going in the right direction.

diff_hunk: @@ -221,12 +221,36 @@ def eval_type_lenient(value: Any, globalns: dict[str, Any] | None, localns: dict
         value = _make_forward_ref(value, is_argument=False, is_class=True)
 
     try:
-        return typing._eval_type(value, globalns, localns)  # type: ignore
+        return eval_type_backport(value, globalns, localns)
     except NameError:
         # the point of this function is to be tolerant to this case
         return value
 
 
+def is_unsupported_types_for_union_error(e: TypeError) -> bool:
+    return str(e).startswith('unsupported operand type(s) for |: ')
+
+
+def eval_type_backport(
+    value: Any, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None
+) -> Any:
+    """Like `typing._eval_type`, but falls back to the `eval_type_backport` package if it's
+    installed to let older Python versions use newer typing features.
+    Currently this just means that `X | Y` is converted to `Union[X, Y]` if `X | Y` is not supported.
+    This would also be the place to add support for `list[int]` instead of `List[int]` etc.
path: pydantic/_internal/_typing_extra.py
text: @user1:
I think it makes sense to support at least the most well known cases of `type`, `list`, `dict`, `set` when we release this.

Presumably there's a set list somewhere and implementing more things is a simple as a lookup dict?

I think supporting pipe unions but nothing else is confusing.

diff_hunk: @@ -221,12 +221,36 @@ def eval_type_lenient(value: Any, globalns: dict[str, Any] | None, localns: dict
         value = _make_forward_ref(value, is_argument=False, is_class=True)
 
     try:
-        return typing._eval_type(value, globalns, localns)  # type: ignore
+        return eval_type_backport(value, globalns, localns)
     except NameError:
         # the point of this function is to be tolerant to this case
         return value
 
 
+def is_unsupported_types_for_union_error(e: TypeError) -> bool:
path: pydantic/_internal/_typing_extra.py
text: @user1:
let's move this below the function that uses it.

diff_hunk: @@ -20,57 +20,6 @@ include = [
     '/requirements',
 ]
 
-[project]
path: pyproject.toml
text: @user1:
Does this whole section need to be moved? It makes it harder to confirm the small diff, and will affect the git blame for this file for future reference.

@author:
No, PDM did this automatically and I didn't want to interfere because until now I thought this change was in `pdm.lock` :laughing: 

@user2:
let's revert all these changes.