From e9cdebb73e38e4447160f28f6d76bfd092ed1aa4 Mon Sep 17 00:00:00 2001 From: Vittoria Date: Sun, 3 May 2026 03:00:00 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9A=A1=20Skip=20set(obj.keys())=20alloca?= =?UTF-8?q?tion=20in=20jsonable=5Fencoder=20when=20include/exclude=20are?= =?UTF-8?q?=20not=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 14 +++++++------ tests/test_jsonable_encoder.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 43f24101b6..329f96ace2 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -280,11 +280,13 @@ def jsonable_encoder( return None if isinstance(obj, dict): encoded_dict = {} - allowed_keys = set(obj.keys()) - if include is not None: - allowed_keys &= set(include) - if exclude is not None: - allowed_keys -= set(exclude) + allowed_keys: set[Any] | None = None + if include is not None or exclude is not None: + allowed_keys = set(obj.keys()) + if include is not None: + allowed_keys &= set(include) + if exclude is not None: + allowed_keys -= set(exclude) for key, value in obj.items(): if ( ( @@ -293,7 +295,7 @@ def jsonable_encoder( or (not key.startswith("_sa")) ) and (value is not None or not exclude_none) - and key in allowed_keys + and (allowed_keys is None or key in allowed_keys) ): encoded_key = jsonable_encoder( key, diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index c23a9e5d79..dde6fe3f8e 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -329,3 +329,41 @@ def test_encode_color(module_path): data = {"color": Color("blue")} assert jsonable_encoder(data) == {"color": "blue"} + + +def test_dict_no_filter_returns_all_keys(): + result = jsonable_encoder({"a": 1, "b": 2, "c": 3}) + assert result == {"a": 1, "b": 2, "c": 3} + + +def test_dict_include_filters_correctly(): + result = jsonable_encoder({"a": 1, "b": 2}, include={"a"}) + assert result == {"a": 1} + + +def test_dict_exclude_filters_correctly(): + result = jsonable_encoder({"a": 1, "b": 2}, exclude={"b"}) + assert result == {"a": 1} + + +def test_dict_empty_include_returns_empty(): + result = jsonable_encoder({"a": 1}, include=set()) + assert result == {} + + +def test_dict_empty_exclude_returns_all(): + result = jsonable_encoder({"a": 1}, exclude=set()) + assert result == {"a": 1} + + +def test_dict_both_include_and_exclude(): + result = jsonable_encoder( + {"a": 1, "b": 2, "c": 3}, include={"a", "b"}, exclude={"b"} + ) + assert result == {"a": 1} + + +def test_encode_nested_dict(): + nested = {"level1": {"level2": {"level3": 42}}} + result = jsonable_encoder(nested) + assert result == nested From fecb50f22101f3bb12826d238f8f11ac424a6474 Mon Sep 17 00:00:00 2001 From: Vittoria Date: Sun, 3 May 2026 14:18:28 +0200 Subject: [PATCH 2/4] add benchmark script for jsonable_encoder lazy allocation fix --- scripts/bench_jsonable_encoder.py | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 scripts/bench_jsonable_encoder.py diff --git a/scripts/bench_jsonable_encoder.py b/scripts/bench_jsonable_encoder.py new file mode 100644 index 0000000000..f4eadc931d --- /dev/null +++ b/scripts/bench_jsonable_encoder.py @@ -0,0 +1,69 @@ +""" +Benchmark: lazy allowed_keys allocation in jsonable_encoder. + +Usage: + uv run python scripts/bench_jsonable_encoder.py + +Run against both branches to compare: + git stash # unpatched + uv run python scripts/bench_jsonable_encoder.py + git stash pop # patched + uv run python scripts/bench_jsonable_encoder.py + +Reference results (20 rounds x 300 iters, mean, Python 3.12, FastAPI 0.136.1): + + Payload mean µs/call stdev + ------------------------------------------------------------ + small dict (3 keys) before: 5.37µs 0.95 + after: 4.93µs 0.65 -8.2% + large dict (300 items, nested) + before: 12,158.80µs 557.40 + after: 11,431.07µs 506.29 -6.0% +""" + +import statistics +import timeit +from typing import Any + +from fastapi.encoders import jsonable_encoder + +LARGE_ITEMS: list[dict[str, Any]] = [ + { + "id": i, + "name": f"item-{i}", + "values": list(range(25)), + "meta": {"active": True, "group": i % 10, "tag": f"t{i % 5}"}, + } + for i in range(300) +] +LARGE_METADATA: dict[str, Any] = { + "source": "benchmark", + "version": 1, + "flags": {"a": True, "b": False, "c": True}, + "notes": ["x" * 50, "y" * 50, "z" * 50], +} +LARGE_PAYLOAD: dict[str, Any] = {"items": LARGE_ITEMS, "metadata": LARGE_METADATA} +SMALL_PAYLOAD: dict[str, Any] = {"name": "foo", "value": 123} + +ROUNDS = 20 +ITERS = 300 + + +def bench(payload: dict[str, Any]) -> tuple[float, float]: + times = [] + for _ in range(ROUNDS): + t = timeit.timeit(lambda: jsonable_encoder(payload), number=ITERS) + times.append(t / ITERS * 1e6) + return statistics.mean(times), statistics.stdev(times) + + +if __name__ == "__main__": + print(f"{'Payload':<35} {'mean µs/call':>14} {'stdev':>8}") + print("-" * 60) + for label, payload in [ + ("small dict (3 keys)", SMALL_PAYLOAD), + ("large dict (300 items, nested)", LARGE_PAYLOAD), + ]: + mean, sd = bench(payload) + print(f"{label:<35} {mean:>12.2f}µs {sd:>6.2f}") + print(f"\n({ROUNDS} rounds x {ITERS} iters each)") From 4d688e726ac2b70ee205b2c9b7854ccbe69a171e Mon Sep 17 00:00:00 2001 From: Vittoria Date: Wed, 6 May 2026 14:45:50 +0200 Subject: [PATCH 3/4] tests: fold jsonable_encoder dict filter cases into test_encode_dict --- tests/test_jsonable_encoder.py | 41 +++------------------------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index dde6fe3f8e..a542c02c65 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -82,6 +82,9 @@ def test_encode_dict(): "name": "Firulais", "owner": {"name": "Foo"}, } + assert jsonable_encoder(pet, include={"name", "owner"}, exclude={"owner"}) == { + "name": "Firulais" + } def test_encode_dict_include_exclude_list(): @@ -329,41 +332,3 @@ def test_encode_color(module_path): data = {"color": Color("blue")} assert jsonable_encoder(data) == {"color": "blue"} - - -def test_dict_no_filter_returns_all_keys(): - result = jsonable_encoder({"a": 1, "b": 2, "c": 3}) - assert result == {"a": 1, "b": 2, "c": 3} - - -def test_dict_include_filters_correctly(): - result = jsonable_encoder({"a": 1, "b": 2}, include={"a"}) - assert result == {"a": 1} - - -def test_dict_exclude_filters_correctly(): - result = jsonable_encoder({"a": 1, "b": 2}, exclude={"b"}) - assert result == {"a": 1} - - -def test_dict_empty_include_returns_empty(): - result = jsonable_encoder({"a": 1}, include=set()) - assert result == {} - - -def test_dict_empty_exclude_returns_all(): - result = jsonable_encoder({"a": 1}, exclude=set()) - assert result == {"a": 1} - - -def test_dict_both_include_and_exclude(): - result = jsonable_encoder( - {"a": 1, "b": 2, "c": 3}, include={"a", "b"}, exclude={"b"} - ) - assert result == {"a": 1} - - -def test_encode_nested_dict(): - nested = {"level1": {"level2": {"level3": 42}}} - result = jsonable_encoder(nested) - assert result == nested From 9ec5654d214295aabec3b799e7a79a7eafe696ce Mon Sep 17 00:00:00 2001 From: Vittoria Lanzo Date: Wed, 6 May 2026 15:02:07 +0200 Subject: [PATCH 4/4] Delete scripts/bench_jsonable_encoder.py --- scripts/bench_jsonable_encoder.py | 69 ------------------------------- 1 file changed, 69 deletions(-) delete mode 100644 scripts/bench_jsonable_encoder.py diff --git a/scripts/bench_jsonable_encoder.py b/scripts/bench_jsonable_encoder.py deleted file mode 100644 index f4eadc931d..0000000000 --- a/scripts/bench_jsonable_encoder.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Benchmark: lazy allowed_keys allocation in jsonable_encoder. - -Usage: - uv run python scripts/bench_jsonable_encoder.py - -Run against both branches to compare: - git stash # unpatched - uv run python scripts/bench_jsonable_encoder.py - git stash pop # patched - uv run python scripts/bench_jsonable_encoder.py - -Reference results (20 rounds x 300 iters, mean, Python 3.12, FastAPI 0.136.1): - - Payload mean µs/call stdev - ------------------------------------------------------------ - small dict (3 keys) before: 5.37µs 0.95 - after: 4.93µs 0.65 -8.2% - large dict (300 items, nested) - before: 12,158.80µs 557.40 - after: 11,431.07µs 506.29 -6.0% -""" - -import statistics -import timeit -from typing import Any - -from fastapi.encoders import jsonable_encoder - -LARGE_ITEMS: list[dict[str, Any]] = [ - { - "id": i, - "name": f"item-{i}", - "values": list(range(25)), - "meta": {"active": True, "group": i % 10, "tag": f"t{i % 5}"}, - } - for i in range(300) -] -LARGE_METADATA: dict[str, Any] = { - "source": "benchmark", - "version": 1, - "flags": {"a": True, "b": False, "c": True}, - "notes": ["x" * 50, "y" * 50, "z" * 50], -} -LARGE_PAYLOAD: dict[str, Any] = {"items": LARGE_ITEMS, "metadata": LARGE_METADATA} -SMALL_PAYLOAD: dict[str, Any] = {"name": "foo", "value": 123} - -ROUNDS = 20 -ITERS = 300 - - -def bench(payload: dict[str, Any]) -> tuple[float, float]: - times = [] - for _ in range(ROUNDS): - t = timeit.timeit(lambda: jsonable_encoder(payload), number=ITERS) - times.append(t / ITERS * 1e6) - return statistics.mean(times), statistics.stdev(times) - - -if __name__ == "__main__": - print(f"{'Payload':<35} {'mean µs/call':>14} {'stdev':>8}") - print("-" * 60) - for label, payload in [ - ("small dict (3 keys)", SMALL_PAYLOAD), - ("large dict (300 items, nested)", LARGE_PAYLOAD), - ]: - mean, sd = bench(payload) - print(f"{label:<35} {mean:>12.2f}µs {sd:>6.2f}") - print(f"\n({ROUNDS} rounds x {ITERS} iters each)")