Skip to content

fix(deps): update dependency fastapi to >=0.162.0,<0.163.0 - #360

Merged
onyb merged 1 commit into
masterfrom
renovate/fastapi-0-x
Nov 3, 2026
Merged

onyb merged 1 commit into
masterfrom
renovate/fastapi-0-x

Conversation

@renovate

@renovate renovate Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
fastapi (changelog) >=0.156.4,<0.157.0 → >=0.162.0,<0.163.0 age confidence

Release Notes

fastapi/fastapi (fastapi)

v0.162.0

Compare Source

Omitted bool and bool | None form fields now use their declared defaults or produce missing-field errors, consistent with other fields and Pydantic behavior. FastAPI no longer infers False for omitted booleans. This applies to individual form parameters, form models, and unions of form models.

Omitted boolean model fields are excluded from model_dump(exclude_unset=True), and their defaults no longer count as submitted fields during union selection. Default factories and validate_default follow the usual rules.

For HTML checkboxes, declare a boolean with a default of False. See Boolean Form Fields.

Breaking Changes

v0.161.1

Compare Source

Fixes

v0.161.0

Compare Source

Features

v0.160.0

Compare Source

Query, header, cookie, and form parameter models now apply their extra configuration to all submitted fields in the same parameter location, consistent with Pydantic behavior. This includes fields declared in other models, individual parameters, or dependencies, which were previously excluded.

With extra="forbid", fields outside the model are rejected even if another parameter declares them. With extra="allow", those fields are included as model extras. With the default extra="ignore", they are ignored.

Breaking Changes
Internal

v0.159.1

Compare Source

Features

v0.159.0

Compare Source

Features

v0.158.2

Compare Source

Features

v0.158.1

Compare Source

Fixes

v0.158.0

Compare Source

Features

v0.157.0

Compare Source

Features
  • ✨ Add support for combining query, header, and cookie parameter models. PR #​16573 by @​tiangolo.
Docs
Internal

v0.156.8

Compare Source

Fixes

v0.156.7

Compare Source

Fixes

v0.156.6

Compare Source

Fixes
  • 🐛 Respect model alias settings for query, header, and cookie parameters. PR #​16562 by @​tiangolo.

v0.156.5

Compare Source

Fixes

Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

Copilot AI balanced review requested due to automatic review settings October 29, 2026 22:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues are reported for the FastAPI dependency update.

No open issues 🎉

What changed in this PR

Updates the API service’s FastAPI dependency to the 0.161 release series, keeping the dependency constraint and Poetry lockfile aligned.

Changes:

  • Changes the FastAPI constraint to >=0.161.0,<0.162.0.
  • Locks FastAPI to 0.161.1 and refreshes package hashes and lockfile metadata.
File Description
pyproject.toml Updates the allowed FastAPI version range.
poetry.lock Records FastAPI 0.161.1 and updates artifact and content hashes.

Review effort: Balanced

@renovate
renovate Bot force-pushed the renovate/fastapi-0-x branch from c045e77 to dc09ce8 Compare October 30, 2026 01:41
@renovate renovate Bot changed the title fix(deps): update dependency fastapi to >=0.161.0,<0.162.0 fix(deps): update dependency fastapi to >=0.161.1,<0.162.0 Oct 30, 2026
@renovate
renovate Bot force-pushed the renovate/fastapi-0-x branch from dc09ce8 to 04cf69a Compare October 30, 2026 21:49
@renovate renovate Bot changed the title fix(deps): update dependency fastapi to >=0.161.1,<0.162.0 fix(deps): update dependency fastapi to >=0.162.0,<0.163.0 Oct 30, 2026
@github-actions

Copy link
Copy Markdown

[puLL-Merge] - fastapi/fastapi@0.156.4..0.157.0

Diff
diff --git .github/workflows/label-approved.yml .github/workflows/label-approved.yml
deleted file mode 100644
index 1d5d3d6b75c58..0000000000000
--- .github/workflows/label-approved.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-name: Label Approved
-
-on:
-  schedule:
-    - cron: "0 12 * * *"
-  workflow_dispatch:
-
-permissions: {}
-
-jobs:
-  label-approved:
-    if: github.repository_owner == 'fastapi'
-    runs-on: ubuntu-latest
-    permissions:
-      pull-requests: write
-    timeout-minutes: 7
-    steps:
-    - name: Dump GitHub context
-      env:
-        GITHUB_CONTEXT: ${{ toJson(github) }}
-      run: echo "$GITHUB_CONTEXT"
-    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-      with:
-        persist-credentials: false
-    - name: Set up Python
-      uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
-      with:
-        python-version-file: ".python-version"
-    - name: Setup uv
-      uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
-      with:
-        version: "latest-known"
-        enable-cache: true
-        cache-dependency-glob: |
-            pyproject.toml
-            uv.lock
-    - name: Install GitHub Actions dependencies
-      run: uv sync --locked --no-dev --group github-actions
-    - name: Label Approved
-      run: uv run ./scripts/label_approved.py
-      env:
-        TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        CONFIG: >
-          {
-            "approved-1":
-              {
-                "number": 1,
-                "await_label": "awaiting-review"
-              }
-          }
diff --git .github/workflows/notify-translations.yml .github/workflows/notify-translations.yml
deleted file mode 100644
index fc29384bd5db9..0000000000000
--- .github/workflows/notify-translations.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: Notify Translations
-
-on:
-  pull_request_target: # zizmor: ignore[dangerous-triggers]
-    types:
-      - labeled
-      - closed
-    branches:
-      - master
-  workflow_dispatch:
-    inputs:
-      number:
-        description: PR number
-        required: true
-      debug_enabled:
-        description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)'
-        required: false
-        default: 'false'
-
-permissions: {}
-
-jobs:
-  job:
-    runs-on: ubuntu-latest
-    permissions:
-      discussions: write
-    timeout-minutes: 5
-    steps:
-      - name: Dump GitHub context
-        env:
-          GITHUB_CONTEXT: ${{ toJson(github) }}
-        run: echo "$GITHUB_CONTEXT"
-      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-        with:
-          ref: ${{ github.event.repository.default_branch }}
-          persist-credentials: false
-      - name: Set up Python
-        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
-        with:
-          python-version-file: ".python-version"
-      - name: Setup uv
-        uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
-        with:
-          version: "latest-known"
-          enable-cache: true
-          cache-dependency-glob: |
-            pyproject.toml
-            uv.lock
-      - name: Install Dependencies
-        run: uv sync --locked --no-dev --group github-actions
-      # Allow debugging with tmate
-      - name: Setup tmate session
-        uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24
-        if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
-        with:
-          limit-access-to-actor: true
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-      - name: Notify Translations
-        run: uv run ./scripts/notify_translations.py
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          NUMBER: ${{ github.event.inputs.number || null }}
-          DEBUG: ${{ github.event.inputs.debug_enabled || 'false' }}
diff --git a/docs_src/query_param_models/tutorial003_an_py310.py b/docs_src/query_param_models/tutorial003_an_py310.py
new file mode 100644
index 0000000000000..fe0ddca3a0ab9
--- /dev/null
+++ docs_src/query_param_models/tutorial003_an_py310.py
@@ -0,0 +1,24 @@
+from typing import Annotated
+
+from fastapi import FastAPI, Query
+from pydantic import BaseModel, Field
+
+app = FastAPI()
+
+
+class Pagination(BaseModel):
+    limit: int = Field(10, ge=1)
+    offset: int = Field(0, ge=0)
+
+
+class Filters(BaseModel):
+    tags: list[str] = []
+
+
+@app.get("/items/")
+def read_items(
+    pagination: Annotated[Pagination, Query()],
+    filters: Annotated[Filters, Query()],
+    search: str | None = None,
+):
+    return {"pagination": pagination, "filters": filters, "search": search}
diff --git a/docs_src/query_param_models/tutorial003_py310.py b/docs_src/query_param_models/tutorial003_py310.py
new file mode 100644
index 0000000000000..8d3d431b737ec
--- /dev/null
+++ docs_src/query_param_models/tutorial003_py310.py
@@ -0,0 +1,22 @@
+from fastapi import FastAPI, Query
+from pydantic import BaseModel, Field
+
+app = FastAPI()
+
+
+class Pagination(BaseModel):
+    limit: int = Field(10, ge=1)
+    offset: int = Field(0, ge=0)
+
+
+class Filters(BaseModel):
+    tags: list[str] = []
+
+
+@app.get("/items/")
+def read_items(
+    pagination: Pagination = Query(),
+    filters: Filters = Query(),
+    search: str | None = None,
+):
+    return {"pagination": pagination, "filters": filters, "search": search}
diff --git fastapi/__init__.py fastapi/__init__.py
index 72e9ab9f9ebfc..7efc66c442d4d 100644
--- fastapi/__init__.py
+++ fastapi/__init__.py
@@ -1,6 +1,6 @@
 """FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
 
-__version__ = "0.156.4"
+__version__ = "0.157.0"
 
 from starlette import status as status
 
diff --git fastapi/dependencies/models.py fastapi/dependencies/models.py
index 5c7cbc82ba12e..047f49d7ac288 100644
--- fastapi/dependencies/models.py
+++ fastapi/dependencies/models.py
@@ -49,6 +49,7 @@ class Dependant:
     use_cache: bool = True
     path: str | None = None
     scope: Literal["function", "request"] | None = None
+    _request_param_names: dict[str, set[str]] | None = None
 
 
 _UsesScopesCache = dict[int, tuple[Dependant, bool]]
diff --git fastapi/dependencies/utils.py fastapi/dependencies/utils.py
index 1de8a24bf8095..5543231cfa8f2 100644
--- fastapi/dependencies/utils.py
+++ fastapi/dependencies/utils.py
@@ -155,16 +155,58 @@ def _get_flat_body_params(dependant: Dependant) -> list[ModelField]:
     return body_params
 
 
-def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
-    if not fields:
-        return fields
-    first_field = fields[0]
-    if len(fields) == 1 and lenient_issubclass(
-        first_field.field_info.annotation, BaseModel
-    ):
-        fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
-        return fields_to_extract
-    return fields
+def _get_flat_fields_from_params(fields: Sequence[ModelField]) -> list[ModelField]:
+    flat_fields: list[ModelField] = []
+    for field in fields:
+        if lenient_issubclass(
+            field.field_info.annotation, BaseModel
+        ) and not _is_json_field(field):
+            flat_fields.extend(get_cached_model_fields(field.field_info.annotation))
+        else:
+            flat_fields.append(field)
+    return flat_fields
+
+
+def _get_request_param_names(
+    dependant: Dependant, dependency_overrides_provider: Any | None = None
+) -> dict[str, set[str]]:
+    """Collect accepted names across the effective dependency graph, by location."""
+    names: dict[str, set[str]] = {"query": set(), "header": set(), "cookie": set()}
+    overrides = (
+        getattr(dependency_overrides_provider, "dependency_overrides", None) or {}
+    )
+    dependants = [dependant]
+    while dependants:
+        current = dependants.pop()
+        for location, fields in (
+            ("query", current.query_params),
+            ("header", current.header_params),
+            ("cookie", current.cookie_params),
+        ):
+            for field in fields:
+                for param in _get_flat_fields_from_params([field]):
+                    convert_underscores = location == "header" and getattr(
+                        param.field_info,
+                        "convert_underscores",
+                        getattr(field.field_info, "convert_underscores", True),
+                    )
+                    for _, name in _get_param_aliases(
+                        param, convert_underscores=convert_underscores
+                    ):
+                        names[location].add(
+                            name.lower() if location == "header" else name
+                        )
+        for sub_dependant in current.dependencies:
+            if sub_dependant.call in overrides:
+                sub_dependant = get_dependant(
+                    path=cast(str, sub_dependant.path),
+                    call=overrides[sub_dependant.call],
+                    name=sub_dependant.name,
+                    parent_oauth_scopes=_get_oauth_scopes(dependant=sub_dependant),
+                    scope=sub_dependant.scope,
+                )
+            dependants.append(sub_dependant)
+    return names
 
 
 def get_flat_params(dependant: Dependant) -> list[ModelField]:
@@ -293,12 +335,11 @@ def get_dependant(
     endpoint_signature = get_typed_signature(call)
     signature_params = endpoint_signature.parameters
     for param_name, param in signature_params.items():
-        is_path_param = param_name in path_param_names
         param_details = analyze_param(
             param_name=param_name,
             annotation=param.annotation,
             value=param.default,
-            is_path_param=is_path_param,
+            path_param_names=path_param_names,
         )
         if param_details.depends is not None:
             assert param_details.depends.dependency
@@ -384,8 +425,9 @@ def analyze_param(
     param_name: str,
     annotation: Any,
     value: Any,
-    is_path_param: bool,
+    path_param_names: set[str],
 ) -> ParamDetails:
+    is_path_param = param_name in path_param_names
     field_info = None
     depends = None
     type_annotation: Any = Any
@@ -537,6 +579,19 @@ def analyze_param(
                 field_info.annotation = _add_form_union_validators(
                     field_info.annotation
                 )
+        validation_alias = field_info.validation_alias
+        if (
+            isinstance(field_info, params.Path)
+            and isinstance(validation_alias, AliasChoices)
+            and all(isinstance(choice, str) for choice in validation_alias.choices)
+        ):
+            for choice in cast(list[str], validation_alias.choices):
+                if choice in path_param_names:
+                    # Resolve the route name for both validation and OpenAPI without
+                    # changing metadata shared with other routes.
+                    field_info = copy(field_info)
+                    field_info.validation_alias = choice
+                    break
         if not field_info.alias and getattr(field_info, "convert_underscores", None):
             alias = param_name.replace("_", "-")
         else:
@@ -613,6 +668,7 @@ async def solve_dependencies(
     async_exit_stack: AsyncExitStack,
     embed_body_fields: bool,
     _uses_scopes_cache: _UsesScopesCache | None = None,
+    _request_param_names: dict[str, set[str]] | None = None,
 ) -> SolvedDependency:
     request_astack = request.scope.get("fastapi_inner_astack")
     assert isinstance(request_astack, AsyncExitStack), (
@@ -632,6 +688,15 @@ async def solve_dependencies(
         dependency_cache = {}
     if _uses_scopes_cache is None:
         _uses_scopes_cache = {}
+    if _request_param_names is None:
+        _request_param_names = dependant._request_param_names
+        if _request_param_names is None or (
+            dependency_overrides_provider
+            and dependency_overrides_provider.dependency_overrides
+        ):
+            _request_param_names = _get_request_param_names(
+                dependant, dependency_overrides_provider
+            )
     for sub_dependant in dependant.dependencies:
         sub_dependant.call = cast(Callable[..., Any], sub_dependant.call)
         call = sub_dependant.call
@@ -664,6 +729,7 @@ async def solve_dependencies(
             async_exit_stack=async_exit_stack,
             embed_body_fields=embed_body_fields,
             _uses_scopes_cache=_uses_scopes_cache,
+            _request_param_names=_request_param_names,
         )
         background_tasks = solved_result.background_tasks
         if solved_result.errors:
@@ -698,13 +764,19 @@ async def solve_dependencies(
         dependant.path_params, request.path_params
     )
     query_values, query_errors = request_params_to_args(
-        dependant.query_params, request.query_params
+        dependant.query_params,
+        request.query_params,
+        known_param_names=_request_param_names["query"],
     )
     header_values, header_errors = request_params_to_args(
-        dependant.header_params, request.headers
+        dependant.header_params,
+        request.headers,
+        known_param_names=_request_param_names["header"],
     )
     cookie_values, cookie_errors = request_params_to_args(
-        dependant.cookie_params, request.cookies
+        dependant.cookie_params,
+        request.cookies,
+        known_param_names=_request_param_names["cookie"],
     )
     values.update(path_values)
     values.update(query_values)
@@ -799,41 +871,31 @@ def _get_multidict_value(
     return value
 
 
-def request_params_to_args(
-    fields: Sequence[ModelField],
-    received_params: Mapping[str, Any] | QueryParams | Headers,
-) -> tuple[dict[str, Any], list[Any]]:
-    values: dict[str, Any] = {}
-    errors: list[dict[str, Any]] = []
-
-    if not fields:
-        return values, errors
-
-    first_field = fields[0]
-    fields_to_extract = fields
-    single_not_embedded_field = False
-    default_convert_underscores = True
-    if len(fields) == 1 and lenient_issubclass(
-        first_field.field_info.annotation, BaseModel
-    ):
-        fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
-        single_not_embedded_field = True
-        # If headers are in a Pydantic model, the way to disable convert_underscores
-        # would be with Header(convert_underscores=False) at the Pydantic model level
-        default_convert_underscores = getattr(
-            first_field.field_info, "convert_underscores", True
-        )
-
+def _request_param_model_to_args(
+    model_field: ModelField,
+    received_params: Mapping[str, Any],
+    known_param_names: set[str],
+) -> tuple[Any, list[Any]]:
+    model = cast(type[BaseModel], model_field.field_info.annotation)
+    fields_to_extract = get_cached_model_fields(model)
+    default_convert_underscores = getattr(
+        model_field.field_info, "convert_underscores", True
+    )
     params_to_process: dict[str, Any] = {}
     header_aliases: dict[str, str] = {}
 
     processed_keys = set()
+    own_names: set[str] = set()
 
     for field in fields_to_extract:
         convert_underscores = isinstance(received_params, Headers) and getattr(
             field.field_info, "convert_underscores", default_convert_underscores
         )
         aliases = _get_param_aliases(field, convert_underscores=convert_underscores)
+        own_names.update(
+            name.lower() if isinstance(received_params, Headers) else name
+            for _, name in aliases
+        )
         alias, request_alias = next(
             (pair for pair in aliases if pair[1] in received_params), aliases[0]
         )
@@ -853,8 +915,12 @@ def request_params_to_args(
         else:
             processed_keys.add(request_alias)
 
+    # Parameters declared elsewhere are not extras of this model. Keep unknown
+    # inputs and unused aliases of its own fields for Pydantic to validate.
     for key in received_params.keys():
-        if key not in processed_keys:
+        if key not in processed_keys and (
+            key not in known_param_names or key in own_names
+        ):
             if isinstance(received_params, (ImmutableMultiDict, Headers)):
                 value = received_params.getlist(key)
                 if isinstance(value, list) and (len(value) == 1):
@@ -864,43 +930,58 @@ def request_params_to_args(
             else:
                 params_to_process[key] = received_params.get(key)
 
-    if single_not_embedded_field:
-        field_info = first_field.field_info
-        assert isinstance(field_info, params.Param), (
-            "Params must be subclasses of Param"
-        )
-        loc: tuple[str, ...] = (field_info.in_.value,)
-        v_, errors_ = _validate_value_with_model_field(
-            field=first_field, value=params_to_process, values=values, loc=loc
-        )
-        if header_aliases and cast(
-            type[BaseModel], first_field.field_info.annotation
-        ).model_config.get("loc_by_alias", True):
-            # Report the header names without changing model aliases or schema.
-            for error in errors_:
-                error_loc = error["loc"]
-                if len(error_loc) > 1 and error_loc[1] in header_aliases:
-                    error["loc"] = (
-                        error_loc[0],
-                        header_aliases[error_loc[1]],
-                        *error_loc[2:],
-                    )
-        return {first_field.name: v_}, errors_
+    field_info = model_field.field_info
+    assert isinstance(field_info, params.Param), "Params must be subclasses of Param"
+    loc: tuple[str, ...] = (field_info.in_.value,)
+    v_, errors_ = _validate_value_with_model_field(
+        field=model_field, value=params_to_process, values={}, loc=loc
+    )
+    if header_aliases and model.model_config.get("loc_by_alias", True):
+        # Report the header names without changing model aliases or schema.
+        for error in errors_:
+            error_loc = error["loc"]
+            if len(error_loc) > 1 and error_loc[1] in header_aliases:
+                error["loc"] = (
+                    error_loc[0],
+                    header_aliases[error_loc[1]],
+                    *error_loc[2:],
+                )
+    return v_, errors_
+
 
+def request_params_to_args(
+    fields: Sequence[ModelField],
+    received_params: Mapping[str, Any] | QueryParams | Headers,
+    *,
+    known_param_names: set[str] | None = None,
+) -> tuple[dict[str, Any], list[Any]]:
+    values: dict[str, Any] = {}
+    errors: list[Any] = []
+    if not fields:
+        return values, errors
+    if known_param_names is None:
+        known_param_names = set()
     for field in fields:
-        aliases = _get_param_aliases(field)
-        _, alias = next(
-            (pair for pair in aliases if pair[1] in received_params), aliases[0]
-        )
-        value = _get_multidict_value(field, received_params, alias=alias)
-        field_info = field.field_info
-        assert isinstance(field_info, params.Param), (
-            "Params must be subclasses of Param"
-        )
-        loc = (field_info.in_.value, alias)
-        v_, errors_ = _validate_value_with_model_field(
-            field=field, value=value, values=values, loc=loc
-        )
+        if lenient_issubclass(
+            field.field_info.annotation, BaseModel
+        ) and not _is_json_field(field):
+            v_, errors_ = _request_param_model_to_args(
+                field, received_params, known_param_names
+            )
+        else:
+            aliases = _get_param_aliases(field)
+            _, alias = next(
+                (pair for pair in aliases if pair[1] in received_params), aliases[0]
+            )
+            value = _get_multidict_value(field, received_params, alias=alias)
+            field_info = field.field_info
+            assert isinstance(field_info, params.Param), (
+                "Params must be subclasses of Param"
+            )
+            loc = (field_info.in_.value, alias)
+            v_, errors_ = _validate_value_with_model_field(
+                field=field, value=value, values=values, loc=loc
+            )
         if errors_:
             errors.extend(errors_)
         else:
@@ -1032,10 +1113,6 @@ def _extract_form_items(
                 value: Any = False
             else:
                 value = _get_multidict_value(field, received_body, alias=alias)
-            if field.config is None:
-                # Individual parameters are later looked up by their declared alias.
-                alias = get_validation_alias(field)
-                field_aliases.add(alias)
             if value is not Undefined:
                 yield alias, value, field
                 # Leave later aliases unchanged for other fields or extra validation.
@@ -1049,17 +1126,25 @@ def _extract_form_items(
                 yield key, param_values, None
 
 
+async def _read_file_bytes(file: UploadFile) -> bytes:
+    # Automatic conversion must not consume a file shared with other parameters.
+    position = file.file.tell()
+    try:
+        return await file.read()
+    finally:
+        await file.seek(position)
+
+
 async def _extract_form_body(
     body_fields: list[ModelField],
     received_body: FormData,
-) -> dict[str, Any]:
-    values: dict[str, Any] = {}
+) -> AsyncIterator[tuple[str, Any, ModelField | None]]:
     for alias, value, field in _extract_form_items(body_fields, received_body):
         if field is not None and isinstance(field.field_info, params.File):
             if is_bytes_or_nonable_bytes_annotation(
                 field.field_info.annotation
             ) and isinstance(value, UploadFile):
-                value = await value.read()
+                value = await _read_file_bytes(value)
             elif is_bytes_sequence_annotation(
                 field.field_info.annotation
             ) and value_is_sequence(value):
@@ -1067,12 +1152,11 @@ async def _extract_form_body(
                 results: list[bytes | str] = []
                 for sub_value in value:
                     if isinstance(sub_value, UploadFile):
-                        results.append(await sub_value.read())
+                        results.append(await _read_file_bytes(sub_value))
                     else:
                         results.append(sub_value)
                 value = serialize_sequence_value(field=field, value=results)
-        values[alias] = value
-    return values
+        yield alias, value, field
 
 
 async def request_body_to_args(
@@ -1086,6 +1170,7 @@ async def request_body_to_args(
     single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields
     first_field = body_fields[0]
     body_to_process = received_body
+    form_values: dict[str, tuple[str, Any]] = {}
 
     fields_to_extract: list[ModelField] = body_fields
 
@@ -1103,7 +1188,15 @@ async def request_body_to_args(
             # Preserve all form values for validators around the union.
             # Each branch applies its own model field extraction afterward.
             fields_to_extract = []
-        body_to_process = await _extract_form_body(fields_to_extract, received_body)
+        body_to_process = {}
+        async for alias, value, field in _extract_form_body(
+            fields_to_extract, received_body
+        ):
+            if single_not_embedded_field:
+                body_to_process[alias] = value
+            elif field is not None:
+                # Keep each parameter's selected alias and scalar/list shape.
+                form_values[field.name] = (alias, value)
 
     if single_not_embedded_field:
         loc: tuple[str, ...] = ("body",)
@@ -1112,14 +1205,15 @@ async def request_body_to_args(
         )
         return {first_field.name: v_}, errors_
     for field in body_fields:
-        if isinstance(received_body, FormData):
-            # Form values have already been extracted using their canonical aliases.
-            aliases = [get_validation_alias(field)]
-        else:
-            aliases = [alias for alias, _ in _get_param_aliases(field)]
+        aliases = [alias for alias, _ in _get_param_aliases(field)]
         loc = ("body", aliases[0])
-        value: Any = Undefined
-        if body_to_process is not Undefined and not isinstance(body_to_process, bytes):
+        value = Undefined
+        if isinstance(received_body, FormData):
+            alias, value = form_values.get(field.name, (aliases[0], Undefined))
+            loc = ("body", alias)
+        elif body_to_process is not Undefined and not isinstance(
+            body_to_process, bytes
+        ):
             try:
                 for alias in aliases:
                     value = body_to_process.get(alias, Undefined)
@@ -1204,15 +1298,18 @@ def _get_param_aliases(
     if convert_underscores and alias == field.name:
         request_alias = alias.replace("_", "-")
     validation_alias = field.field_info.validation_alias
-    if (
-        isinstance(field.field_info, params.Path)
-        or not isinstance(validation_alias, AliasChoices)
-        or any(isinstance(alias, AliasPath) for alias in validation_alias.choices)
+    if isinstance(field.field_info, params.Path) or isinstance(
+        validation_alias, AliasPath
     ):
         return [(alias, request_alias)]
-    choices = cast(list[str], validation_alias.choices)
-    # Explicit choices are literal names, including underscores in headers.
-    aliases = [(choice, choice) for choice in choices]
+    aliases = [(alias, request_alias)]
+    if isinstance(validation_alias, AliasChoices):
+        if any(isinstance(choice, AliasPath) for choice in validation_alias.choices):
+            return aliases
+        # Explicit choices are literal names, including underscores in headers.
+        aliases = [
+            (choice, choice) for choice in cast(list[str], validation_alias.choices)
+        ]
     config = field.config or {}
     if config.get("validate_by_alias") is False or config.get(
         "validate_by_name", config.get("populate_by_name", False)
@@ -1222,6 +1319,6 @@ def _get_param_aliases(
             request_name = request_name.replace("_", "-")
         if config.get("validate_by_alias") is False:
             return [(field.name, request_name)]
-        if field.name not in choices:
+        if field.name not in [alias for alias, _ in aliases]:
             aliases.append((field.name, request_name))
     return aliases
diff --git fastapi/openapi/utils.py fastapi/openapi/utils.py
index aec55ae05f064..31d1ee5fcbb6e 100644
--- fastapi/openapi/utils.py
+++ fastapi/openapi/utils.py
@@ -42,7 +42,6 @@
     generate_operation_id_for_path,
     is_body_allowed_for_status_code,
 )
-from pydantic import BaseModel
 from starlette.responses import JSONResponse
 from starlette.routing import BaseRoute
 
@@ -166,60 +165,51 @@ def _get_openapi_operation_parameters(
     separate_input_output_schemas: bool = True,
 ) -> list[dict[str, Any]]:
     parameters = []
-    path_params = _get_flat_fields_from_params(dependency_data.path_params)
-    query_params = _get_flat_fields_from_params(dependency_data.query_params)
-    header_params = _get_flat_fields_from_params(dependency_data.header_params)
-    cookie_params = _get_flat_fields_from_params(dependency_data.cookie_params)
     parameter_groups = [
-        (ParamTypes.path, path_params),
-        (ParamTypes.query, query_params),
-        (ParamTypes.header, header_params),
-        (ParamTypes.cookie, cookie_params),
+        (ParamTypes.path, dependency_data.path_params),
+        (ParamTypes.query, dependency_data.query_params),
+        (ParamTypes.header, dependency_data.header_params),
+        (ParamTypes.cookie, dependency_data.cookie_params),
     ]
-    default_convert_underscores = True
-    if len(dependency_data.header_params) == 1:
-        first_field = dependency_data.header_params[0]
-        if lenient_issubclass(first_field.field_info.annotation, BaseModel):
-            default_convert_underscores = getattr(
-                first_field.field_info, "convert_underscores", True
-            )
     for param_type, param_group in parameter_groups:
-        for param in param_group:
-            field_info = param.field_info
-            # field_info = cast(Param, field_info)
-            if not getattr(field_info, "include_in_schema", True):
+        for parent in param_group:
+            if not getattr(parent.field_info, "include_in_schema", True):
                 continue
-            convert_underscores = param_type == ParamTypes.header and getattr(
-                field_info, "convert_underscores", default_convert_underscores
-            )
-            alias, name = _get_param_aliases(
-                param, convert_underscores=convert_underscores
-            )[0]
-            param_schema = get_schema_from_model_field(
-                field=param,
-                model_name_map=model_name_map,
-                field_mapping=field_mapping,
-                separate_input_output_schemas=separate_input_output_schemas,
-                field_alias=alias,
-            )
+            for param in _get_flat_fields_from_params([parent]):
+                field_info = param.field_info
+                convert_underscores = param_type == ParamTypes.header and getattr(
+                    field_info,
+                    "convert_underscores",
+                    getattr(parent.field_info, "convert_underscores", True),
+                )
+                alias, name = _get_param_aliases(
+                    param, convert_underscores=convert_underscores
+                )[0]
+                param_schema = get_schema_from_model_field(
+                    field=param,
+                    model_name_map=model_name_map,
+                    field_mapping=field_mapping,
+                    separate_input_output_schemas=separate_input_output_schemas,
+                    field_alias=alias,
+                )
 
-            parameter = {
-                "name": name,
-                "in": param_type.value,
-                "required": param.field_info.is_required(),
-                "schema": param_schema,
-            }
-            if field_info.description:
-                parameter["description"] = field_info.description
-            openapi_examples = getattr(field_info, "openapi_examples", None)
-            example = getattr(field_info, "example", None)
-            if openapi_examples:
-                parameter["examples"] = jsonable_encoder(openapi_examples)
-            elif example is not _Unset:
-                parameter["example"] = jsonable_encoder(example)
-            if getattr(field_info, "deprecated", None):
-                parameter["deprecated"] = True
-            parameters.append(parameter)
+                parameter = {
+                    "name": name,
+                    "in": param_type.value,
+                    "required": param.field_info.is_required(),
+                    "schema": param_schema,
+                }
+                if field_info.description:
+                    parameter["description"] = field_info.description
+                openapi_examples = getattr(field_info, "openapi_examples", None)
+                example = getattr(field_info, "example", None)
+                if openapi_examples:
+                    parameter["examples"] = jsonable_encoder(openapi_examples)
+                elif example is not _Unset:
+                    parameter["example"] = jsonable_encoder(example)
+                if getattr(field_info, "deprecated", None):
+                    parameter["deprecated"] = True
+                parameters.append(parameter)
     return parameters
 
 
diff --git fastapi/routing.py fastapi/routing.py
index a3f8043c9e326..9a14ebf6512ae 100644
--- fastapi/routing.py
+++ fastapi/routing.py
@@ -60,6 +60,7 @@
     SolvedDependency,
     _get_body_field,
     _get_flat_body_params,
+    _get_request_param_names,
     _should_embed_body_fields,
     get_dependant,
     get_parameterless_sub_dependant,
@@ -917,6 +918,7 @@ def _build_dependant_with_parameterless_dependencies(
             0,
             get_parameterless_sub_dependant(depends=depends, path=path),
         )
+    dependant._request_param_names = _get_request_param_names(dependant)
     body_params = _get_flat_body_params(dependant)
     embed_body_fields = _should_embed_body_fields(body_params)
     return dependant, body_params, embed_body_fields
diff --git scripts/label_approved.py scripts/label_approved.py
deleted file mode 100644
index 81de92efbe84d..0000000000000
--- scripts/label_approved.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import logging
-from typing import Literal
-
-from github import Github
-from github.PullRequestReview import PullRequestReview
-from pydantic import BaseModel, SecretStr
-from pydantic_settings import BaseSettings
-
-
-class LabelSettings(BaseModel):
-    await_label: str | None = None
-    number: int
-
-
-default_config = {"approved-2": LabelSettings(await_label="awaiting-review", number=2)}
-
-
-class Settings(BaseSettings):
-    github_repository: str
-    token: SecretStr
-    debug: bool | None = False
-    config: dict[str, LabelSettings] | Literal[""] = default_config
-
-
-settings = Settings()
-if settings.debug:
-    logging.basicConfig(level=logging.DEBUG)
-else:
-    logging.basicConfig(level=logging.INFO)
-logging.debug(f"Using config: {settings.model_dump_json()}")
-g = Github(settings.token.get_secret_value())
-repo = g.get_repo(settings.github_repository)
-for pr in repo.get_pulls(state="open"):
-    logging.info(f"Checking PR: #{pr.number}")
-    pr_labels = list(pr.get_labels())
-    pr_label_by_name = {label.name: label for label in pr_labels}
-    reviews = list(pr.get_reviews())
-    review_by_user: dict[str, PullRequestReview] = {}
-    for review in reviews:
-        if review.user.login in review_by_user:
-            stored_review = review_by_user[review.user.login]
-            if review.submitted_at >= stored_review.submitted_at:
-                review_by_user[review.user.login] = review
-        else:
-            review_by_user[review.user.login] = review
-    approved_reviews = [
-        review for review in review_by_user.values() if review.state == "APPROVED"
-    ]
-    config = settings.config or default_config
-    for approved_label, conf in config.items():
-        logging.debug(f"Processing config: {conf.model_dump_json()}")
-        if conf.await_label is None or (conf.await_label in pr_label_by_name):
-            logging.debug(f"Processable PR: {pr.number}")
-            if len(approved_reviews) >= conf.number:
-                logging.info(f"Adding label to PR: {pr.number}")
-                pr.add_to_labels(approved_label)
-                if conf.await_label:
-                    logging.info(f"Removing label from PR: {pr.number}")
-                    pr.remove_from_labels(conf.await_label)
-logging.info("Finished")
diff --git scripts/notify_translations.py scripts/notify_translations.py
deleted file mode 100644
index 85cf768031c93..0000000000000
--- scripts/notify_translations.py
+++ /dev/null
@@ -1,433 +0,0 @@
-import logging
-import random
-import sys
-import time
-from pathlib import Path
-from typing import Any, cast
-
-import httpx
-from github import Github
-from pydantic import BaseModel, SecretStr
-from pydantic_settings import BaseSettings
-
-awaiting_label = "awaiting-review"
-lang_all_label = "lang-all"
-approved_label = "approved-1"
-
-
-github_graphql_url = "https://api.github.com/graphql"
-questions_translations_category_id = "DIC_kwDOCZduT84CT5P9"
-
-all_discussions_query = """
-query Q($category_id: ID) {
-  repository(name: "fastapi", owner: "fastapi") {
-    discussions(categoryId: $category_id, first: 100) {
-      nodes {
-        title
-        id
-        number
-        labels(first: 10) {
-          edges {
-            node {
-              id
-              name
-            }
-          }
-        }
-      }
-    }
-  }
-}
-"""
-
-translation_discussion_query = """
-query Q($after: String, $discussion_number: Int!) {
-  repository(name: "fastapi", owner: "fastapi") {
-    discussion(number: $discussion_number) {
-      comments(first: 100, after: $after) {
-        edges {
-          cursor
-          node {
-            id
-            url
-            body
-          }
-        }
-      }
-    }
-  }
-}
-"""
-
-add_comment_mutation = """
-mutation Q($discussion_id: ID!, $body: String!) {
-  addDiscussionComment(input: {discussionId: $discussion_id, body: $body}) {
-    comment {
-      id
-      url
-      body
-    }
-  }
-}
-"""
-
-update_comment_mutation = """
-mutation Q($comment_id: ID!, $body: String!) {
-  updateDiscussionComment(input: {commentId: $comment_id, body: $body}) {
-    comment {
-      id
-      url
-      body
-    }
-  }
-}
-"""
-
-
-class Comment(BaseModel):
-    id: str
-    url: str
-    body: str
-
-
-class UpdateDiscussionComment(BaseModel):
-    comment: Comment
-
-
-class UpdateCommentData(BaseModel):
-    updateDiscussionComment: UpdateDiscussionComment
-
-
-class UpdateCommentResponse(BaseModel):
-    data: UpdateCommentData
-
-
-class AddDiscussionComment(BaseModel):
-    comment: Comment
-
-
-class AddCommentData(BaseModel):
-    addDiscussionComment: AddDiscussionComment
-
-
-class AddCommentResponse(BaseModel):
-    data: AddCommentData
-
-
-class CommentsEdge(BaseModel):
-    node: Comment
-    cursor: str
-
-
-class Comments(BaseModel):
-    edges: list[CommentsEdge]
-
-
-class CommentsDiscussion(BaseModel):
-    comments: Comments
-
-
-class CommentsRepository(BaseModel):
-    discussion: CommentsDiscussion
-
-
-class CommentsData(BaseModel):
-    repository: CommentsRepository
-
-
-class CommentsResponse(BaseModel):
-    data: CommentsData
-
-
-class AllDiscussionsLabelNode(BaseModel):
-    id: str
-    name: str
-
-
-class AllDiscussionsLabelsEdge(BaseModel):
-    node: AllDiscussionsLabelNode
-
-
-class AllDiscussionsDiscussionLabels(BaseModel):
-    edges: list[AllDiscussionsLabelsEdge]
-
-
-class AllDiscussionsDiscussionNode(BaseModel):
-    title: str
-    id: str
-    number: int
-    labels: AllDiscussionsDiscussionLabels
-
-
-class AllDiscussionsDiscussions(BaseModel):
-    nodes: list[AllDiscussionsDiscussionNode]
-
-
-class AllDiscussionsRepository(BaseModel):
-    discussions: AllDiscussionsDiscussions
-
-
-class AllDiscussionsData(BaseModel):
-    repository: AllDiscussionsRepository
-
-
-class AllDiscussionsResponse(BaseModel):
-    data: AllDiscussionsData
-
-
-class Settings(BaseSettings):
-    model_config = {"env_ignore_empty": True}
-
-    github_repository: str
-    github_token: SecretStr
-    github_event_path: Path
-    github_event_name: str | None = None
-    httpx_timeout: int = 30
-    debug: bool | None = False
-    number: int | None = None
-
-
-class PartialGitHubEventIssue(BaseModel):
-    number: int | None = None
-
-
-class PartialGitHubEvent(BaseModel):
-    pull_request: PartialGitHubEventIssue | None = None
-
-
-def get_graphql_response(
-    *,
-    settings: Settings,
-    query: str,
-    after: str | None = None,
-    category_id: str | None = None,
-    discussion_number: int | None = None,
-    discussion_id: str | None = None,
-    comment_id: str | None = None,
-    body: str | None = None,
-) -> dict[str, Any]:
-    headers = {"Authorization": f"token {settings.github_token.get_secret_value()}"}
-    variables = {
-        "after": after,
-        "category_id": category_id,
-        "discussion_number": discussion_number,
-        "discussion_id": discussion_id,
-        "comment_id": comment_id,
-        "body": body,
-    }
-    response = httpx.post(
-        github_graphql_url,
-        headers=headers,
-        timeout=settings.httpx_timeout,
-        json={"query": query, "variables": variables, "operationName": "Q"},
-    )
-    if response.status_code != 200:
-        logging.error(
-            f"Response was not 200, after: {after}, category_id: {category_id}"
-        )
-        logging.error(response.text)
-        raise RuntimeError(response.text)
-    data = response.json()
-    if "errors" in data:
-        logging.error(f"Errors in response, after: {after}, category_id: {category_id}")
-        logging.error(data["errors"])
-        logging.error(response.text)
-        raise RuntimeError(response.text)
-    return cast(dict[str, Any], data)
-
-
-def get_graphql_translation_discussions(
-    *, settings: Settings
-) -> list[AllDiscussionsDiscussionNode]:
-    data = get_graphql_response(
-        settings=settings,
-        query=all_discussions_query,
-        category_id=questions_translations_category_id,
-    )
-    graphql_response = AllDiscussionsResponse.model_validate(data)
-    return graphql_response.data.repository.discussions.nodes
-
-
-def get_graphql_translation_discussion_comments_edges(
-    *, settings: Settings, discussion_number: int, after: str | None = None
-) -> list[CommentsEdge]:
-    data = get_graphql_response(
-        settings=settings,
-        query=translation_discussion_query,
-        discussion_number=discussion_number,
-        after=after,
-    )
-    graphql_response = CommentsResponse.model_validate(data)
-    return graphql_response.data.repository.discussion.comments.edges
-
-
-def get_graphql_translation_discussion_comments(
-    *, settings: Settings, discussion_number: int
-) -> list[Comment]:
-    comment_nodes: list[Comment] = []
-    discussion_edges = get_graphql_translation_discussion_comments_edges(
-        settings=settings, discussion_number=discussion_number
-    )
-
-    while discussion_edges:
-        for discussion_edge in discussion_edges:
-            comment_nodes.append(discussion_edge.node)
-        last_edge = discussion_edges[-1]
-        discussion_edges = get_graphql_translation_discussion_comments_edges(
-            settings=settings,
-            discussion_number=discussion_number,
-            after=last_edge.cursor,
-        )
-    return comment_nodes
-
-
-def create_comment(*, settings: Settings, discussion_id: str, body: str) -> Comment:
-    data = get_graphql_response(
-        settings=settings,
-        query=add_comment_mutation,
-        discussion_id=discussion_id,
-        body=body,
-    )
-    response = AddCommentResponse.model_validate(data)
-    return response.data.addDiscussionComment.comment
-
-
-def update_comment(*, settings: Settings, comment_id: str, body: str) -> Comment:
-    data = get_graphql_response(
-        settings=settings,
-        query=update_comment_mutation,
-        comment_id=comment_id,
-        body=body,
-    )
-    response = UpdateCommentResponse.model_validate(data)
-    return response.data.updateDiscussionComment.comment
-
-
-def main() -> None:
-    settings = Settings()
-    if settings.debug:
-        logging.basicConfig(level=logging.DEBUG)
-    else:
-        logging.basicConfig(level=logging.INFO)
-    logging.debug(f"Using config: {settings.model_dump_json()}")
-    g = Github(settings.github_token.get_secret_value())
-    repo = g.get_repo(settings.github_repository)
-    if not settings.github_event_path.is_file():
-        raise RuntimeError(
-            f"No github event file available at: {settings.github_event_path}"
-        )
-    contents = settings.github_event_path.read_text("utf-8")
-    github_event = PartialGitHubEvent.model_validate_json(contents)
-    logging.info(f"Using GitHub event: {github_event}")
-    number = (
-        github_event.pull_request and github_event.pull_request.number
-    ) or settings.number
-    if number is None:
-        raise RuntimeError("No PR number available")
-    number = cast(int, number)
-
-    # Avoid race conditions with multiple labels
-    sleep_time = random.random() * 10  # random number between 0 and 10 seconds
-    logging.info(
-        f"Sleeping for {sleep_time} seconds to avoid "
-        "race conditions and multiple comments"
-    )
-    time.sleep(sleep_time)
-
-    # Get PR
-    logging.debug(f"Processing PR: #{number}")
-    pr = repo.get_pull(number)
-    label_strs = {label.name for label in pr.get_labels()}
-    langs = []
-    for label in label_strs:
-        if label.startswith("lang-") and not label == lang_all_label:
-            langs.append(label[5:])
-    logging.info(f"PR #{pr.number} has labels: {label_strs}")
-    if not langs or lang_all_label not in label_strs:
-        logging.info(f"PR #{pr.number} doesn't seem to be a translation PR, skipping")
-        sys.exit(0)
-
-    # Generate translation map, lang ID to discussion
-    discussions = get_graphql_translation_discussions(settings=settings)
-    lang_to_discussion_map: dict[str, AllDiscussionsDiscussionNode] = {}
-    for discussion in discussions:
-        for edge in discussion.labels.edges:
-            label = edge.node.name
-            if label.startswith("lang-") and not label == lang_all_label:
-                lang = label[5:]
-                lang_to_discussion_map[lang] = discussion
-    logging.debug(f"Using translations map: {lang_to_discussion_map}")
-
-    # Messages to create or check
-    new_translation_message = f"Good news everyone! 😉 There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}. 🎉 This requires 2 approvals from native speakers to be merged. 🤓"
-    done_translation_message = f"~There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}~ Good job! This is done. 🍰☕"
-
-    # Normally only one language, but still
-    for lang in langs:
-        if lang not in lang_to_discussion_map:
-            log_message = f"Could not find discussion for language: {lang}"
-            logging.error(log_message)
-            raise RuntimeError(log_message)
-        discussion = lang_to_discussion_map[lang]
-        logging.info(
-            f"Found a translation discussion for language: {lang} in discussion: #{discussion.number}"
-        )
-
-        already_notified_comment: Comment | None = None
-        already_done_comment: Comment | None = None
-
-        logging.info(
-            f"Checking current comments in discussion: #{discussion.number} to see if already notified about this PR: #{pr.number}"
-        )
-        comments = get_graphql_translation_discussion_comments(
-            settings=settings, discussion_number=discussion.number
-        )
-        for comment in comments:
-            if new_translation_message in comment.body:
-                already_notified_comment = comment
-            elif done_translation_message in comment.body:
-                already_done_comment = comment
-        logging.info(
-            f"Already notified comment: {already_notified_comment}, already done comment: {already_done_comment}"
-        )
-
-        if pr.state == "open" and awaiting_label in label_strs:
-            logging.info(
-                f"This PR seems to be a language translation and awaiting reviews: #{pr.number}"
-            )
-            if already_notified_comment:
-                logging.info(
-                    f"This PR #{pr.number} was already notified in comment: {already_notified_comment.url}"
-                )
-            else:
-                logging.info(
-                    f"Writing notification comment about PR #{pr.number} in Discussion: #{discussion.number}"
-                )
-                comment = create_comment(
-                    settings=settings,
-                    discussion_id=discussion.id,
-                    body=new_translation_message,
-                )
-                logging.info(f"Notified in comment: {comment.url}")
-        elif pr.state == "closed" or approved_label in label_strs:
-            logging.info(f"Already approved or closed PR #{pr.number}")
-            if already_done_comment:
-                logging.info(
-                    f"This PR #{pr.number} was already marked as done in comment: {already_done_comment.url}"
-                )
-            elif already_notified_comment:
-                updated_comment = update_comment(
-                    settings=settings,
-                    comment_id=already_notified_comment.id,
-                    body=done_translation_message,
-                )
-                logging.info(f"Marked as done in comment: {updated_comment.url}")
-        else:
-            logging.info(
-                f"There doesn't seem to be anything to be done about PR #{pr.number}"
-            )
-    logging.info("Finished")
-
-
-if __name__ == "__main__":
-    main()
diff --git tests/test_header_cookie_alias_choices.py tests/test_header_cookie_alias_choices.py
index 725b1764ab52e..f2b549271205e 100644
--- tests/test_header_cookie_alias_choices.py
+++ tests/test_header_cookie_alias_choices.py
@@ -359,20 +359,26 @@ def read(data: Annotated[Model, Cookie()]):
     "convert_underscores, name, rejected_name",
     [(True, "x-values", "x_values"), (False, "x_values", "x-values")],
 )
+@pytest.mark.parametrize(
+    "field_info",
+    [
+        Field(alias="X-Second"),
+        Field(validation_alias="X-Second"),
+        Field(validation_alias=AliasChoices("X-First", "X-Second")),
+    ],
+)
 def test_header_model_name_fallback(
-    config, expected_value, convert_underscores, name, rejected_name
+    config, expected_value, convert_underscores, name, rejected_name, field_info
 ):
     app = FastAPI()
 
     class Model(BaseModel):
         model_config = config
-        x_values: Annotated[
-            list[int], Field(validation_alias=AliasChoices("X-First", "X-Second"))
-        ]
+        x_values: Annotated[list[int], field_info]
 
     @app.get("/")
     def read(data: Annotated[Model, Header(convert_underscores=convert_underscores)]):
-        return data
+        return {"x_values": data.x_values}
 
     client = TestClient(app)
     response = client.get("/", headers={name: "1"})
@@ -403,16 +409,24 @@ def read(data: Annotated[Model, Header(convert_underscores=convert_underscores)]
         ),
     ],
 )
-def test_cookie_model_name_fallback(config, expected_value):
+@pytest.mark.parametrize(
+    "field_info",
+    [
+        Field(alias="second"),
+        Field(validation_alias="second"),
+        Field(validation_alias=AliasChoices("first", "second")),
+    ],
+)
+def test_cookie_model_name_fallback(config, expected_value, field_info):
     app = FastAPI()
 
     class Model(BaseModel):
         model_config = config
-        value: Annotated[int, Field(validation_alias=AliasChoices("first", "second"))]
+        value: Annotated[int, field_info]
 
     @app.get("/")
     def read(data: Annotated[Model, Cookie()]):
-        return data
+        return {"value": data.value}
 
     client = TestClient(app)
     response = client.get("/", headers={"cookie": "value=1"})
@@ -423,7 +437,7 @@ def read(data: Annotated[Model, Cookie()]):
     assert response.json() == {"value": expected_value}
 
 
-def test_path_alias_choices_are_unchanged():
+def test_unmatched_path_alias_choices_preserve_parameter_name():
     response = client.get("/path/1")
     assert response.status_code == 200
     assert response.json() == 1
diff --git a/tests/test_multiple_parameter_models.py b/tests/test_multiple_parameter_models.py
new file mode 100644
index 0000000000000..abd4332cf0f9e
--- /dev/null
+++ tests/test_multiple_parameter_models.py
@@ -0,0 +1,417 @@
+from typing import Annotated
+from unittest.mock import patch
+
+import pytest
+from fastapi import APIRouter, Cookie, Depends, FastAPI, Header, Query, WebSocket
+from fastapi.testclient import TestClient
+from pydantic import AliasChoices, BaseModel, ConfigDict, Field, Json, model_validator
+
+
+@pytest.mark.parametrize(
+    "marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
+)
+@pytest.mark.parametrize("in_dependency", [False, True])
+@pytest.mark.parametrize("extra", ["forbid", "allow"])
+def test_composed_parameter_models(marker, location, in_dependency, extra):
+    app = FastAPI()
+
+    class Filters(BaseModel):
+        model_config = ConfigDict(extra=extra)
+        term: str
+
+    class Pagination(BaseModel):
+        model_config = ConfigDict(extra=extra)
+        limit: int = Field(10, ge=1, description="Page size")
+
+    def filters_dependency(filters: Annotated[Filters, marker()]):
+        return filters
+
+    filter_annotation = Depends(filters_dependency) if in_dependency else marker()
+
+    @app.get("/")
+    def read(
+        filters: Annotated[Filters, filter_annotation],
+        pagination: Annotated[Pagination, marker()],
+        host: Annotated[str, marker()] = "testserver",
+    ):
+        return {"filters": filters, "pagination": pagination, "host": host}
+
+    client = TestClient(app)
+    client.headers.clear()
+
+    def send(data):
+        if location == "query":
+            return client.get("/", params=data)
+        if location == "header":
+            return client.get("/", headers=data)
+        return client.get(
+            "/", headers={"cookie": "; ".join(f"{k}={v}" for k, v in data.items())}
+        )
+
+    response = send({"term": "hello", "limit": "2", "host": "example.com"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "filters": {"term": "hello"},
+        "pagination": {"limit": 2},
+        "host": "example.com",
+    }
+    response = send({"term": "hello"})
+    assert response.status_code == 200, response.text
+    assert response.json()["pagination"] == {"limit": 10}
+    response = send({"term": "hello", "limit": "0"})
+    assert response.status_code == 422
+    assert response.json()["detail"][0]["loc"] == [location, "limit"]
+    response = send({"term": "hello", "typo": "unexpected"})
+    if extra == "forbid":
+        assert response.status_code == 422
+        assert all(
+            error["loc"] == [location, "typo"] for error in response.json()["detail"]
+        )
+    else:
+        assert response.status_code == 200, response.text
+        assert response.json() == {
+            "filters": {"term": "hello", "typo": "unexpected"},
+            "pagination": {"limit": 10, "typo": "unexpected"},
+            "host": "testserver",
+        }
+    parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
+    assert {(p["in"], p["name"]) for p in parameters} == {
+        (location, "term"),
+        (location, "limit"),
+        (location, "host"),
+    }
+    limit = next(p for p in parameters if p["name"] == "limit")
+    assert limit["schema"]["minimum"] == 1
+    assert limit["schema"]["default"] == 10
+    assert limit["description"] == "Page size"
+    assert limit["required"] is False
+
+
+def test_parameter_shapes_and_aliases_are_independent():
+    app = FastAPI()
+
+    class Many(BaseModel):
+        names: Annotated[
+            list[str], Field(validation_alias=AliasChoices("names", "legacy"))
+        ]
+
+    class One(BaseModel):
+        text: str = Field(alias="legacy")
+
+    @app.get("/")
+    def read(many: Annotated[Many, Query()], one: Annotated[One, Query()], legacy: str):
+        return {"many": many, "one": one, "last": legacy}
+
+    response = TestClient(app).get(
+        "/", params=[("legacy", "first"), ("legacy", "last")]
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "many": {"names": ["first", "last"]},
+        "one": {"legacy": "last"},
+        "last": "last",
+    }
+
+
+def test_header_conversion_is_per_model_and_does_not_mutate_cached_fields():
+    app = FastAPI()
+
+    class Headers(BaseModel):
+        x_id: int
+        tags: list[str] = Field(default_factory=list, alias="X-Tags")
+
+    @app.get("/mixed")
+    def read(
+        converted: Annotated[Headers, Header()],
+        literal: Annotated[Headers, Header(convert_underscores=False)],
+    ):
+        return {"converted": converted, "literal": literal}
+
+    @app.get("/query")
+    def read_query(data: Annotated[Headers, Query()]):
+        return data
+
+    client = TestClient(app)
+    response = client.get(
+        "/mixed",
+        headers=[("X-Id", "1"), ("x_id", "2"), ("X-Tags", "a"), ("x-tags", "b")],
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "converted": {"x_id": 1, "X-Tags": ["a", "b"]},
+        "literal": {"x_id": 2, "X-Tags": ["a", "b"]},
+    }
+    schema = app.openapi()
+    assert {p["name"] for p in schema["paths"]["/mixed"]["get"]["parameters"]} == {
+        "x-id",
+        "x_id",
+        "X-Tags",
+    }
+    assert {p["name"] for p in schema["paths"]["/query"]["get"]["parameters"]} == {
+        "x_id",
+        "X-Tags",
+    }
+    response = client.get("/query", params={"x_id": "3", "X-Tags": ["c", "d"]})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"x_id": 3, "X-Tags": ["c", "d"]}
+    response = client.get("/mixed", headers={"x-id": "bad", "x_id": "also-bad"})
+    assert response.status_code == 422
+    assert [error["loc"] for error in response.json()["detail"]] == [
+        ["header", "x-id"],
+        ["header", "x_id"],
+    ]
+    assert Headers.model_fields["x_id"].alias is None
+
+
+def test_model_validators_defaults_and_unknown_extras():
+    app = FastAPI()
+
+    class Range(BaseModel):
+        model_config = ConfigDict(extra="allow")
+        start: int = 0
+        end: int = 10
+        tags: list[str] = Field(default_factory=list)
+
+        @model_validator(mode="after")
+        def check_range(self):
+            if self.start > self.end:
+                raise ValueError("start must not exceed end")
+            return self
+
+    @app.get("/")
+    def read(data: Annotated[Range, Query()], order: str = "asc"):
+        return {
+            "data": data,
+            "fields_set": sorted(data.model_fields_set),
+            "order": order,
+        }
+
+    client = TestClient(app)
+    response = client.get("/", params={"order": "desc", "note": ["a", "b"]})
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "data": {"start": 0, "end": 10, "tags": [], "note": ["a", "b"]},
+        "fields_set": ["note"],
+        "order": "desc",
+    }
+    response = client.get("/", params={"start": "20"})
+    assert response.status_code == 422
+    assert response.json()["detail"][0]["loc"] == ["query"]
+
+
+def test_dependency_overrides_and_router_parameters():
+    app = FastAPI()
+
+    class Filters(BaseModel):
+        model_config = ConfigDict(extra="forbid")
+        term: str
+
+    def pagination(limit: int = 10):
+        return limit
+
+    def replacement(page: int = 1):
+        return page
+
+    def token(token: str):
+        return token
+
+    router = APIRouter(dependencies=[Depends(token)])
+
+    @router.get("/")
+    def read(
+        filters: Annotated[Filters, Query()],
+        page: Annotated[int, Depends(pagination)],
+    ):
+        return {"filters": filters, "page": page}
+
+    app.include_router(router)
+    client = TestClient(app)
+    assert (
+        client.get(
+            "/", params={"term": "a", "limit": "2", "token": "secret"}
+        ).status_code
+        == 200
+    )
+    with patch(
+        "fastapi.dependencies.utils._get_request_param_names",
+        side_effect=AssertionError("Parameter names should already be collected"),
+    ):
+        response = client.get(
+            "/", params={"term": "a", "limit": "2", "token": "secret"}
+        )
+        assert response.status_code == 200, response.text
+        assert response.json() == {"filters": {"term": "a"}, "page": 2}
+    app.dependency_overrides[pagination] = replacement
+    response = client.get("/", params={"term": "a", "page": "3", "token": "secret"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"filters": {"term": "a"}, "page": 3}
+    response = client.get("/", params={"term": "a", "limit": "2", "token": "secret"})
+    assert response.status_code == 422
+    assert response.json()["detail"][0]["loc"] == ["query", "limit"]
+    app.dependency_overrides.clear()
+    response = client.get("/", params={"term": "a", "limit": "4", "token": "secret"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"filters": {"term": "a"}, "page": 4}
+    response = client.get("/", params={"term": "a", "page": "3", "token": "secret"})
+    assert response.status_code == 422
+    assert response.json()["detail"][0]["loc"] == ["query", "page"]
+
+
+def test_parameter_models_keep_request_locations_separate():
+    app = FastAPI()
+
+    class Model(BaseModel):
+        value: int
+
+    @app.get("/")
+    def read(
+        query: Annotated[Model, Query()],
+        header: Annotated[Model, Header()],
+        cookie: Annotated[Model, Cookie()],
+    ):
+        return {"query": query, "header": header, "cookie": cookie}
+
+    response = TestClient(app).get(
+        "/", params={"value": "1"}, headers={"value": "2", "cookie": "value=3"}
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "query": {"value": 1},
+        "header": {"value": 2},
+        "cookie": {"value": 3},
+    }
+    parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
+    assert {(param["in"], param["name"]) for param in parameters} == {
+        ("query", "value"),
+        ("header", "value"),
+        ("cookie", "value"),
+    }
+
+
+@pytest.mark.parametrize("marker", [Header, Cookie])
+def test_other_locations_do_not_allow_extra_query_parameters(marker):
+    app = FastAPI()
+
+    class Filters(BaseModel):
+        model_config = ConfigDict(extra="forbid")
+        term: str
+
+    @app.get("/")
+    def read(
+        filters: Annotated[Filters, Query()], limit: Annotated[int, marker()] = 10
+    ):
+        return filters
+
+    client = TestClient(app)
+    response = client.get("/", params={"term": "a"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"term": "a"}
+    response = client.get("/", params={"term": "a", "limit": "2"})
+    assert response.status_code == 422
+    assert response.json()["detail"][0]["loc"] == ["query", "limit"]
+    assert response.json()["detail"][0]["type"] == "extra_forbidden"
+
+
+def test_hidden_model_and_shared_required_parameter():
+    app = FastAPI()
+
+    class Optional(BaseModel):
+        shared: int = 1
+
+    class Required(BaseModel):
+        shared: int
+
+    class Hidden(BaseModel):
+        secret: str
+
+    @app.get("/")
+    def read(
+        optional: Annotated[Optional, Query()],
+        required: Annotated[Required, Query()],
+        hidden: Annotated[Hidden, Query(include_in_schema=False)],
+    ):
+        return [optional, required, hidden]
+
+    client = TestClient(app)
+    response = client.get("/", params={"shared": "2", "secret": "ok"})
+    assert response.status_code == 200, response.text
+    assert response.json() == [{"shared": 2}, {"shared": 2}, {"secret": "ok"}]
+    assert client.get("/", params={"secret": "ok"}).status_code == 422
+    parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
+    assert len(parameters) == 1
+    assert parameters[0]["name"] == "shared"
+    assert parameters[0]["required"] is True
+
+
+def test_shared_alias_choices_still_validate_unused_choices_as_extras():
+    app = FastAPI()
+
+    class Model(BaseModel):
+        model_config = ConfigDict(extra="forbid")
+        value: Annotated[int, Field(validation_alias=AliasChoices("first", "second"))]
+
+    @app.get("/")
+    def read(model: Annotated[Model, Query()], second: int = 0):
+        return model
+
+    client = TestClient(app)
+    assert client.get("/", params={"second": "2"}).json() == {"value": 2}
+    response = client.get("/", params={"first": "1", "second": "2"})
+    assert response.status_code == 422
+    assert response.json()["detail"][0]["loc"] == ["query", "second"]
+
+
+@pytest.mark.parametrize(
+    "marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
+)
+def test_explicit_json_model_stays_a_single_parameter(marker, location):
+    app = FastAPI()
+
+    class Model(BaseModel):
+        value: int
+
+    @app.get("/")
+    def read(
+        data: Annotated[Json[Model], marker()], limit: Annotated[int, marker()] = 10
+    ):
+        return {"data": data, "limit": limit}
+
+    client = TestClient(app)
+    data = '{"value": 2}'
+    if location == "query":
+        response = client.get("/", params={"data": data})
+    elif location == "header":
+        response = client.get("/", headers={"data": data})
+    else:
+        response = client.get("/", headers={"cookie": f"data={data}"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"data": {"value": 2}, "limit": 10}
+    assert {p["name"] for p in app.openapi()["paths"]["/"]["get"]["parameters"]} == {
+        "data",
+        "limit",
+    }
+
+
+def test_nested_dependencies_and_websocket_parameter_models():
+    app = FastAPI()
+
+    class Model(BaseModel):
+        model_config = ConfigDict(extra="forbid")
+        term: str
+
+    def filters(data: Annotated[Model, Query()]):
+        return data
+
+    def nested(data: Annotated[Model, Depends(filters)]):
+        return data
+
+    @app.websocket("/ws")
+    async def read(
+        websocket: WebSocket, data: Annotated[Model, Depends(nested)], limit: int = 10
+    ):
+        await websocket.accept()
+        await websocket.send_json({"term": data.term, "limit": limit})
+        await websocket.close()
+
+    with TestClient(app).websocket_connect("/ws?term=a&limit=2") as websocket:
+        assert websocket.receive_json() == {"term": "a", "limit": 2}
diff --git a/tests/test_path_alias_choices.py b/tests/test_path_alias_choices.py
new file mode 100644
index 0000000000000..11aecab8f7237
--- /dev/null
+++ tests/test_path_alias_choices.py
@@ -0,0 +1,287 @@
+from typing import Annotated
+
+import pytest
+from fastapi import APIRouter, Depends, FastAPI, Path, WebSocket
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+from pydantic import AliasChoices, AliasPath
+
+app = FastAPI()
+router = APIRouter()
+identifier_path = Path(
+    alias="unused",
+    validation_alias=AliasChoices("item_id", "id"),
+    ge=1,
+)
+Identifier = Annotated[int, identifier_path]
+
+
+def get_identifier(value: Identifier):
+    return value
+
+
+@router.get("/")
+def read_item(value: Annotated[int, Depends(get_identifier)]):
+    return value
+
+
+app.include_router(router, prefix="/items/{item_id}")
+app.include_router(router, prefix="/legacy-items/{id}")
+
+
+@app.websocket("/ws/{id}")
+async def read_websocket(
+    websocket: WebSocket, value: Annotated[int, Depends(get_identifier)]
+):
+    await websocket.accept()
+    await websocket.send_json(value)
+    await websocket.close()
+
+
+client = TestClient(app)
+
+
+@pytest.mark.parametrize("prefix", ["/items", "/legacy-items"])
+def test_dependency_alias_choices_follow_router_prefix(prefix: str):
+    response = client.get(f"{prefix}/1/")
+    assert response.status_code == 200
+    assert response.json() == 1
+    assert identifier_path.validation_alias == AliasChoices("item_id", "id")
+    assert identifier_path.alias == "unused"
+
+
+@pytest.mark.parametrize(
+    "prefix, alias", [("/items", "item_id"), ("/legacy-items", "id")]
+)
+def test_invalid_value_uses_route_parameter_name(prefix: str, alias: str):
+    response = client.get(f"{prefix}/invalid/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "int_parsing",
+                "loc": ["path", alias],
+                "msg": "Input should be a valid integer, unable to parse string as an integer",
+                "input": "invalid",
+            }
+        ]
+    }
+
+
+def test_selected_alias_preserves_constraints():
+    response = client.get("/legacy-items/0/")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "greater_than_equal",
+                "loc": ["path", "id"],
+                "msg": "Input should be greater than or equal to 1",
+                "input": "0",
+                "ctx": {"ge": 1},
+            }
+        ]
+    }
+
+
+@pytest.mark.parametrize("alias", ["first", "second"])
+def test_individual_path_alias_choices(alias: str):
+    app = FastAPI()
+
+    @app.get("/{" + alias + "}")
+    def read(
+        value: Annotated[int, Path(validation_alias=AliasChoices("first", "second"))],
+    ):
+        return value
+
+    client = TestClient(app)
+    response = client.get("/1")
+    assert response.status_code == 200
+    assert response.json() == 1
+
+
+def test_multiple_matching_choices_follow_declaration_order():
+    app = FastAPI()
+
+    @app.get("/{second}/{first}")
+    def read(
+        value: Annotated[
+            int, Path(alias="second", validation_alias=AliasChoices("first", "second"))
+        ],
+        second: str,
+    ):
+        return {"value": value, "second": second}
+
+    client = TestClient(app)
+    response = client.get("/2/1")
+    assert response.status_code == 200
+    assert response.json() == {"value": 1, "second": "2"}
+    response = client.get("/2/invalid")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "int_parsing",
+                "loc": ["path", "first"],
+                "msg": "Input should be a valid integer, unable to parse string as an integer",
+                "input": "invalid",
+            }
+        ]
+    }
+
+
+def test_unmatched_choices_preserve_explicit_alias():
+    app = FastAPI()
+
+    @app.get("/{legacy}")
+    def read(
+        value: Annotated[
+            int, Path(alias="legacy", validation_alias=AliasChoices("first", "second"))
+        ],
+    ):
+        return value
+
+    client = TestClient(app)
+    response = client.get("/1")
+    assert response.status_code == 200
+    assert response.json() == 1
+
+
+@pytest.mark.parametrize(
+    "validation_alias",
+    [AliasPath("value"), AliasChoices("first", AliasPath("value"))],
+)
+def test_alias_path_extraction_is_unchanged(validation_alias):
+    app = FastAPI()
+
+    @app.get("/{first}/{value}")
+    def read(
+        value: Annotated[int, Path(validation_alias=validation_alias)], first: int
+    ):
+        return {"value": value, "first": first}
+
+    client = TestClient(app)
+    response = client.get("/1/2")
+    assert response.status_code == 200
+    assert response.json() == {"value": 2, "first": 1}
+
+
+def test_websocket_dependency_uses_matching_alias():
+    with client.websocket_connect("/ws/1") as websocket:
+        assert websocket.receive_json() == 1
+
+
+def test_openapi():
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == snapshot(
+        {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/items/{item_id}/": {
+                    "get": {
+                        "summary": "Read Item",
+                        "operationId": "read_item_items__item_id___get",
+                        "parameters": [
+                            {
+                                "name": "item_id",
+                                "in": "path",
+                                "required": True,
+                                "schema": {
+                                    "type": "integer",
+                                    "minimum": 1,
+                                    "title": "Item Id",
+                                },
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                },
+                "/legacy-items/{id}/": {
+                    "get": {
+                        "summary": "Read Item",
+                        "operationId": "read_item_legacy_items__id___get",
+                        "parameters": [
+                            {
+                                "name": "id",
+                                "in": "path",
+                                "required": True,
+                                "schema": {
+                                    "type": "integer",
+                                    "minimum": 1,
+                                    "title": "Id",
+                                },
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                },
+            },
+            "components": {
+                "schemas": {
+                    "HTTPValidationError": {
+                        "properties": {
+                            "detail": {
+                                "items": {
+                                    "$ref": "#/components/schemas/ValidationError"
+                                },
+                                "type": "array",
+                                "title": "Detail",
+                            }
+                        },
+                        "type": "object",
+                        "title": "HTTPValidationError",
+                    },
+                    "ValidationError": {
+                        "properties": {
+                            "loc": {
+                                "items": {
+                                    "anyOf": [{"type": "string"}, {"type": "integer"}]
+                                },
+                                "type": "array",
+                                "title": "Location",
+                            },
+                            "msg": {"type": "string", "title": "Message"},
+                            "type": {"type": "string", "title": "Error Type"},
+                            "input": {"title": "Input"},
+                            "ctx": {"type": "object", "title": "Context"},
+                        },
+                        "type": "object",
+                        "required": ["loc", "msg", "type"],
+                        "title": "ValidationError",
+                    },
+                }
+            },
+        }
+    )
diff --git tests/test_query_alias_choices.py tests/test_query_alias_choices.py
index 34a234cab8bed..9870f74bebb0b 100644
--- tests/test_query_alias_choices.py
+++ tests/test_query_alias_choices.py
@@ -201,42 +201,84 @@ def read(data: Annotated[Model, Query()]):
 
 
 @pytest.mark.parametrize(
-    "validation_alias", [AliasChoices("item", "tag"), AliasChoices("item", "items")]
+    "field_info",
+    [
+        Field(alias="item"),
+        Field(validation_alias="item"),
+        Field(validation_alias=AliasChoices("item", "tag")),
+        Field(validation_alias=AliasChoices("item", "items")),
+    ],
+)
+@pytest.mark.parametrize(
+    "config",
+    [
+        ConfigDict(populate_by_name=True),
+        pytest.param(
+            ConfigDict(validate_by_name=True),
+            marks=pytest.mark.skipif(
+                PYDANTIC_VERSION_MINOR_TUPLE < (2, 11),
+                reason="validate_by_name requires Pydantic 2.11",
+            ),
+        ),
+    ],
 )
-def test_model_list_populated_by_name(validation_alias: AliasChoices):
+def test_model_list_populated_by_name(field_info, config):
     app = FastAPI()
 
     class Model(BaseModel):
-        model_config = ConfigDict(populate_by_name=True)
-        items: Annotated[list[str], Field(validation_alias=validation_alias)]
+        model_config = config
+        items: Annotated[list[int], field_info]
 
     @app.get("/")
     def read(data: Annotated[Model, Query()]):
-        return data
+        return {"items": data.items}
 
     client = TestClient(app)
-    response = client.get("/", params={"items": "one"})
+    response = client.get("/", params={"items": "1"})
     assert response.status_code == 200
-    assert response.json() == {"items": ["one"]}
-    response = client.get("/", params={"items": "one", "item": "two"})
+    assert response.json() == {"items": [1]}
+    response = client.get("/", params={"items": "1", "item": "2"})
     assert response.status_code == 200
-    assert response.json() == {"items": ["two"]}
+    assert response.json() == {"items": [2]}
+    response = client.get("/", params={"items": "invalid"})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "int_parsing",
+                "loc": ["query", "items", 0],
+                "msg": "Input should be a valid integer, unable to parse string as an integer",
+                "input": "invalid",
+            }
+        ]
+    }
+    response = client.get("/", params={"items": "1", "item": "invalid"})
+    assert response.status_code == 422
+    assert response.json()["detail"][0]["loc"] == ["query", "item", 0]
 
 
 @pytest.mark.skipif(
     PYDANTIC_VERSION_MINOR_TUPLE < (2, 11),
     reason="validate_by_name and validate_by_alias require Pydantic 2.11",
 )
-def test_model_can_disable_validation_by_alias():
+@pytest.mark.parametrize(
+    "field_info",
+    [
+        Field(alias="tag"),
+        Field(validation_alias="tag"),
+        Field(validation_alias=AliasChoices("item", "tag")),
+    ],
+)
+def test_model_can_disable_validation_by_alias(field_info):
     app = FastAPI()
 
     class Model(BaseModel):
         model_config = ConfigDict(validate_by_alias=False, extra="forbid")
-        items: Annotated[list[str], Field(validation_alias=AliasChoices("item", "tag"))]
+        items: Annotated[list[str], field_info]
 
     @app.get("/")
     def read(data: Annotated[Model, Query()]):
-        return data
+        return {"items": data.items}
 
     client = TestClient(app)
     response = client.get("/", params={"items": "one"})
diff --git tests/test_request_param_model_by_alias.py tests/test_request_param_model_by_alias.py
index c29130d7a6da4..f1b22b2a7b47b 100644
--- tests/test_request_param_model_by_alias.py
+++ tests/test_request_param_model_by_alias.py
@@ -1,7 +1,12 @@
+from typing import Annotated
+
+import pytest
 from dirty_equals import IsPartialDict
 from fastapi import Cookie, FastAPI, Header, Query
+from fastapi._compat import PYDANTIC_VERSION_MINOR_TUPLE
 from fastapi.testclient import TestClient
-from pydantic import BaseModel, Field
+from inline_snapshot import snapshot
+from pydantic import BaseModel, ConfigDict, Field
 
 app = FastAPI()
 
@@ -70,3 +75,173 @@ def test_cookie_model_with_alias_by_name():
     assert response.status_code == 422, response.text
     details = response.json()
     assert details["detail"][0]["input"] == {"param": "value"}
+
+
+@pytest.mark.skipif(
+    PYDANTIC_VERSION_MINOR_TUPLE < (2, 11),
+    reason="validate_by_alias requires Pydantic 2.11",
+)
+def test_openapi_with_validation_by_alias_disabled():
+    app = FastAPI()
+
+    class Model(BaseModel):
+        model_config = ConfigDict(validate_by_alias=False)
+        int_value: Annotated[int, Field(alias="legacy")]
+
+    @app.get("/query")
+    def read_query(data: Annotated[Model, Query()]):
+        return data
+
+    @app.get("/header")
+    def read_header(data: Annotated[Model, Header()]):
+        return data
+
+    @app.get("/cookie")
+    def read_cookie(data: Annotated[Model, Cookie()]):
+        return data
+
+    client = TestClient(app)
+    response = client.get("/query", params={"int_value": "1"})
+    assert response.status_code == 200
+    assert response.json() == {"legacy": 1}
+    response = client.get("/header", headers={"int-value": "1"})
+    assert response.status_code == 200
+    assert response.json() == {"legacy": 1}
+    response = client.get("/cookie", headers={"cookie": "int_value=1"})
+    assert response.status_code == 200
+    assert response.json() == {"legacy": 1}
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == snapshot(
+        {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/query": {
+                    "get": {
+                        "summary": "Read Query",
+                        "operationId": "read_query_query_get",
+                        "parameters": [
+                            {
+                                "name": "int_value",
+                                "in": "query",
+                                "required": True,
+                                "schema": {"type": "integer", "title": "Int Value"},
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                },
+                "/header": {
+                    "get": {
+                        "summary": "Read Header",
+                        "operationId": "read_header_header_get",
+                        "parameters": [
+                            {
+                                "name": "int-value",
+                                "in": "header",
+                                "required": True,
+                                "schema": {"type": "integer", "title": "Int Value"},
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                },
+                "/cookie": {
+                    "get": {
+                        "summary": "Read Cookie",
+                        "operationId": "read_cookie_cookie_get",
+                        "parameters": [
+                            {
+                                "name": "int_value",
+                                "in": "cookie",
+                                "required": True,
+                                "schema": {"type": "integer", "title": "Int Value"},
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                },
+            },
+            "components": {
+                "schemas": {
+                    "HTTPValidationError": {
+                        "properties": {
+                            "detail": {
+                                "items": {
+                                    "$ref": "#/components/schemas/ValidationError"
+                                },
+                                "type": "array",
+                                "title": "Detail",
+                            }
+                        },
+                        "type": "object",
+                        "title": "HTTPValidationError",
+                    },
+                    "ValidationError": {
+                        "properties": {
+                            "loc": {
+                                "items": {
+                                    "anyOf": [{"type": "string"}, {"type": "integer"}]
+                                },
+                                "type": "array",
+                                "title": "Location",
+                            },
+                            "msg": {"type": "string", "title": "Message"},
+                            "type": {"type": "string", "title": "Error Type"},
+                            "input": {"title": "Input"},
+                            "ctx": {"type": "object", "title": "Context"},
+                        },
+                        "type": "object",
+                        "required": ["loc", "msg", "type"],
+                        "title": "ValidationError",
+                    },
+                }
+            },
+        }
+    )
diff --git tests/test_request_params/test_file/test_nullable_and_defaults.py tests/test_request_params/test_file/test_nullable_and_defaults.py
index d4d988564eeb2..5d6cd2dda37f3 100644
--- tests/test_request_params/test_file/test_nullable_and_defaults.py
+++ tests/test_request_params/test_file/test_nullable_and_defaults.py
@@ -300,6 +300,112 @@ def read_file(
     assert response.json() == "hello"
 
 
+def test_file_alias_choices_errors_use_selected_aliases():
+    app = FastAPI()
+
+    @app.post("/")
+    def read_files(
+        file: Annotated[
+            UploadFile,
+            File(alias="legacy", validation_alias=AliasChoices("primary", "secondary")),
+        ],
+        files: Annotated[
+            list[UploadFile],
+            File(validation_alias=AliasChoices("primary-files", "secondary-files")),
+        ],
+    ):
+        return {"file": file.filename, "files": [item.filename for item in files]}
+
+    client = TestClient(app)
+    response = client.post(
+        "/",
+        files=[
+            ("secondary", ("one.txt", b"one")),
+            ("secondary-files", ("two.txt", b"two")),
+            ("secondary-files", ("three.txt", b"three")),
+        ],
+    )
+    assert response.status_code == 200
+    assert response.json() == {"file": "one.txt", "files": ["two.txt", "three.txt"]}
+
+    response = client.post(
+        "/",
+        files=[
+            ("secondary", (None, "invalid")),
+            ("secondary-files", ("valid.txt", b"valid")),
+            ("secondary-files", (None, "invalid")),
+            ("legacy", ("ignored.txt", b"ignored")),
+        ],
+    )
+    assert response.status_code == 422
+    assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+        ("value_error", ["body", "secondary"]),
+        ("value_error", ["body", "secondary-files", 1]),
+    ]
+
+    response = client.post(
+        "/",
+        files=[
+            ("primary", (None, "invalid")),
+            ("secondary", ("valid.txt", b"valid")),
+            ("primary-files", (None, "invalid")),
+            ("secondary-files", ("valid.txt", b"valid")),
+        ],
+    )
+    assert response.status_code == 422
+    assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+        ("value_error", ["body", "primary"]),
+        ("value_error", ["body", "primary-files", 0]),
+    ]
+
+    response = client.post("/")
+    assert response.status_code == 422
+    assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+        ("missing", ["body", "primary"]),
+        ("missing", ["body", "primary-files"]),
+    ]
+
+
+def test_unselected_file_alias_choices_use_the_next_alias():
+    app = FastAPI()
+
+    @app.post("/")
+    def read_file(
+        file: Annotated[
+            bytes,
+            File(alias="legacy", validation_alias=AliasChoices("file", "selected")),
+        ],
+    ):
+        return file.decode()
+
+    client = TestClient(app)
+    response = client.post(
+        "/",
+        content=(
+            b"--boundary\r\n"
+            b'Content-Disposition: form-data; name="file"; filename=""\r\n'
+            b"Content-Type: application/octet-stream\r\n\r\n"
+            b"\r\n--boundary\r\n"
+            b'Content-Disposition: form-data; name="selected"; filename="hello.txt"\r\n'
+            b"Content-Type: application/octet-stream\r\n\r\n"
+            b"hello\r\n--boundary--\r\n"
+        ),
+        headers={"content-type": "multipart/form-data; boundary=boundary"},
+    )
+    assert response.status_code == 200
+    assert response.json() == "hello"
+
+    response = client.post(
+        "/",
+        content=EMPTY_FILE_FORM,
+        headers={"content-type": "multipart/form-data; boundary=boundary"},
+    )
+    assert response.status_code == 422
+    assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+        ("missing", ["body", "file"]),
+    ]
+
+
 @pytest.mark.parametrize(
     "file_alias,files_alias",
     [("primary", "primary-files"), ("secondary", "secondary-files"), ("file", "files")],
diff --git tests/test_request_params/test_form/test_nullable_and_defaults.py tests/test_request_params/test_form/test_nullable_and_defaults.py
index 0c45682f2b72c..abd7d1200341e 100644
--- tests/test_request_params/test_form/test_nullable_and_defaults.py
+++ tests/test_request_params/test_form/test_nullable_and_defaults.py
@@ -295,10 +295,8 @@ def read_model(params: Annotated[Model, Form()]):
     assert response.json() == {"value": expected}
 
 
-@pytest.mark.parametrize(
-    "path,error_alias", [("/parameter", "value"), ("/model", "first")]
-)
-def test_required_form_alias_choices_preserve_validation_precedence(path, error_alias):
+@pytest.mark.parametrize("path", ["/parameter", "/model"])
+def test_required_form_alias_choices_preserve_validation_precedence(path):
     app = FastAPI()
 
     class Model(BaseModel):
@@ -322,13 +320,31 @@ def read_model(params: Annotated[Model, Form()]):
     response = client.post(path, data={"first": "invalid", "second": "5"})
     assert response.status_code == 422
     assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
-        ("int_parsing", ["body", error_alias]),
+        ("int_parsing", ["body", "first"]),
     ]
 
     response = client.post(path, data={"first": "", "second": ""})
     assert response.status_code == 422
     assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
-        ("missing", ["body", error_alias]),
+        ("missing", ["body", "first"]),
+    ]
+
+    response = client.post(path, data={"first": "", "second": "invalid"})
+    assert response.status_code == 422
+    assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+        ("int_parsing", ["body", "second"]),
+    ]
+
+    response = client.post(path, data={"second": "invalid"})
+    assert response.status_code == 422
+    assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+        ("int_parsing", ["body", "second"]),
+    ]
+
+    response = client.post(path)
+    assert response.status_code == 422
+    assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
+        ("missing", ["body", "first"]),
     ]
 
 
@@ -396,6 +412,71 @@ def read_model(params: Annotated[Model, Form()]):
     assert response.json() == {"value": expected}
 
 
+@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"])
+def test_form_list_alias_choices_error_uses_selected_alias(encoding):
+    app = FastAPI()
+
+    @app.post("/")
+    def read_form(
+        values: Annotated[
+            list[int],
+            Form(alias="legacy", validation_alias=AliasChoices("first", "second")),
+        ],
+    ):
+        return values
+
+    client = TestClient(app)
+    if encoding == "urlencoded":
+        response = client.post("/", data={"second": ["1", "2"]})
+    else:
+        response = client.post(
+            "/", files=[("second", (None, "1")), ("second", (None, "2"))]
+        )
+    assert response.status_code == 200
+    assert response.json() == [1, 2]
+
+    if encoding == "urlencoded":
+        response = client.post("/", data={"second": ["1", "invalid"], "legacy": "3"})
+    else:
+        response = client.post(
+            "/",
+            files=[
+                ("second", (None, "1")),
+                ("second", (None, "invalid")),
+                ("legacy", (None, "3")),
+            ],
+        )
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "int_parsing",
+                "loc": ["body", "second", 1],
+                "msg": "Input should be a valid integer, unable to parse string as an integer",
+                "input": "invalid",
+            }
+        ]
+    }
+
+
+def test_individual_form_parameters_can_share_an_alias_choice():
+    app = FastAPI()
+
+    @app.post("/")
+    def read_form(
+        values: Annotated[
+            list[int], Form(validation_alias=AliasChoices("values", "shared"))
+        ],
+        value: Annotated[int, Form(validation_alias=AliasChoices("value", "shared"))],
+    ):
+        return {"values": values, "value": value}
+
+    client = TestClient(app)
+    response = client.post("/", data={"shared": ["1", "2"]})
+    assert response.status_code == 200
+    assert response.json() == {"values": [1, 2], "value": 2}
+
+
 @pytest.mark.parametrize("encoding", ["urlencoded", "multipart"])
 def test_form_model_fields_can_share_an_unused_alias_choice(encoding):
     app = FastAPI()
@@ -935,10 +1016,8 @@ def read_model(params: Annotated[Model, Form()]):
     assert response.json() == {"value": False, "nullable": False}
 
 
-@pytest.mark.parametrize(
-    "path,error_alias", [("/parameter", "legacy"), ("/model", "enabled")]
-)
-def test_checkbox_alias_choices_preserve_validation_precedence(path, error_alias):
+@pytest.mark.parametrize("path", ["/parameter", "/model"])
+def test_checkbox_alias_choices_preserve_validation_precedence(path):
     app = FastAPI()
 
     class Model(BaseModel):
@@ -976,7 +1055,7 @@ def read_model(params: Annotated[Model, Form()]):
         "detail": [
             {
                 "type": "bool_parsing",
-                "loc": ["body", error_alias],
+                "loc": ["body", "enabled"],
                 "msg": "Input should be a valid boolean, unable to interpret input",
                 "input": "invalid",
             }
diff --git a/tests/test_tutorial/test_query_param_models/test_tutorial003.py b/tests/test_tutorial/test_query_param_models/test_tutorial003.py
new file mode 100644
index 0000000000000..c441edca3e783
--- /dev/null
+++ tests/test_tutorial/test_query_param_models/test_tutorial003.py
@@ -0,0 +1,38 @@
+import importlib
+
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_py310
+
+
+@pytest.fixture(
+    name="client",
+    params=[
+        pytest.param("tutorial003_py310", marks=needs_py310),
+        pytest.param("tutorial003_an_py310", marks=needs_py310),
+    ],
+)
+def get_client(request: pytest.FixtureRequest):
+    mod = importlib.import_module(f"docs_src.query_param_models.{request.param}")
+    return TestClient(mod.app)
+
+
+def test_combined_query_param_models(client: TestClient):
+    response = client.get("/items/?limit=5&offset=10&tags=red&tags=blue&search=shirt")
+    assert response.status_code == 200
+    assert response.json() == {
+        "pagination": {"limit": 5, "offset": 10},
+        "filters": {"tags": ["red", "blue"]},
+        "search": "shirt",
+    }
+
+
+def test_combined_query_param_models_defaults(client: TestClient):
+    response = client.get("/items/")
+    assert response.status_code == 200
+    assert response.json() == {
+        "pagination": {"limit": 10, "offset": 0},
+        "filters": {"tags": []},
+        "search": None,
+    }
diff --git tests/test_tutorial/test_query_params/test_tutorial003.py tests/test_tutorial/test_query_params/test_tutorial003.py
index 2951f1adf9529..151abef2cb905 100644
--- tests/test_tutorial/test_query_params/test_tutorial003.py
+++ tests/test_tutorial/test_query_params/test_tutorial003.py
@@ -50,6 +50,32 @@ def test_read_user_item(client: TestClient, path, expected_json):
     assert response.json() == expected_json
 
 
+@pytest.mark.parametrize("short", ["0", "false", "off", "no", "FaLsE"])
+def test_boolean_query_false(client: TestClient, short: str):
+    response = client.get("/items/foo", params={"short": short})
+    assert response.status_code == 200
+    assert response.json() == {
+        "item_id": "foo",
+        "description": "This is an amazing item that has a long description",
+    }
+
+
+@pytest.mark.parametrize("short", ["", "maybe"])
+def test_invalid_boolean_query(client: TestClient, short: str):
+    response = client.get("/items/foo", params={"short": short})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "bool_parsing",
+                "loc": ["query", "short"],
+                "msg": "Input should be a valid boolean, unable to interpret input",
+                "input": short,
+            }
+        ]
+    }
+
+
 def test_openapi_schema(client: TestClient):
     response = client.get("/openapi.json")
     assert response.status_code == 200
diff --git tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py
index 74c10b885d480..bdd6fb271cd7f 100644
--- tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py
+++ tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py
@@ -22,39 +22,34 @@ def get_client(request: pytest.FixtureRequest):
     return client
 
 
-@pytest.mark.xfail(
-    reason="Code example is not valid. See https://github.com/fastapi/fastapi/issues/12419"
-)
 def test_query_params_str_validations_no_query(client: TestClient):
     response = client.get("/items/")
-    assert response.status_code == 200
-    assert response.json() == {  # pragma: no cover
-        "items": [{"item_id": "Foo"}, {"item_id": "Bar"}],
-    }
-
-
-@pytest.mark.xfail(
-    reason="Code example is not valid. See https://github.com/fastapi/fastapi/issues/12419"
-)
-def test_query_params_str_validations_empty_str(client: TestClient):
-    response = client.get("/items/?q=")
-    assert response.status_code == 200
-    assert response.json() == {  # pragma: no cover
-        "items": [{"item_id": "Foo"}, {"item_id": "Bar"}],
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["query", "q"],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
     }
 
 
-def test_query_params_str_validations_q_query(client: TestClient):
-    response = client.get("/items/", params={"q": "query"})
+@pytest.mark.parametrize("q", ["query", "None", "null"])
+def test_query_params_str_validations_q_query(client: TestClient, q: str):
+    response = client.get("/items/", params={"q": q})
     assert response.status_code == 200
     assert response.json() == {
         "items": [{"item_id": "Foo"}, {"item_id": "Bar"}],
-        "q": "query",
+        "q": q,
     }
 
 
-def test_query_params_str_validations_q_short(client: TestClient):
-    response = client.get("/items/", params={"q": "fa"})
+@pytest.mark.parametrize("q", ["", "fa"])
+def test_query_params_str_validations_q_short(client: TestClient, q: str):
+    response = client.get("/items/", params={"q": q})
     assert response.status_code == 422
     assert response.json() == {
         "detail": [
@@ -62,7 +57,7 @@ def test_query_params_str_validations_q_short(client: TestClient):
                 "type": "string_too_short",
                 "loc": ["query", "q"],
                 "msg": "String should have at least 3 characters",
-                "input": "fa",
+                "input": q,
                 "ctx": {"min_length": 3},
             }
         ]
diff --git a/tests/test_upload_file_read_position.py b/tests/test_upload_file_read_position.py
new file mode 100644
index 0000000000000..8e8a8ec592962
--- /dev/null
+++ tests/test_upload_file_read_position.py
@@ -0,0 +1,147 @@
+from io import BytesIO
+from typing import Annotated
+
+import pytest
+from fastapi import Depends, FastAPI, File, UploadFile
+from fastapi.dependencies.utils import _read_file_bytes
+from fastapi.testclient import TestClient
+
+
+@pytest.mark.parametrize(
+    "content",
+    [
+        pytest.param(b"hello", id="memory"),
+        pytest.param(b"x" * (1024 * 1024 + 1), id="disk"),
+    ],
+)
+def test_file_bytes_shared_by_dependency_and_endpoint(content: bytes):
+    app = FastAPI()
+
+    def inspect_file(file: Annotated[bytes, File()]):
+        return file.decode()
+
+    @app.post("/")
+    def upload(
+        inspected: Annotated[str, Depends(inspect_file)],
+        file: Annotated[bytes, File()],
+    ):
+        return {"dependency": inspected, "endpoint": file.decode()}
+
+    client = TestClient(app)
+    response = client.post("/", files={"file": ("hello.txt", content)})
+    assert response.status_code == 200
+    assert response.json() == {
+        "dependency": content.decode(),
+        "endpoint": content.decode(),
+    }
+
+
+def test_file_bytes_list_shared_by_dependency_and_endpoint():
+    app = FastAPI()
+
+    def inspect_files(files: Annotated[list[bytes], File()]):
+        return [file.decode() for file in files]
+
+    @app.post("/")
+    def upload(
+        inspected: Annotated[list[str], Depends(inspect_files)],
+        files: Annotated[list[bytes], File()],
+    ):
+        return {"dependency": inspected, "endpoint": [file.decode() for file in files]}
+
+    client = TestClient(app)
+    response = client.post(
+        "/",
+        files=[
+            ("files", ("one.txt", b"one")),
+            ("files", ("two.txt", b"two")),
+        ],
+    )
+    assert response.status_code == 200
+    assert response.json() == {
+        "dependency": ["one", "two"],
+        "endpoint": ["one", "two"],
+    }
+
+
+def test_bytes_dependency_preserves_upload_file_for_endpoint():
+    app = FastAPI()
+
+    def inspect_file(file: Annotated[bytes, File()]):
+        return file.decode()
+
+    @app.post("/")
+    async def upload(
+        inspected: Annotated[str, Depends(inspect_file)],
+        file: Annotated[UploadFile, File()],
+    ):
+        content = await file.read()
+        second_read = await file.read()
+        return {
+            "dependency": inspected,
+            "endpoint": content.decode(),
+            "second_read": second_read.decode(),
+        }
+
+    client = TestClient(app)
+    response = client.post("/", files={"file": ("hello.txt", b"hello")})
+    assert response.status_code == 200
+    assert response.json() == {
+        "dependency": "hello",
+        "endpoint": "hello",
+        "second_read": "",
+    }
+
+
+def test_automatic_file_read_preserves_nonzero_position():
+    app = FastAPI()
+
+    async def read_prefix(file: Annotated[UploadFile, File()]):
+        return (await file.read(2)).decode()
+
+    def inspect_remainder(
+        prefix: Annotated[str, Depends(read_prefix)],
+        file: Annotated[bytes, File()],
+    ):
+        return {"prefix": prefix, "remainder": file.decode()}
+
+    @app.post("/")
+    async def upload(
+        inspected: Annotated[dict[str, str], Depends(inspect_remainder)],
+        file: Annotated[UploadFile, File()],
+    ):
+        position = file.file.tell()
+        remainder = await file.read()
+        return {
+            "dependency": inspected,
+            "position": position,
+            "remainder": remainder.decode(),
+        }
+
+    client = TestClient(app)
+    response = client.post("/", files={"file": ("hello.txt", b"hello")})
+    assert response.status_code == 200
+    assert response.json() == {
+        "dependency": {"prefix": "he", "remainder": "llo"},
+        "position": 2,
+        "remainder": "llo",
+    }
+
+
+@pytest.mark.anyio
+async def test_failed_automatic_file_read_restores_position(
+    monkeypatch: pytest.MonkeyPatch,
+):
+    with BytesIO(b"hello") as stream:
+        file = UploadFile(file=stream)
+        stream.seek(2)
+
+        async def failing_read(size: int = -1) -> bytes:
+            stream.read(1)
+            raise OSError("read failed")
+
+        monkeypatch.setattr(file, "read", failing_read)
+        with pytest.raises(OSError, match="read failed"):
+            await _read_file_bytes(file)
+
+        assert stream.tell() == 2

Description

Allows multiple Pydantic parameter models per location (query/header/cookie), mixed with scalar params and nested dependencies. Each model validates only its own inputs plus unknown ones. Names declared elsewhere in the dependency graph no longer count as model extras. Also:

  • Path AliasChoices now resolve to whichever choice matches the route's path params.
  • Automatic bytes file conversion no longer consumes a shared UploadFile.
  • Form alias/error-loc handling reworked so errors report the alias actually used.
  • Two repo-maintenance workflows and their scripts deleted.
  • Version bumped to 0.157.0.

Possible Issues

  • Per-request recomputation with overrides: solve_dependencies calls _get_request_param_names whenever dependency_overrides is non-empty. That walks the whole graph and calls get_dependant for each overridden dep on every request, duplicating work solve_dependencies already does. Hot-path cost in test suites and any prod app using overrides. Consider caching keyed on overrides identity/version.
  • Callers outside routing: third-party code calling solve_dependencies with a dependant built by plain get_dependant gets _request_param_names=None. That forces a recompute on every call. Silent perf regression.
  • cast(str, sub_dependant.path): cast does no runtime conversion. A None path reaches get_dependant/get_path_param_names and may raise. Guard or default to "".
  • Unknown inputs fan out to all models:
    • With extra="allow", every model receives every unknown key (test confirms typo lands in both Filters and Pagination).
    • With extra="forbid", the same unknown key yields one 422 error per model, i.e. duplicate errors.
    • Likely surprising; worth documenting or deduplicating.
  • Shared field names across models: OpenAPI emits duplicate (in, name) entries, relying on later dedup (required wins). Conflicting types/constraints between models get silently collapsed to one schema.
  • Memory for large uploads: _read_file_bytes re-reads the whole file for each bytes param sharing the upload. N params → N full copies in memory. file.file.tell() also fails on non-seekable streams.
  • Behavior change for form error locs: model/parameter error loc now reports the selected alias (e.g. "first") instead of the declared alias ("value"/"legacy"). Existing tests were edited to match, so this is a breaking change for clients parsing loc. Needs a release note.
  • Scope creep: removing the label-approved and notify-translations workflows/scripts is unrelated to the feature. It silently drops translation notifications and approval labeling. Split into a separate PR; same for the version bump.
  • Dataclass field _request_param_names: private state on a public dataclass becomes a public __init__ kwarg and shows up in repr/eq. Prefer field(default=None, init=False, repr=False, compare=False).

Security Hotspots

  • fastapi/dependencies/utils.py _request_param_model_to_args: inputs whose names match params declared elsewhere are now exempt from extra="forbid" on a model. Models previously relied on as strict allowlists now accept those keys silently. Low risk, but it changes the semantics apps may depend on for input rejection.
Changes

Changes

  • fastapi/dependencies/utils.py
    • _get_flat_fields_from_params: flattens every non-Json BaseModel param, not just a single one.
    • New _get_request_param_names: collects accepted names per location across the effective graph, honoring overrides; headers are lowercased.
    • request_params_to_args: now loops over fields and delegates models to new _request_param_model_to_args, which skips keys owned by other params (known_param_names).
    • solve_dependencies: computes or propagates _request_param_names.
    • analyze_param: takes path_param_names; copies Path field_info and pins validation_alias to the matching choice.
    • _get_param_aliases: handles plain string aliases with validate_by_name/populate_by_name; AliasPath short-circuit.
    • Forms:
      • _extract_form_body becomes an async generator.
      • New _read_file_bytes restores the file position.
      • request_body_to_args keeps per-field (alias, value) so errors report the selected alias.
  • fastapi/dependencies/models.py: adds Dependant._request_param_names.
  • fastapi/routing.py: precomputes _request_param_names when building the route dependant.
  • fastapi/openapi/utils.py: flattens each param separately; convert_underscores taken per parent model; drops single-header-model special case.
  • fastapi/__init__.py: 0.157.0.
  • .github/workflows/label-approved.yml, notify-translations.yml, scripts/label_approved.py, scripts/notify_translations.py: deleted.
  • docs_src/query_param_models/tutorial003*: new combined-models example.
  • Tests:
    • New: test_multiple_parameter_models.py, test_path_alias_choices.py, test_upload_file_read_position.py.
    • Extended: alias-choice, form/file, and tutorial tests. tutorial006c xfails replaced with actual 422 expectations.
sequenceDiagram
    participant R as APIRoute
    participant U as dependencies.utils
    participant S as solve_dependencies
    participant P as request_params_to_args
    participant M as _request_param_model_to_args
    participant V as Pydantic

    R->>U: _get_request_param_names(dependant)
    U-->>R: {query, header, cookie} name sets
    R->>R: dependant._request_param_names = names
    Note over S: per request
    S->>S: use cached names (recompute if overrides set)
    loop sub-dependants
        S->>S: solve_dependencies(sub, _request_param_names)
    end
    S->>P: query/header/cookie fields + known_param_names
    loop each field
        alt BaseModel (non-Json)
            P->>M: model field, received params, known names
            M->>M: collect own fields + unknown keys (skip names owned elsewhere)
            M->>V: validate model
            V-->>M: value / errors (loc remapped for headers)
            M-->>P: value, errors
        else scalar
            P->>V: validate single alias value
            V-->>P: value / errors
        end
    end
    P-->>S: values, errors
Loading

@onyb
onyb merged commit 612d6a1 into master Nov 3, 2026
7 checks passed
@onyb
onyb deleted the renovate/fastapi-0-x branch November 3, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants