Breakwater / Guides
Upgrading stripe-python to v15 or v16: what breaks and how to fix it
In stripe-python v15, Stripe objects stopped being Python dicts. Attribute access and obj["key"] still work, but .get(), .keys(), .items(), .values(), dict(obj), looping over keys and json.dumps(obj) now fail at runtime. Decimal fields became decimal.Decimal, and to_dict_recursive() and stripe_id are gone. v16, released October 1, 2026, keeps all of these and adds three smaller changes.
Want to check a file in seconds? Paste it into the free stripe-python v15 checker. It runs in your browser.
None of this fails at import. It fails when a webhook arrives, often in code whose tests still pass. Below you'll find every break with its exact error and fix, a test that actually catches it, and a checklist for the upgrade.
Every v15 break, with its error and fix
| Code that breaks | What happens on v15 | Fix |
|---|---|---|
session.get("customer") | AttributeError: 'get' is a dict method | getattr(session, "customer", None) or session.to_dict().get("customer") |
session.metadata.get("plan") | AttributeError, same message. metadata is a Stripe object too | session.metadata.to_dict().get("plan") |
obj.keys(), obj.values(), obj.items() | AttributeError, same message | Call them on obj.to_dict() |
dict(obj), for key in obj | TypeError: not iterable or a mapping | obj.to_dict() |
json.dumps(obj) | TypeError: not JSON serializable | json.dumps(obj.to_dict(for_json=True)) or str(obj) |
isinstance(obj, dict) | Returns False instead of True, with no error | Check for stripe.StripeObject, or convert with to_dict() first |
obj.to_dict_recursive() | AttributeError: to_dict_recursive | obj.to_dict(), which now recurses |
obj.stripe_id | AttributeError: stripe_id | obj.id |
price.unit_amount_decimal == "9.99" | Returns False, with no error. The field is now a Decimal | Compare with Decimal("9.99"), and pass Decimal values in request params |
json.dumps(price.to_dict()) | TypeError: Object of type Decimal is not JSON serializable | price.to_dict(for_json=True) |
subscription.items() as the dict method | subscription.items is now the subscription's own items list | subscription.items.data for the items; subscription.to_dict().items() for key-value pairs |
Still working on v15 and v16: attribute access (event.data.object.customer), brackets (event["data"]["object"]), "key" in obj, assigning fields, printing, and looping over list objects such as for line in invoice.lines.
v15 also requires Python 3.9 or newer.
The error messages you'll see
These are the exact messages from stripe 15.6.1 and 16.0.0. The class name changes with the object: Event, Customer, Invoice, Subscription and so on, or StripeObject for nested fields like metadata.
AttributeError: 'get' is a dict method, but a Session is not a dict. Use .to_dict() to convert it.
AttributeError: 'get' is a dict method, but a StripeObject is not a dict. Use .to_dict() to convert it.
TypeError: Session is not iterable or a mapping; call .to_dict() for a plain dict.
TypeError: Object of type Session is not JSON serializable
TypeError: Object of type Decimal is not JSON serializable
AttributeError: to_dict_recursive
AttributeError: stripe_id
Why your tests probably won't catch it
Most test suites build Stripe events as plain dicts or mocks. A dict still has .get(), so those tests pass on v15 whether or not the code was fixed. Here's a typical webhook handler and its test:
# app/webhooks.py
def fulfill_checkout(event):
session = event["data"]["object"]
plan = session.get("metadata", {}).get("plan", "free")
return {"customer": session.get("customer"), "plan": plan}
# tests/test_webhooks.py: passes on v14, v15 and v16
def test_fulfill_checkout():
event = {"data": {"object": {"customer": "cus_123", "metadata": {"plan": "pro"}}}}
assert fulfill_checkout(event) == {"customer": "cus_123", "plan": "pro"}
In production, event comes from stripe.Webhook.construct_event and is a real Event, so the first .get() raises. The test never sees it.
This is common. We scanned 498 open-source Python projects that use Stripe objects or webhooks, and 231 of them contain app code that crashes on v15, mostly .get() on checkout sessions, customers and webhook events. In most of the projects we ran, their own tests didn't catch the break. The scan is static, so if anything it undercounts.
Write one test that fails before the fix
Build the event the way the SDK does. stripe.Event.construct_from turns a plain payload into a real Stripe object, with no network calls:
import stripe
from app.webhooks import fulfill_checkout
def test_fulfill_checkout_with_a_real_stripe_event():
event = stripe.Event.construct_from({
"id": "evt_test", "object": "event", "type": "checkout.session.completed",
"data": {"object": {"id": "cs_test", "object": "checkout.session",
"customer": "cus_123", "metadata": {"plan": "pro"}}},
}, "sk_test")
assert fulfill_checkout(event) == {"customer": "cus_123", "plan": "pro"}
On stripe 14 this passes. On 15 or 16, with the handler unchanged, it fails with the AttributeError above. That's the point: it's a test that can see the break. To cover signature checking too, sign a payload with your test webhook secret and pass it through stripe.Webhook.construct_event.
Now the fix. Converting once at the top of the handler is usually the smallest change:
def fulfill_checkout(event):
session = event.data.object.to_dict()
plan = session.get("metadata", {}).get("plan", "free")
return {"customer": session.get("customer"), "plan": plan}
On v15 and later, to_dict() returns a plain dict all the way down, so the rest of the function keeps working. The real-event test passes after this change on 14.4.1, 15.6.1 and 16.0.0, so you can ship the fix before you upgrade. Delete the dict-based test: it was never testing what production sends.
Upgrade checklist
- Move to Python 3.9 or newer. v15 won't install on older versions.
- Find dict-style use of Stripe objects. Search for
.get(,.keys(,.items(,.values(,dict(,json.dumps(,to_dict_recursive,stripe_idand_decimal. Start with code that handles values fromstripe.*.retrieve,create,listandconstruct_event, and anything readingevent["data"]["object"]. - Add a real-object test for each webhook handler, like the one above, and confirm it fails on v15 before you change the code.
- Apply the fixes from the table. Compare decimal fields with
Decimalvalues, not strings. - Coming from v12 or older? Replace
stripe.error.CardErrorand the rest ofstripe.errorwith the top-level names, such asstripe.CardError. That module was removed in v13. - Going to v16? Rename
stripe.Reversaltostripe.TransferReversal, replaceStripeObject.request()withraw_request()on aStripeClient, and pass path parameters positionally, as inclient.v1.customers.retrieve("cus_123"). - Pin the major version, for example
stripe>=16,<17, so the next breaking release doesn't arrive unannounced.
That last step matters more than it used to. stripe-python 12.0 shipped in April 2025, 13.0 in September 2025, 14.0 in November 2025, 15.0 in March 2026 and 16.0 in October 2026: five major versions in 18 months.
Or have the upgrade done and proven for you
Breakwater is an AI agent that does this upgrade and proves it. On 22 real open-source Stripe projects, it upgraded 19 to v15. Where a project's own tests couldn't see the break, it first added a test like the one above. Each change comes as a pull request, with the failing-then-passing test as evidence.
If you ship an SDK and want your users moved to your next major version, we'll run it free on public repos still on your old one.
Request a free runSources
- Stripe's migration guide for stripe-python v15
- Stripe's migration guide for stripe-python v16
- stripe on PyPI, for release dates
- Error messages and behavior: our own runs on stripe 14.4.1, 15.6.1 and 16.0.0 under Python 3.11