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,