Skip to content

⚡ Skip set(obj.keys()) allocation in jsonable_encoder when include/exclude are not set#15476

Open
VittoriaLanzo wants to merge 4 commits into
fastapi:masterfrom
VittoriaLanzo:fix/jsonable-encoder-lazy-allowed-keys
Open

⚡ Skip set(obj.keys()) allocation in jsonable_encoder when include/exclude are not set#15476
VittoriaLanzo wants to merge 4 commits into
fastapi:masterfrom
VittoriaLanzo:fix/jsonable-encoder-lazy-allowed-keys

Conversation

@VittoriaLanzo
Copy link
Copy Markdown

@VittoriaLanzo VittoriaLanzo commented May 3, 2026

jsonable_encoder builds allowed_keys = set(obj.keys()) unconditionally on every dict branch entry, even when both include and exclude are None . The common case where no filtering is needed. The set is then used only to confirm that every key passes a membership test it would always pass, making the allocation pure overhead. Because jsonable_encoder recurses into nested dict values, the cost multiplies with nesting depth and runs on every dict response in the application.

Solution

Initialise allowed_keys: set[Any] | None = None and wrap the set construction inside if include is not None or exclude is not None:. The loop condition becomes allowed_keys is None or key in allowed_keys. When both parameters are None, no set is built and no membership test runs. When either is set, the existing intersection/subtraction logic is unchanged.

Performance

Measured locally with timeit (20 rounds × 300 iterations, mean reported). These are wall-time numbers on a single machine

Payload Before After Δ
Small dict (3 keys, no nesting) 5.37 µs 4.93 µs −8.2%
Large dict (300 items, nested meta) 12,158 µs 11,431 µs −6.0%

The wall-time gain is proportionally larger on shallow dicts, where the set allocation is a greater fraction of total work, and accumulates across repeated calls on dicts of any size.

Testing

Added seven tests to tests/test_jsonable_encoder.py covering: all-keys returned when include and exclude are both None, include filtering, exclude filtering, combined include+exclude, nested dict with no filter, empty include returns empty, empty exclude returns all keys. All pre-existing tests pass unchanged.

Files modified

  • fastapi/encoders.py
  • tests/test_jsonable_encoder.py
Agentic Pipeline diagram
flowchart TD
    %% Node Definitions
    START([START]) --> PE

    PE[F1.extractor<br/>confirms bug on main]
    PE -.->|ALREADY_IMPLEMENTED| HALT_IMPL([HALT: already fixed])
    PE -->|PATTERN_EXTRACTED| IW

    IW[F1.issue-writer<br/>writes issue body]
    IW -.->|DUPLICATE_EXISTS| HALT_DUP([HALT: duplicate])
    IW -->|ISSUE_READY| FILE

    FILE{{HITL check}}
    FILE --> CA

    CA[F1.author<br/>implement + tests · single pass]
    CA --> LOOP

    subgraph LOOP [Review loop · max 5 rounds]
        direction TB
        SR[F1.swe-reviewer<br/>blind to cold findings]
        CR[F1.cold-reviewer<br/>blind to swe findings]
        SE[F1.engineer<br/>fixes against both reports]

        SR --> SE
        CR --> SE
        SE -->|any_valid_findings=true · round N+1| SR
        SE -->|any_valid_findings=true · round N+1| CR
    end

    LOOP -->|any_valid_findings=false| CG
    LOOP -.->|round 5 · still findings| HITL_WARN([HITL<br/>review])

    CG{F1.ci-gate}
    CG -->|RED| CA
    CG -->|GREEN| PRW

    PRW[F1.pr-writer<br/>references issue_number]
    PRW --> PRA

    PRA{F1.pr-adversary<br/>description-only loop}
    PRA -->|revisions| PRW
    PRA -->|PR_READY| LA

    LA{F1.license-auditor}
    LA -.->|BLOCK| HALT_COMP([HALT: compliance])
    LA -->|PASS| HITL

    HITL{{HITL final rewiev }}
    HITL -.->|NO| CA
    HITL -->|YES| PUSH([git push · open PR])

    %% Styling & Palette
    classDef default fill:#f9f9fb,stroke:#333,stroke-width:1px,color:#1a1a1a;
    classDef action fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
    classDef critical fill:#fff4e5,stroke:#ff9800,stroke-width:2px;
    classDef halt fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5;
    classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;

    class PE,IW,CA,SR,CR,SE,PRW,PRA,LA action;
    class FILE,HITL critical;
    class HALT_IMPL,HALT_DUP,HALT_COMP halt;
    class PUSH success;
    
    style LOOP fill:#f1f3f4,stroke:#5f6368,stroke-width:2px,stroke-dasharray: 5 5
    style HITL_WARN fill:#fffde7,stroke:#fbc02d,stroke-width:2px

Loading

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 3, 2026

Merging this PR will not alter performance

✅ 20 untouched benchmarks


Comparing VittoriaLanzo:fix/jsonable-encoder-lazy-allowed-keys (9ec5654) with master (fb74293)1

Open in CodSpeed

Footnotes

  1. No successful run was found on master (622b635) during the generation of this report, so fb74293 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@VittoriaLanzo VittoriaLanzo changed the title Skip set(obj.keys()) allocation in jsonable_encoder when include/exclude are not set ⚡ Skip set(obj.keys()) allocation in jsonable_encoder when include/exclude are not set May 3, 2026
@VittoriaLanzo VittoriaLanzo marked this pull request as ready for review May 3, 2026 01:43
Copy link
Copy Markdown
Member

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

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

@VittoriaLanzo, thanks for your interest and efforts!

I agree this would improve the performance in some specific cases, but from what I can see, Sebastian tends not to touch jsonable_encoder if it's not necessary.

Before I can pass this to Sebastian for review, could you please:

  • remove scripts/bench_jsonable_encoder.py‎ (you can share it in the comments, show how you tested the performance impact)
  • and address my comment regarding tests in tests/test_jsonable_encoder.py

?

Comment thread tests/test_jsonable_encoder.py Outdated
@VittoriaLanzo
Copy link
Copy Markdown
Author

The speedup comes from two costs eliminated on every no-filter call (include=None, exclude=None):

  1. Heap allocationset(obj.keys()) asks the allocator for a new set object and a backing hash table on every call, regardless of whether filtering is needed.
  2. Key hashing — each key must be hashed and inserted into that set (O(n) in the key count), even though the result is only used to confirm that every key of obj is already present in set(obj.keys()) — which is trivially always true.

On the hot path, the replacement is a single pointer comparison (allowed_keys is None): no allocation, no hashing. The gain is proportionally larger on shallow dicts because the allocation is a fixed overhead that dominates more of the total call time; on large nested payloads it accumulates across every recursively encoded dict.

Benchmark script and methodology

Measured with timeit (20 rounds × 300 iterations, mean reported) against two worktrees pinned to known commits so the baseline is reproducible after this merges:

git fetch origin master
git worktree add /tmp/fastapi-before 622b6356  # fastapi/fastapi master 2026-05-06
git worktree add /tmp/fastapi-after fix/jsonable-encoder-lazy-allowed-keys

cp bench_jsonable_encoder.py /tmp/fastapi-before/

uv run --directory /tmp/fastapi-before python bench_jsonable_encoder.py
uv run --directory /tmp/fastapi-after python bench_jsonable_encoder.py

git worktree remove /tmp/fastapi-before
git worktree remove /tmp/fastapi-after
import statistics, timeit
from typing import Any
from fastapi.encoders import jsonable_encoder

LARGE_ITEMS = [
    {"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 = {
    "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, ITERS = 20, 300

def bench(payload):
    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)

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:>8.2f} µs  ±{sd:.2f}")
Payload Before After Δ
Small dict (3 keys) 5.37 µs 4.93 µs −8.2%
Large dict (300 items, nested) 12,158 µs 11,431 µs −6.0%

Python 3.12, FastAPI 0.136.1.

@VittoriaLanzo VittoriaLanzo force-pushed the fix/jsonable-encoder-lazy-allowed-keys branch from f0a4a2c to 9ec5654 Compare May 6, 2026 13:06
Copy link
Copy Markdown
Member

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

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

LGTM!

@VittoriaLanzo, thanks!
Passing this to Sebastian for the final review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants