Skip to content
All writing

25 September 2026, 3 min read

By

A One-Character Fix in AWS Powertools: Identity Checks and list[Item]

A release of Powertools for AWS Lambda started crashing OpenAPI schema generation for any route whose response model was list[Item]. The cause was a deep copy and an identity check that could never match. Here is how I tracked it down and why the fix was one character.


By Tanbir Hossain Ramim. Pull request: aws-powertools/powertools-lambda-python#8453, fixing issue #8450.

Powertools for AWS Lambda (Python) has an event handler that builds an OpenAPI schema from your routes. You can describe a custom response like this:

@app.get(
    "/items",
    responses={200: {"description": "List of items",
                     "content": {"application/json": {"model": list[Item]}}}},
)
def list_items() -> list[Item]:
    return []

Up to version 3.24.0 that produced an array schema for the response. From 3.25.0 on, calling get_openapi_schema() raised an unhandled StopIteration. That also took down everything built on the schema: get_openapi_json_schema(), enable_swagger() and request validation setup. Routes whose model was a plain Pydantic class kept working. Only generic aliases like list[Item] and dict[str, Item] broke.

Reading the stack trace backwards

A StopIteration escaping into user code almost always means someone called next() on an iterator that turned out to be empty. The trace ended in _resolve_response_payload, and the call was exactly that:

return_field = next(
    filter(
        lambda model: model.type_ is model_payload_typed["model"],
        dependant.response_extra_models,
    ),
)

This looks up the field that was registered for the response model. The code assumes the model it is holding and the model that was registered earlier are the same object, and it checks that with is. For list[Item] the filter found nothing, so next() raised.

So the registered field existed, but the identity check said it was a different object. The question was what had made a copy.

The deep copy

A few frames up, the custom response was passed through copy.deepcopy before its model was resolved. That change arrived in 3.25.0, which matched the version where the bug started. A deep copy of a response dictionary is a reasonable thing to do. It keeps the builder from mutating the user's route definition. But it changes what is sees:

>>> import copy
>>> class Item: ...
>>> copy.deepcopy(Item) is Item
True
>>> alias = list[Item]
>>> copy.deepcopy(alias) is alias
False
>>> copy.deepcopy(alias) == alias
True

deepcopy treats classes as atomic and returns the same object. A generic alias like list[Item] is an ordinary object of type types.GenericAlias, so deepcopy builds a new one. The new alias is equal to the old one, but it is not the same object. Every plain model still passed the identity check by accident, and every generic alias failed it.

The fix

The comparison should have been about equality all along:

lambda model: model.type_ == model_payload_typed["model"],

For classes nothing changes, because a class compares equal only to itself. For generic aliases, == compares the origin and the arguments, so list[Item] == list[Item] is true even across copies. One character, is to ==, and the lookup finds its field again.

I wanted the test to prove the actual failure, not just the happy path. So it registers two routes, one returning list[Item] and one returning dict[str, Item], and asserts on the schema that comes out: an array whose items point at #/components/schemas/Item, and an object whose additionalProperties point at the same component. Before the change the test fails with the same StopIteration from the issue. After it, both schemas come out as they did in 3.24.0. I ran the event handler functional tests, ruff and mypy before opening the pull request.

The maintainers merged it the next day, and it shipped in v3.35.0 on 15 September 2026.

What I took from it

Identity checks are a statement about lifetimes. is says "this is the object I stored earlier". That is only true if nothing between storing and looking up can make a copy. Code tends to grow copies over time, for good reasons like the one here. Unless you really mean the same instance, compare with ==.

Start from the version boundary. The issue said the bug started in 3.25.0. Reading what changed in that release pointed straight at the deep copy, which was much faster than stepping through the builder.

The smallest diff still needs the full explanation. A one-character change is easy to merge but easy to doubt. The pull request explained why == is safe for plain classes as well as aliases. The test covered both list and dict, so a reviewer did not have to take my word for it.

If you want to try a first contribution like this one, recent bug reports with a clear version boundary are a good place to look. I keep a list of open, unclaimed ones at Open Source Radar, and I wrote up how I pick them in How to Find Good First Issues That Are Still Open in 2026.