From 311e14b3dc032e2178dba3b2e07c40e916b525d8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 9 Jul 2026 19:20:19 +0530 Subject: [PATCH 1/7] perf: optimize jsonable_encoder by skipping redundant recursive calls for Pydantic models and add benchmarking and performance tests --- fastapi/encoders.py | 12 +++---- tests/test_encoder_performance.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 tests/test_encoder_performance.py diff --git a/fastapi/encoders.py b/fastapi/encoders.py index c9f882d2b..59d788c89 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -250,12 +250,12 @@ def jsonable_encoder( exclude_none=exclude_none, exclude_defaults=exclude_defaults, ) - return jsonable_encoder( - obj_dict, - exclude_none=exclude_none, - exclude_defaults=exclude_defaults, - sqlalchemy_safe=sqlalchemy_safe, - ) + if not sqlalchemy_safe: + return obj_dict + return { + k: v for k, v in obj_dict.items() + if not (isinstance(k, str) and k.startswith("_sa")) + } if dataclasses.is_dataclass(obj): assert not isinstance(obj, type) obj_dict = dataclasses.asdict(obj) diff --git a/tests/test_encoder_performance.py b/tests/test_encoder_performance.py new file mode 100644 index 000000000..86f5bc00c --- /dev/null +++ b/tests/test_encoder_performance.py @@ -0,0 +1,52 @@ +from typing import List, Optional +from pydantic import BaseModel +from fastapi.encoders import jsonable_encoder + + +class SubModel(BaseModel): + name: str + value: int = 42 + + +class MainModel(BaseModel): + id: int + title: str + sub: SubModel + items: List[SubModel] + maybe: Optional[str] = None + _sa_instance_state: str = "should-be-removed" + + +def test_basemodel_serialization_correctness(): + sub = SubModel(name="test") + model = MainModel( + id=1, + title="hello", + sub=sub, + items=[sub, SubModel(name="another", value=10)], + ) + + # 1. Standard serialization + encoded = jsonable_encoder(model) + assert encoded == { + "id": 1, + "title": "hello", + "sub": {"name": "test", "value": 42}, + "items": [ + {"name": "test", "value": 42}, + {"name": "another", "value": 10}, + ], + "maybe": None, + } + + # 2. Exclude none + encoded_exclude_none = jsonable_encoder(model, exclude_none=True) + assert "maybe" not in encoded_exclude_none + + # 3. Include and Exclude parameter filtering + encoded_filtered = jsonable_encoder(model, include={"id", "title"}) + assert encoded_filtered == {"id": 1, "title": "hello"} + + encoded_excluded = jsonable_encoder(model, exclude={"sub", "items"}) + assert "sub" not in encoded_excluded + assert "items" not in encoded_excluded From f5686c7fabe1eb75c4e04d76c85f4b01fcd1310c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:56:19 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 3 ++- tests/test_encoder_performance.py | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 59d788c89..d660d4642 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -253,7 +253,8 @@ def jsonable_encoder( if not sqlalchemy_safe: return obj_dict return { - k: v for k, v in obj_dict.items() + k: v + for k, v in obj_dict.items() if not (isinstance(k, str) and k.startswith("_sa")) } if dataclasses.is_dataclass(obj): diff --git a/tests/test_encoder_performance.py b/tests/test_encoder_performance.py index 86f5bc00c..aba3b7805 100644 --- a/tests/test_encoder_performance.py +++ b/tests/test_encoder_performance.py @@ -1,6 +1,5 @@ -from typing import List, Optional -from pydantic import BaseModel from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel class SubModel(BaseModel): @@ -12,8 +11,8 @@ class MainModel(BaseModel): id: int title: str sub: SubModel - items: List[SubModel] - maybe: Optional[str] = None + items: list[SubModel] + maybe: str | None = None _sa_instance_state: str = "should-be-removed" From 7742bf883ed11da9a39303182bc6bebc06d6d65a Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 9 Jul 2026 19:34:37 +0530 Subject: [PATCH 3/7] perf: optimize jsonable_encoder by short-circuiting Pydantic model serialization --- benchmark_encoder.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ fastapi/encoders.py | 17 +++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 benchmark_encoder.py diff --git a/benchmark_encoder.py b/benchmark_encoder.py new file mode 100644 index 000000000..c46ea3f1a --- /dev/null +++ b/benchmark_encoder.py @@ -0,0 +1,60 @@ +import time +from typing import List, Optional +from pydantic import BaseModel +from fastapi.encoders import jsonable_encoder + + +class SubModel(BaseModel): + name: str + value: int = 42 + + +class MainModel(BaseModel): + id: int + title: str + sub: SubModel + items: List[SubModel] + maybe: Optional[str] = None + + +def run_benchmark(): + sub = SubModel(name="test") + model = MainModel( + id=1, + title="hello", + sub=sub, + items=[sub] * 50, # 50 items to have a decent dictionary size + ) + + iterations = 20000 + print(f"Benchmarking jsonable_encoder over {iterations} iterations...") + + # 1. Optimized Path (Direct return) + # Warmup + for _ in range(100): + jsonable_encoder(model) + + start_time = time.perf_counter() + for _ in range(iterations): + jsonable_encoder(model) + optimized_time = time.perf_counter() - start_time + + # 2. Original Path (Double serialization via model_dump + recursive dict encoding) + # Warmup + for _ in range(100): + jsonable_encoder(model.model_dump(mode="json")) + + start_time = time.perf_counter() + for _ in range(iterations): + # We simulate the exact old logic: model_dump(mode="json") followed by recursive jsonable_encoder + obj_dict = model.model_dump(mode="json") + jsonable_encoder(obj_dict) + original_time = time.perf_counter() - start_time + + print(f"Original Code Path: {original_time:.4f} seconds") + print(f"Optimized Code Path: {optimized_time:.4f} seconds") + print(f"Speedup: {original_time / optimized_time:.2f}x") + + +if __name__ == "__main__": + run_benchmark() diff --git a/fastapi/encoders.py b/fastapi/encoders.py index d660d4642..06de643d4 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -241,6 +241,23 @@ def jsonable_encoder( if exclude is not None and not isinstance(exclude, (set, dict)): exclude = set(exclude) # type: ignore[assignment] # ty: ignore[invalid-assignment] if isinstance(obj, BaseModel): + if not exclude_none and not exclude_defaults: + obj_dict = obj.model_dump( + mode="json", + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + ) + if not sqlalchemy_safe: + return obj_dict + return { + k: v for k, v in obj_dict.items() + if not (isinstance(k, str) and k.startswith("_sa")) + } + # Fallback to recursive call for recursive exclude_none/exclude_defaults cleanup obj_dict = obj.model_dump( mode="json", include=include, From 0edf6ad5138406593b8e41793c67efa95ac5308d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:07:57 +0000 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- benchmark_encoder.py | 8 ++++---- fastapi/encoders.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/benchmark_encoder.py b/benchmark_encoder.py index c46ea3f1a..c50e75fa8 100644 --- a/benchmark_encoder.py +++ b/benchmark_encoder.py @@ -1,7 +1,7 @@ import time -from typing import List, Optional -from pydantic import BaseModel + from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel class SubModel(BaseModel): @@ -13,8 +13,8 @@ class MainModel(BaseModel): id: int title: str sub: SubModel - items: List[SubModel] - maybe: Optional[str] = None + items: list[SubModel] + maybe: str | None = None def run_benchmark(): diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 06de643d4..fab66b6d6 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -254,7 +254,8 @@ def jsonable_encoder( if not sqlalchemy_safe: return obj_dict return { - k: v for k, v in obj_dict.items() + k: v + for k, v in obj_dict.items() if not (isinstance(k, str) and k.startswith("_sa")) } # Fallback to recursive call for recursive exclude_none/exclude_defaults cleanup From 5fbb8556ce643c3d456218ecf0dc74bce1c34527 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 9 Jul 2026 19:42:13 +0530 Subject: [PATCH 5/7] refactor: simplify SQLAlchemy instance handling in jsonable_encoder by delegating to recursive calls --- fastapi/encoders.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index fab66b6d6..741bcf7fe 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -268,13 +268,12 @@ def jsonable_encoder( exclude_none=exclude_none, exclude_defaults=exclude_defaults, ) - if not sqlalchemy_safe: - return obj_dict - return { - k: v - for k, v in obj_dict.items() - if not (isinstance(k, str) and k.startswith("_sa")) - } + return jsonable_encoder( + obj_dict, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + sqlalchemy_safe=sqlalchemy_safe, + ) if dataclasses.is_dataclass(obj): assert not isinstance(obj, type) obj_dict = dataclasses.asdict(obj) From df60a111dc50aabaf0e7aaf4d7b53a39cc2c79b0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 10 Jul 2026 21:59:22 +0530 Subject: [PATCH 6/7] test: add coverage for sqlalchemy_safe parameter in jsonable_encoder for models, dicts, and dataclasses --- tests/test_jsonable_encoder.py | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 8f8bd3fcb..b1e73756f 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -329,3 +329,62 @@ def test_encode_color(module_path): data = {"color": Color("blue")} assert jsonable_encoder(data) == {"color": "blue"} + + +def test_jsonable_encoder_sqlalchemy_safe_base_model(): + class Model(BaseModel): + sa_key: str = Field(alias="_sa_key") + normal_key: str + + model = Model(_sa_key="foo", normal_key="bar") + + # Test case 1: sqlalchemy_safe=True (default) and no exclude_none/exclude_defaults + assert jsonable_encoder(model, by_alias=True) == {"normal_key": "bar"} + + # Test case 2: sqlalchemy_safe=False and no exclude_none/exclude_defaults + # This hits: "if not sqlalchemy_safe: return obj_dict" + assert jsonable_encoder(model, by_alias=True, sqlalchemy_safe=False) == { + "_sa_key": "foo", + "normal_key": "bar", + } + + # Test case 3: sqlalchemy_safe=True (default) and exclude_none=True + assert jsonable_encoder(model, by_alias=True, exclude_none=True) == {"normal_key": "bar"} + + # Test case 4: sqlalchemy_safe=False and exclude_none=True + assert jsonable_encoder(model, by_alias=True, exclude_none=True, sqlalchemy_safe=False) == { + "_sa_key": "foo", + "normal_key": "bar", + } + + +def test_jsonable_encoder_sqlalchemy_safe_dict(): + data = {"_sa_key": "foo", "normal_key": "bar"} + + # Test case 5: dict with sqlalchemy_safe=True (default) + assert jsonable_encoder(data) == {"normal_key": "bar"} + + # Test case 6: dict with sqlalchemy_safe=False + assert jsonable_encoder(data, sqlalchemy_safe=False) == { + "_sa_key": "foo", + "normal_key": "bar", + } + + +def test_jsonable_encoder_sqlalchemy_safe_dataclass(): + @dataclass + class DataclassItem: + normal_key: str + _sa_key: str + + item = DataclassItem(normal_key="bar", _sa_key="foo") + + # Test case 7: dataclass with sqlalchemy_safe=True (default) + assert jsonable_encoder(item) == {"normal_key": "bar"} + + # Test case 8: dataclass with sqlalchemy_safe=False + assert jsonable_encoder(item, sqlalchemy_safe=False) == { + "_sa_key": "foo", + "normal_key": "bar", + } + From c9b869ed388f91fb0dc155f6e78d983583ac62ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:30:25 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_jsonable_encoder.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index b1e73756f..d037acf7b 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -349,10 +349,14 @@ def test_jsonable_encoder_sqlalchemy_safe_base_model(): } # Test case 3: sqlalchemy_safe=True (default) and exclude_none=True - assert jsonable_encoder(model, by_alias=True, exclude_none=True) == {"normal_key": "bar"} + assert jsonable_encoder(model, by_alias=True, exclude_none=True) == { + "normal_key": "bar" + } # Test case 4: sqlalchemy_safe=False and exclude_none=True - assert jsonable_encoder(model, by_alias=True, exclude_none=True, sqlalchemy_safe=False) == { + assert jsonable_encoder( + model, by_alias=True, exclude_none=True, sqlalchemy_safe=False + ) == { "_sa_key": "foo", "normal_key": "bar", } @@ -387,4 +391,3 @@ def test_jsonable_encoder_sqlalchemy_safe_dataclass(): "_sa_key": "foo", "normal_key": "bar", } -