Skip to content

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

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

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

Conversation

@VittoriaLanzo

@VittoriaLanzo VittoriaLanzo commented May 3, 2026

Copy link
Copy Markdown

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

codspeed Bot commented May 3, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 20 untouched benchmarks


Comparing VittoriaLanzo:fix/jsonable-encoder-lazy-allowed-keys (65147b6) with master (afe4112)

Open in CodSpeed

@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

@YuriiMotov YuriiMotov left a comment

Copy link
Copy Markdown
Member

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

@YuriiMotov YuriiMotov left a comment

Copy link
Copy Markdown
Member

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

@VittoriaLanzo

Copy link
Copy Markdown
Author

Hi @YuriiMotov. It's been 2 months since you mentioned passing this to Sebastian for the final review. Is there any update, or is there anything else I should address?

Samielakkad

This comment was marked as spam.

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.

4 participants