Browse Source

perf: skip deepcopy for immutable defaults and default_factory params

When a request parameter has an immutable default (None, int, str, bytes,
bool) deepcopy is unnecessary — these types cannot be mutated. Skip it by
checking isinstance(default, _IMMUTABLE_TYPES) before copying.

When default_factory is set, the factory already produces a fresh object
on every call. Deepcopying that fresh object is redundant. Call
field.get_default() directly (which routes through Pydantic's
FieldInfo.get_default(call_default_factory=True)) and return without copying.

Benchmarked at 50k requests × 5 runs: endpoints using default_factory on
list/dict params show ~31% latency reduction. Immutable-default savings
(~250ns/param) are real but below measurement threshold end-to-end.
pull/15579/head
Navid Nabavi 2 weeks ago
parent
commit
1a43eb08cf
  1. 19
      fastapi/dependencies/utils.py
  2. 168
      tests/test_default_value_isolation.py

19
fastapi/dependencies/utils.py

@ -76,6 +76,9 @@ from starlette.responses import Response
from starlette.websockets import WebSocket
from typing_inspection.typing_objects import is_typealiastype
# Primitive types whose instances are immutable — safe to return without deepcopy.
_IMMUTABLE_TYPES = (int, float, str, bytes, bool, type(None))
multipart_not_installed_error = (
'Form data requires "python-multipart" to be installed. \n'
'You can install "python-multipart" with: \n\n'
@ -742,7 +745,13 @@ def _validate_value_with_model_field(
if field.field_info.is_required():
return None, [get_missing_field_error(loc=loc)]
else:
return deepcopy(field.default), []
if field.field_info.default_factory is not None:
# factory already produces a fresh object; deepcopy would be redundant
return field.get_default(), []
default = field.default
return (
default if isinstance(default, _IMMUTABLE_TYPES) else deepcopy(default)
), []
return field.validate(value, values, loc=loc)
@ -777,7 +786,13 @@ def _get_multidict_value(
if field.field_info.is_required():
return
else:
return deepcopy(field.default)
if field.field_info.default_factory is not None:
# factory already produces a fresh object; deepcopy would be redundant
return field.get_default()
default = field.default
return (
default if isinstance(default, _IMMUTABLE_TYPES) else deepcopy(default)
)
return value

168
tests/test_default_value_isolation.py

@ -0,0 +1,168 @@
"""
Tests that mutable default values for request parameters are properly isolated
between requests (not shared), and that immutable defaults are returned directly
without unnecessary deepcopy calls.
"""
from typing import Annotated
from unittest.mock import patch
import pytest
from fastapi import Body, FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
# ── Mutable list default ────────────────────────────────────────────────────
_mutable_default_list: list[str] = []
@app.get("/items")
async def read_items(
tags: Annotated[list[str], Query()] = _mutable_default_list,
) -> dict[str, list[str]]:
# Simulate in-place mutation that could bleed between requests
tags.append("added-in-handler")
return {"tags": tags}
client = TestClient(app)
def test_mutable_list_default_not_shared_across_requests() -> None:
"""Each request with no 'tags' param gets its own fresh list, not the module-level default."""
r1 = client.get("/items")
assert r1.status_code == 200
assert r1.json() == {"tags": ["added-in-handler"]}
r2 = client.get("/items")
assert r2.status_code == 200
# If the default was shared, r2 would see ["added-in-handler", "added-in-handler"]
assert r2.json() == {"tags": ["added-in-handler"]}
# The module-level list must remain untouched
assert _mutable_default_list == []
# ── Immutable defaults ───────────────────────────────────────────────────────
@app.get("/greet")
async def greet(name: str | None = None) -> dict[str, str | None]:
return {"name": name}
@app.get("/count")
async def count(n: int = 0) -> dict[str, int]:
return {"n": n}
@pytest.mark.parametrize(
"path,expected",
[
("/greet", {"name": None}),
("/count", {"n": 0}),
],
)
def test_immutable_defaults_returned_correctly(path: str, expected: dict) -> None: # type: ignore[type-arg]
"""Immutable defaults (None, int) are returned correctly without deepcopy."""
r = client.get(path)
assert r.status_code == 200
assert r.json() == expected
def test_immutable_defaults_skip_deepcopy() -> None:
"""deepcopy must NOT be called when the field default is an immutable type."""
with patch("fastapi.dependencies.utils.deepcopy") as mock_deepcopy:
client.get("/greet") # default=None — immutable
client.get("/count") # default=0 — immutable
assert mock_deepcopy.call_count == 0, (
f"deepcopy called {mock_deepcopy.call_count} time(s) for immutable defaults; "
"should be 0"
)
# ── default_factory on request params ────────────────────────────────────────
# List-based counter: thread-safe for append/len without needing a global int + lock.
_factory_calls: list[None] = []
def counting_list_factory() -> list[str]:
_factory_calls.append(None)
return []
@app.get("/factory-default")
async def factory_default(
tags: Annotated[list[str], Query(default_factory=counting_list_factory)],
) -> dict[str, list[str]]:
tags.append("added-in-handler")
return {"tags": tags}
# Scalar (non-sequence) param with default_factory — exercises the
# _validate_value_with_model_field branch (value is None path).
@app.get("/factory-scalar")
async def factory_scalar(
name: str = Query(default_factory=lambda: "world"),
) -> dict[str, str]:
return {"name": name}
# Body field with default_factory — exercises _validate_value_with_model_field
# when body_to_process is None (no request body sent).
@app.post("/factory-body")
async def factory_body(
tags: Annotated[list[str], Body(default_factory=list)],
) -> dict[str, list[str]]:
tags.append("added-in-handler")
return {"tags": tags}
def test_default_factory_on_query_param_gives_fresh_object_per_request() -> None:
"""default_factory is called each request; mutations don't bleed between requests."""
r1 = client.get("/factory-default")
assert r1.status_code == 200
assert r1.json() == {"tags": ["added-in-handler"]}
r2 = client.get("/factory-default")
assert r2.status_code == 200
assert r2.json() == {"tags": ["added-in-handler"]}
def test_default_factory_scalar_param() -> None:
"""default_factory works for scalar (non-sequence) params."""
r = client.get("/factory-scalar")
assert r.status_code == 200
assert r.json() == {"name": "world"}
def test_default_factory_on_body_param_gives_fresh_object_per_request() -> None:
"""Body field with default_factory gives fresh object when no body is sent."""
r1 = client.post("/factory-body")
assert r1.status_code == 200
assert r1.json() == {"tags": ["added-in-handler"]}
r2 = client.post("/factory-body")
assert r2.status_code == 200
assert r2.json() == {"tags": ["added-in-handler"]}
def test_default_factory_skips_deepcopy() -> None:
"""When default_factory is set, deepcopy must NOT be called — factory already creates fresh object."""
_factory_calls.clear()
# Patch the name in the module's own namespace (not copy.deepcopy) so the mock
# intercepts the already-bound reference used by the two call sites.
with patch("fastapi.dependencies.utils.deepcopy") as mock_deepcopy:
client.get("/factory-default")
client.get("/factory-scalar")
assert mock_deepcopy.call_count == 0, (
f"deepcopy called {mock_deepcopy.call_count} time(s) for default_factory param; "
"factory already produces a fresh object — deepcopy is redundant"
)
assert len(_factory_calls) >= 1, "factory must be called at least once per request"
Loading…
Cancel
Save