skip to navigation
skip to content

Planet Python

Last update: May 26, 2026 10:43 AM UTC

May 26, 2026


Graham Dumpleton

WSGISwitchInterval in mod_wsgi 6.0.0

The first two posts in this series covered new directives in mod_wsgi 6.0.0 that change the concurrency model the interpreter runs under. WSGIPerInterpreterGIL opts a sub-interpreter into its own GIL. WSGIFreeThreading opts a process into PEP 703 free-threaded mode. This third directive, WSGISwitchInterval, is a different sort of thing. It does not change the concurrency model. It exposes a Python tuning knob that has existed since Python 3.2 and that almost nobody touches, but that I have come to think is worth touching for a meaningful class of WSGI workloads.

The post is partly about what the directive does. Mostly though it is about a measurement story, and about why having telemetry to drive tuning decisions matters more than the directive itself.

What the switch interval is

The Python GIL is the lock that serialises bytecode execution across threads in a CPython process. Only one thread at a time holds it. For other threads to make progress on Python code, the holder has to release the lock. Some releases are voluntary, for instance during I/O calls that drop the GIL while they wait. Voluntary releases are not enough on their own to schedule cleanly between several CPU-busy threads though, so the interpreter also has a scheduler that nudges the holder to give the lock up periodically. That scheduler is what the switch interval controls.

In CPython 2 the scheduler was bytecode-count based. After every N bytecodes the interpreter would check for pending signals, drop the lock, and reacquire it. The setting was sys.setcheckinterval(N), default 100 ticks. The problem with bytecode counting was that bytecodes are not equal-cost. Some operations completed in a fraction of a microsecond. Others, like calling out into a slow built-in, took milliseconds. So the actual wall-clock interval between handoffs varied widely depending on what code was running.

Python 3.2 replaced this with a time-based scheduler. Antoine Pitrou's new GIL implementation moved the handoff trigger from "after N bytecodes" to "after T seconds since the last release", controlled by sys.setswitchinterval() with a default of 5 milliseconds. That default was a reasonable compromise on the hardware that existed in 2010. It has not changed since. Fifteen years on, on hardware that runs Python several times faster per cycle, the same 5 ms can be a much larger amount of Python work than it used to be. That is the rationale for considering whether the default is still the right value for your workload.

What WSGISwitchInterval does

The directive calls sys.setswitchinterval() after interpreter initialisation, so the setting takes effect for the rest of that interpreter's life. The simplest form is at server scope.

WSGISwitchInterval 0.002

This applies to the embedded mode interpreter in Apache child processes. For daemon mode the equivalent is the switch-interval= option on WSGIDaemonProcess.

WSGIDaemonProcess my-app processes=2 threads=5 switch-interval=0.002

The directive can also appear inside the <WSGIInterpreterOptions> container introduced in the per-interpreter GIL post. If the matched sub-interpreter has its own GIL via WSGIPerInterpreterGIL, you can tune that one sub-interpreter's switch interval separately from the others in the same process.

<WSGIInterpreterOptions process-group="my-app" application-group="cpu-heavy">
    WSGIPerInterpreterGIL On
    WSGISwitchInterval 0.001
</WSGIInterpreterOptions>

Without an own-GIL on the matched sub-interpreter the directive cannot be made per-sub-interpreter, because the GIL is shared across the process and tuning it for one sub-interpreter would silently affect all of them. mod_wsgi rejects that configuration with a warning rather than silently scoping wider than the operator asked for.

Under free-threading the directive is a no-op. There is no GIL to schedule.

The default is to leave Python's own default alone. You opt in to tune.

You cannot tune what you cannot measure

The case for adjusting the switch interval rests on being able to see what happens when you change it. Python itself does not expose any direct measure of GIL contention. There is no counter you can read to ask "how much time was spent waiting for the GIL". The interpreter knows in some sense, but it does not surface the information.

mod_wsgi exposes a partial measure, surfaced as gil_wait_time. It is the time the worker thread was held up acquiring the GIL at points where mod_wsgi is doing work on the application's behalf: request dispatch, request body reads, response writes, logging. It does not see contention while the application's own Python code is running, and it cannot see contention inside C extensions that release and reacquire the GIL on their own schedule. So the value is a lower bound, not an absolute measure of contention.

That is enough to drive tuning decisions, though. The metric moves directionally with actual contention. Combined with throughput and response time, three numbers from the same telemetry stream, it is enough to tell you whether a switch interval change helped or hurt.

The rest of the post is a worked example that uses exactly those three signals.

A benchmark to make the case

The workload is a synthetic WSGI handler. Each request spends approximately 3 ms running Python code on the CPU, plus a 1 ms simulated wait standing in for a small bit of I/O, and returns a 1 KB response body. The load generator drives concurrency 10, more than enough to saturate the available workers in every configuration shown below. The workload is deliberately idealised, with no real I/O and no C extension calls, because the point is to surface the effect of GIL scheduling on pure-Python compute as clearly as possible.

All four configurations below run on the same host, same Apache, same Python, same WSGI handler. Only the process and thread counts and the switch interval change. Each step includes a small table of the key metrics so the numbers are legible even if the dashboard screenshots are too small to read, and the table grows as we go so each configuration can be compared with the ones before.

Baseline: ten processes, one thread each

This is the no-contention reference point. Each daemon process has a single worker thread, so no two threads compete for the same GIL. Whatever GIL pressure shows up here is whatever overhead the lock adds on the dispatch and I/O paths in mod_wsgi itself, with no waiting.

WSGIDaemonProcess my-app processes=10 threads=1

The result is 134k requests per minute, 4 ms mean response time, gil_wait_time effectively zero. The GIL wait time distribution is a single bar in the head bucket, which is what no-contention looks like.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none

Baseline overview dashboard, ten processes with one thread each, showing 134k rpm and 4 ms mean response time.

This is the upper bound for what the workload can do on the available cores when nothing contends with anything. Roughly 13.4k rpm per process.

Add threads: GIL contention takes over

Keep the total worker pool roughly comparable, but reshape it: two processes with five threads each. Same default 5 ms switch interval.

WSGIDaemonProcess my-app processes=2 threads=5

Throughput collapses to 37k requests per minute, about 28% of the baseline. Mean response time goes from 4 ms to 16 ms. Application time mean is now 11 ms, up from 4 ms in the baseline. Each process is now CPU/GIL-bound: five threads competing for one GIL inside the process, with cores sitting underutilised because only one thread can run Python at a time.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none
2 × 5, 5 ms 37k 16 ms 11 ms 13 ms

Overview dashboard with two processes of five threads each at the default 5 ms switch interval, showing 37k rpm and a 16 ms response time.

The shape of the contention is most visible in the GIL wait time distribution chart.

GIL wait time distribution at 5 ms switch interval, showing a head bucket plus a series of bumps at multiples of the switch interval.

The chart tells a clear story. There is a head bucket holding the requests that got their handoff immediately, then a series of bumps further out at multiples of the 5 ms switch interval. Each bump corresponds to a request that had to wait one or more switch intervals to acquire the GIL: one missed cycle, two missed cycles, three missed cycles, and so on. The bumps shrink as you move right, which is the shape of a contention pattern where missed cycles do not pile up too heavily. But the tail is fat. The percentile numbers along the top of the chart confirm this: p95 is 13 ms and p99 is 18 ms, meaning a meaningful fraction of requests are waiting several full switch intervals to make progress on Python code.

This is the textbook case for the CPU/GIL-bound label. With five threads competing for one GIL on each process, the GIL is the wall. The standard remediation is to add processes. The point of this post is that there is a second lever, which is to make each handoff cheaper rather than less frequent.

Tighten the switch interval to 2 ms

Same process and thread shape, but cut the switch interval from 5 ms to 2 ms.

WSGIDaemonProcess my-app processes=2 threads=5 switch-interval=0.002

Throughput moves from 37k to 42k requests per minute, about 13% better. Mean response time drops from 16 ms to 14 ms. The GIL wait time distribution chart is where the more interesting change shows up.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none
2 × 5, 5 ms 37k 16 ms 11 ms 13 ms
2 × 5, 2 ms 42k 14 ms 13 ms 6 ms

GIL wait time distribution at 2 ms switch interval, showing bumps that are shorter and closer together than at 5 ms.

The chart is dramatically more head-heavy than at 5 ms. The head bucket now holds the bulk of the requests, where at 5 ms it was only about a fifth of them. Most requests are getting the GIL on their first try at the new interval. The smaller bumps further out are still there, but they sit closer to the head than their counterparts at 5 ms did, because each cycle is now 2 ms wide instead of 5 ms wide. The percentile numbers in the chart header confirm what the shape is showing: p50 has dropped from 5 ms to under 1 ms, p95 from 13 ms to 6 ms, p99 from 18 ms to 10 ms. Contention is both less frequent and cheaper when it does happen, and the throughput gain on the dashboard follows from that.

A reasonable stopping point for tuning the GIL switch interval on a mixed workload is around 2 ms. The reasoning is that more frequent GIL handoffs means more context switching, and at very short intervals that overhead can start to dominate. So if you do not have telemetry that lets you see the effect on your specific workload, 2 ms is a sensible place to stop. Going lower than that is something to do only when you can measure the result and confirm that the gain is real. The benchmark workload here is not a mixed workload, and the rest of this post is the measurement story that earns the right to go further.

Tighten further to 0.1 ms

Same shape again, but switch interval down to 0.1 ms.

WSGIDaemonProcess my-app processes=2 threads=5 switch-interval=0.0001

Throughput jumps to 121k requests per minute. That is within roughly 10% of the no-contention baseline of 134k. Mean response time is back to 5 ms. Application time mean is back down to around 4.7 ms, close to its baseline value of 4.3 ms.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none
2 × 5, 5 ms 37k 16 ms 11 ms 13 ms
2 × 5, 2 ms 42k 14 ms 13 ms 6 ms
2 × 5, 0.1 ms 121k 5 ms 5 ms 1 ms

Overview dashboard at 0.1 ms switch interval, showing 121k rpm and 5 ms mean response time, near the no-contention baseline.

The GIL wait time distribution collapses back to essentially the head bucket.

GIL wait time distribution at 0.1 ms switch interval, with the bumps gone and the head bucket holding nearly all samples.

The bumps are gone. p95 is 1.2 ms and p99 is 1.2 ms, which is essentially "everything fits in the first bucket of the histogram". What is going on at this setting is that the switch interval is now much shorter than the per-request CPU cost. Each handoff happens many times during a single request's CPU work, so threads are interleaving at fine granularity rather than passing the GIL around in big chunks. There is no missed-cycle structure left for waiters to pile up on. Handoffs are continuous rather than periodic.

The workload is still CPU/GIL-bound in shape. Threads still spend most of their wall time holding a request without consuming CPU on it directly, because at any given instant only one thread per process can run Python. That structural fact has not changed. But the measured throughput cost of that shape has nearly vanished. The new switch interval has just made the cost of being that workload small enough not to hurt.

What this means

The default 5 ms switch interval is conservative for a pure-Python CPU-bound workload. For workloads of that shape the knob is real, and the gain can be substantial. Three observations follow from that, all of them important.

Most WSGI applications do not look like this benchmark. The typical web request spends most of its time in I/O, in database calls, in C extensions like JSON parsers or template engines, in HTTP client libraries. All of those release the GIL during their slow phase. For those workloads the default is probably fine, and tuning the switch interval will not move much.

Stopping at around 2 ms is a sensible default for a mixed workload. It is not the answer for every workload, though. If you have endpoints that are heavy on Python compute, data processing endpoints, ML preprocessing, anything that does meaningful work in pure Python before returning, those endpoints may be in the same regime as this benchmark, and the same lever can apply. The further down you go past 2 ms the more important it is to have the telemetry that confirms you are actually winning rather than guessing.

The way you find out is by measuring. Throughput, response time, and gil_wait_time on the same telemetry stream, with the switch interval as the only variable, is enough to tell you whether tuning helps for your workload.

Caveats

More frequent GIL handoffs mean more context switching. There is a cost to that, and at some interval that cost begins to dominate the gain. The benchmark workload here does not show that cost emerging at 0.1 ms, but that is partly because the workload is idealised. With real concurrency patterns and real I/O it would emerge sooner.

Tuning the switch interval down does not fix GIL contention inside C extensions that manage their own GIL acquire and release. If your contention lives inside NumPy or a database driver, this knob does not reach it.

The right framing is that this is a tuning lever for a specific class of workload, not a default to flip across the board. Use it where the measurements say it helps. Leave it alone where they say it does not.

What's next

If you run mod_wsgi and the case above is interesting for your workload, please install the 6.0.0 release candidate, try WSGISwitchInterval against your real traffic, and file issues against the GitHub project for anything that does not behave the way the documentation suggests it should.

This post has leaned heavily on telemetry from mod_wsgi-telemetry, the companion tool that records and visualises the metrics shown in the screenshots above. That tool is going to be the subject of a follow-up series. Before we get to that though, the next post will revisit the free-threading configuration from earlier in this series and look at how performance under it manifests through the same request metrics used here. The argument for tuning at all rests on having that visibility, and the screenshots here are what the tool surfaces out of the box.

For reference:

May 26, 2026 10:38 AM UTC

Free-threading vs the GIL in mod_wsgi 6.0.0

The previous post in this series walked through tuning WSGISwitchInterval to claw back throughput on a multi-threaded mod_wsgi daemon group whose workload is CPU-bound Python. Tightening the switch interval recovered most of the throughput a two-process, five-thread shape had lost compared with the ten-process, one-thread baseline. What it did not change was per-process CPU usage. Each process stayed pinned at about one core regardless of how the switch interval was tuned, because that ceiling is the GIL itself.

This post is about what happens when that ceiling is removed. PEP 703 free-threading provides a CPython build with no GIL at all, and mod_wsgi 6.0.0 exposes the opt-in for it through WSGIFreeThreading, the second directive covered in this series. I have rerun the same benchmark workload on a free-threaded Python with that directive on. The interesting metric is now CPU usage per process.

Why CPU usage is the new focus

Throughput and response time were the headline metrics for tracking the effect of switch interval changes. Both are still relevant here. But the comparison turns on something different now. With the GIL, threads in a process serialise on the lock, and the process consumes at most one core regardless of how many threads you give it. With free-threading there is no GIL, and the process can use as many cores as its threads can actually fill. If the workload is CPU-bound Python, CPU usage per process is what tells you whether the runtime is making real use of the hardware.

What disappears from the toolkit

GIL wait time was the central diagnostic in the switch-interval post. Under free-threading there is no GIL, so there is no GIL wait to measure. The histogram that showed the convoy bumps at multiples of the switch interval in the previous post goes flat by definition. What replaces it as positive evidence that the workload is genuinely parallel is the CPU usage number itself. Multiple cores per process is the new signal.

A reminder of what free-threading asks of you

The free-threading post earlier in this series went into detail on what free-threading actually requires. Briefly: it is a separate CPython build (typically named python3.14t on systems that distribute it), C extensions must declare Py_mod_gil = Py_MOD_GIL_NOT_USED or the runtime quietly re-enables the GIL for the whole process, and application code must handle concurrent execution correctly rather than being incidentally safe under GIL atomicity. None of that has changed since I wrote that post. The metrics below assume those prerequisites are satisfied and show what the upside looks like when they are.

The benchmark setup

The workload is the same as in the switch-interval post. Each request spends approximately 3 ms running Python code on the CPU, plus a 1 ms simulated wait, and returns a 1 KB response body. Concurrency 10, same host, same Apache, same WSGI handler. The only changes are that Python is the free-threaded build, mod_wsgi has WSGIFreeThreading On configured on the daemon group, and two configurations are exercised: two processes with five threads each (matching the comparison from the previous post), and one process with ten threads (a configuration that has no point under the GIL but lights up under free-threading).

Comparison: two processes, five threads each

WSGIDaemonProcess my-app processes=2 threads=5
WSGIFreeThreading On
Config rpm response CPU/proc CPU total GIL p95
10 × 1 GIL (baseline) 134k 4 ms 0.66 cores 6.6 cores none
2 × 5 GIL, default 5 ms 37k 16 ms 0.95 cores 1.9 cores 13 ms
2 × 5 GIL, 0.1 ms tuned 121k 5 ms 0.90 cores 1.8 cores 1 ms
2 × 5 free-threading 131k 4 ms 3.3 cores 6.6 cores n/a

Overview dashboard with two free-threaded processes of five threads each, showing 131k rpm and 4 ms mean response time.

Throughput jumps to 131k rpm, almost matching the ten-process baseline of 134k, and well past what 0.1 ms switch interval tuning could achieve. Response time is back to 4 ms.

The row that has changed in character is CPU per process. Under the GIL each process was pinned at about one core no matter what we did with the switch interval. Free-threading lifts that ceiling, and each process is now consuming about 3.3 cores. Total CPU is 6.6 cores, the same as the ten-process baseline, but with one fifth the processes.

The GIL p95 column has no value to report any more. The histogram that showed contention bumps for missed switch-interval cycles is now flat. There is no GIL to schedule and no wait to measure.

Comparison: one process, ten threads

WSGIDaemonProcess my-app processes=1 threads=10
WSGIFreeThreading On

Under the GIL this configuration would not really make sense. The threads would all queue for the one GIL, the process would cap at about one core, and throughput would likely be cut by half or more compared with the ten-process baseline. The exact figure depends on the workload and how the switch interval is set, but the shape is clear: one process with ten threads on a CPU-bound workload is not a configuration the GIL rewards.

Under free-threading the picture is dramatically different.

Config rpm response CPU/proc CPU total GIL p95
10 × 1 GIL (baseline) 134k 4 ms 0.66 cores 6.6 cores none
2 × 5 GIL, default 5 ms 37k 16 ms 0.95 cores 1.9 cores 13 ms
2 × 5 GIL, 0.1 ms tuned 121k 5 ms 0.90 cores 1.8 cores 1 ms
2 × 5 free-threading 131k 4 ms 3.3 cores 6.6 cores n/a
1 × 10 free-threading 134k 4 ms 6.65 cores 6.65 cores n/a

Overview dashboard with one free-threaded process of ten threads, showing 134k rpm and 6.65 cores of CPU usage in a single process.

Throughput matches the ten-process baseline at 134k rpm. Response time is 4 ms. The single process is consuming about 6.65 cores. That is the headline finding of the comparison. The ten processes of the baseline have collapsed into one process that genuinely uses about 6.65 of the available cores.

A note on the ceiling

Both the ten-process baseline and the one-process free-threading run land at the same total CPU usage of around 6.6 cores. That is not an artefact of the configurations meeting in the middle. It is the ceiling of the machine the tests are running on. The load generator is also running on the same host and is consuming some of the available CPU envelope itself. So the 134k rpm number is the ceiling of this machine under this workload, not a fundamental ceiling of either configuration. On a more capable host, or with the load generator run from a separate machine, both configurations could likely scale further. The point being made in the comparison is the shape of CPU usage across configurations, not the absolute throughput number.

What this means in practice

Free-threading is another lever in the mod_wsgi concurrency toolkit. The free-threading post earlier in this series introduced it. This post shows what it does when applied to a workload that fits.

A few operational implications follow from the numbers above.

Memory. Fewer processes means less duplicated interpreter state, fewer copies of the application code in memory, fewer per-process caches. The ten-process baseline reported around 200 MB total RSS. The one-process free-threaded run reported around 31 MB. That is a real saving for memory-constrained deployments, and it is largely independent of whether the throughput is fully utilising the hardware.

Topology. One daemon group with a thread pool is simpler to operate than ten separate processes. Fewer file descriptors, fewer accept queues, one unit to restart and reload, easier capacity reasoning.

Trade-off. Process-level isolation is less granular. A crash in a thread on a single-process pool takes the whole pool with it, where on a multi-process pool it would only take one worker. For many workloads that is a fair trade, especially if the application itself does not crash frequently in production. For others, keeping at least a handful of processes around still makes sense. Free-threading composes happily with that, and the 2 × 5 configuration above is exactly that intermediate point.

Caveats

The constraints from the free-threading post all still apply. Free-threaded CPython is a separate build and not the one most distributions ship as default. C extensions need to declare free-threading support or the GIL silently comes back on for the whole process, undoing the benefit. Application code needs to be genuinely thread-safe rather than incidentally OK because the GIL was doing the work. The free-threaded build also carries a small single-threaded overhead.

The case for adopting free-threading still rests on those prerequisites being met for your specific application. The metrics here just show what the lever does when they are.

What's next

If you run mod_wsgi and the case made above is interesting for your application, please install the 6.0.0 release candidate against a free-threaded Python build, try WSGIFreeThreading against your real workload, and file issues against the GitHub project for anything that does not behave as the documentation says it should.

This concludes the directive tour of the new concurrency-related additions in mod_wsgi 6.0.0. I will look more closely at mod_wsgi-telemetry itself, the tool that has been quietly doing the work in every screenshot and table in this series, in some future posts.

For reference:

May 26, 2026 07:00 AM UTC


Bob Belderbos

From Python Script to Production: A Django Coaching Case Study

Six weeks of 1:1 coaching. The output: a Django app in production on Fly.io, covering movies, anime, and manga, with user accounts, a save library, Docker, and CI/CD on every push. Daniele started with Python skills and a project idea. Here's what the work actually looked like.

Daniele

The starting point

The idea was a platform to discover and track movies, anime, and manga. He had enough Python to start, already building a CLI tool with a swappable data layer in our first app together. What he didn't have was experience building a web app: the real mechanics of Django, how the pieces connect, what "ready to ship" means in practice.

Self-study can get you to a prototype. It won't tell you when code that works is teaching you the wrong habits. That's what weekly PR reviews are for.

Starting with discipline, not speed

The first PR was a Python script. It called the TMDB API, parsed the results, and displayed them. Functional and already a place to build habits.

For example: the type hint on _fetch_tmdb_data said -> dict, but the function could return None. Fix the type hint. The constants TMDB_URL and headers weren't uppercased consistently. Follow PEP8 conventions. The API key loaded from an .env file, but there was no .env-template telling other developers which variables to set. Add the template.

None of these changes affect whether the script runs. All of them affect whether another developer, or Daniele himself in six months, can reason about it. That's where professional developer habits form.

Django's machinery is yours to understand

Moving from a script to Django means the framework does a lot for you. The risk is accepting what it does for you without understanding its deeper workings.

In week 2, Daniele ran uv run ty check . and got two errors: Class 'Movie' has no attribute 'objects'. Django adds the objects manager dynamically at runtime; ty is a static type checker and can't see it. He asked the right question:

"So this was because of ty/type checker flagging an error? If we have a way to instrument ty globally to recognize Django's dynamic managers, that would be better. Do we need to pull in 'django-stubs' or a similar configuration?"

The answer was: Django's dynamic ORM creates real friction with static type checkers. The pragmatic fix was to explicitly declare objects: models.Manager on the model, making the implicit explicit for both the type checker and any developer reading the code. The question itself was the point. Understanding why the error existed led to a better solution.

We also made a note to compare with other type checkers like pyrefly (v1.0 just came out) or mypy and see if they have better Django support.

The same week, the movie detail page returned 404s even though data existed. The cause: movie_list fetched from the API but didn't persist to the database. movie_detail queried the database. Nothing matched. Daniele fixed the sync logic and wrote:

"I ran across a problem earlier that was caused by not having the DB in sync. So lesson learned and now I try not to forget to run it."

Running makemigrations after every model change now became a habit.

Refactoring is how architecture emerges

By week 6, the codebase had grown. Two API functions, get_movie_list_from_api and get_services_list_from_api, had identical try/except blocks. The only difference was the endpoint and the default return value.

Extracting a private _get_from_api(endpoint, default) helper isn't a trick. It's a principle: if two pieces of code do the same thing, one is a future bug waiting to diverge.

The refactor also cleaned up the return types from list[Movie] | None to list[Movie], replacing a None sentinel with a proper empty list default.

Each review surfaced one decision that sharpened his model of how good code behaves.

Week 6: Docker, CI/CD, and a live URL

Week 6 was Docker and Fly.io. The app that ran locally needed to run in the cloud: no SQLite disappearing on container restart (or moving to Postgres), environment variables properly set, static files served correctly, no secrets hardcoded anywhere, GitHub Actions deploying on every push to main (with passing tests).

Daniele learned a lot here and shipped his app:

Daniele's movie and anime discovery platform

When Daniele shared the deploy win in my coaching Slack, he put into words something that sits at the center of anybody wanting to improve their coding/developer skills now:

"Addressing quality and best practices and security and maintainability in your code does not pair well with velocity, especially when you're learning. So I preferred to improve my code quality and to become better at it by learning properly, postponing some feature release for later. In the process I learned Django and its main mechanics, Docker and deployment in the cloud."

The velocity-vs-quality tradeoff he named isn't unique to learning Django. It's the choice every developer makes every week. My coaching gave him the framework, the discipline and persistence were his.

May 26, 2026 12:00 AM UTC


Graham Dumpleton

Per-interpreter GIL in mod_wsgi 6.0.0

mod_wsgi 6.0.0 is currently available as a release candidate. You can install it from PyPI, or grab the source from the GitHub releases page. There is a significant amount of code cleanup behind this release, alongside a range of new features and operator-facing improvements that have been overdue for some time.

Rather than describe everything in one post, I am going to work through the headline changes in a short series. The most consequential set for anyone running mod_wsgi in production is the new concurrency configuration. CPython has gained two genuinely new concurrency modes over the last few releases (per-interpreter GIL in 3.12 and free-threading in 3.13), and mod_wsgi 6.0.0 exposes both as opt-in directives, along with finer-grained control over how the GIL switches between threads.

This first post covers the per-interpreter GIL story and the new WSGIPerInterpreterGIL directive.

Why the GIL has always been the deployment problem

This is well-trodden ground, but worth recapping for context. CPython's Global Interpreter Lock serialises Python bytecode execution within a single process. It does not matter how many OS threads you create inside that process. Only one of them runs Python at a time.

For WSGI deployments, this has shaped the way servers like mod_wsgi scale. Threads within a single process are useful for handling I/O concurrently, since any reasonable C extension or built-in I/O call releases the GIL while it waits on the kernel, but they do not give you parallelism for CPU-bound Python work. To get that, you have always needed more processes. mod_wsgi's daemon mode is built around this assumption. You configure N daemon processes, each with its own Python interpreter and its own GIL, and you get N-way Python parallelism that way.

Sub-interpreters complicate the picture slightly. They have existed in CPython for a long time, and mod_wsgi has used them since the beginning, but until PEP 684 landed in Python 3.12 they all shared one process-wide GIL. Adding more sub-interpreters inside a single process gave you isolation between applications, but no additional concurrency.

What changed in Python 3.12 and 3.14

PEP 684 made per-interpreter GIL possible as an opt-in for sub-interpreters created through the C API. With it, each sub-interpreter holds its own lock, and two sub-interpreters running on different OS threads can execute Python bytecode at the same time. The main interpreter is excluded from this. It always holds the original process-wide GIL and cannot be given one of its own. That distinction matters later.

Python 3.14 then shipped PEP 734 as concurrent.interpreters, the first standard-library API for working with sub-interpreters from Python code. It is a useful addition, but it does come with a deliberate restriction. Data passed between interpreters is either pickled and copied through a queue, shared through the buffer protocol, or limited to a small set of immortal immutable built-ins. Anything that wants to share mutable Python objects across interpreters has to find another way.

That data-sharing restriction is why concurrent.interpreters is most naturally suited to message-passing worker patterns rather than ordinary Python code which tends to lean heavily on shared mutable state. The same restriction is one of the reasons embedding hosts like mod_wsgi are well-positioned to get value out of per-interpreter GIL ahead of general Python code.

How mod_wsgi has always used sub-interpreters

mod_wsgi has used sub-interpreters from the start, but originally for a completely different reason. The driver was isolation, not parallelism. Running multiple WSGI applications inside a single Apache process is a real operational need, and you cannot do it safely if they all share the same sys.modules, signal handlers, atexit handlers, and so on. Sub-interpreters give each application its own private copy of all of that.

mod_wsgi calls this an "application group". Each named application group maps to a sub-interpreter inside whichever daemon process (or embedded Apache child process) is hosting it. Until Python 3.12, that arrangement was purely about keeping applications from stepping on each other.

What changes with per-interpreter GIL is that the same sub-interpreters mod_wsgi was already creating for isolation can now hold their own locks and run Python bytecode in parallel. The application group concept does not need to change. The directive that flips this on is new, but the underlying structure is the one mod_wsgi has had all along.

There is also a happy alignment with the data-sharing constraint mentioned above. mod_wsgi routes each incoming WSGI request directly into a chosen sub-interpreter, and the WSGI contract does not ask for any shared mutable Python state to span requests. The request is the message. From an application author's point of view, there is not much new to do. The configuration changes; in most cases the application does not. The caveats, and there are always caveats, are what your C extensions will tolerate and, if your application spawns its own background threads, what their shutdown handling looks like under per-interpreter rules. More on both at the end.

The new directive

The new directive is WSGIPerInterpreterGIL, with the obvious syntax:

WSGIPerInterpreterGIL On

The default is Off. Opt-in is deliberate; there is no scenario where it would be safe for mod_wsgi to flip this on by default. The directive is valid at server config scope and can also appear inside a <WSGIInterpreterOptions> container, which is what you want most of the time and which I will get to next.

Two things worth flagging up front. First, the main interpreter is excluded. If your application runs in the main interpreter, which it will if you have set WSGIApplicationGroup %{GLOBAL}, then enabling WSGIPerInterpreterGIL has no effect on it. Per-interpreter GIL only applies to sub-interpreters. Second, Python 3.12 or later is required. On older Python the directive is accepted but does nothing, with a configuration warning logged.

Composing with daemon mode

The interesting case for WSGIPerInterpreterGIL is not opting an entire daemon process group into it. If you want extra parallel Python execution across separate processes, you can already get that by adding more daemon processes. The interesting case is selectively enabling per-interpreter GIL for specific sub-interpreters that already exist within a daemon process you are running.

A small example. Suppose you have a daemon process group called localhost:8000 running a single WSGI application. You can create a named sub-interpreter inside that process and give it its own GIL, like this:

<WSGIInterpreterOptions process-group="localhost:8000" application-group="sub-interp-1">
    WSGIPerInterpreterGIL On
</WSGIInterpreterOptions>

WSGIInterpreterOptions is the container directive that lets you scope settings to a particular sub-interpreter. The process-group= selector matches a daemon process group by name, or %{GLOBAL} for the embedded mode interpreter in Apache child processes. The application-group= selector further narrows to a specific application group inside that process, which is the same thing as a specific sub-interpreter. Both selectors are optional, and the most-specific match wins.

On its own, the directive above does nothing useful. The sub-interpreter is configured to hold its own GIL but no requests are being routed into it yet. To actually use it, you can delegate a sub-URL of the existing application to that sub-interpreter using a <Location> block:

<Location /suburl>
    WSGIApplicationGroup sub-interp-1
</Location>

The end result is that requests to /suburl are dispatched into a second copy of the application running in sub-interp-1, which holds its own GIL, while everything else continues to run in the default application group with the process-wide GIL. Two halves of the same application can now execute Python bytecode in parallel inside one daemon process.

There is a different shape that may suit a different setup. If your Apache configuration already has multiple WSGIScriptAlias directives pointing at distinct WSGI applications, and you have arranged for those applications to run in separate sub-interpreters of a single daemon process (as opposed to separate daemon process groups), then WSGIPerInterpreterGIL lets you opt the relevant sub-interpreters into their own GILs without rearranging the process layout.

A note on cost. If the daemon process was previously hosting one sub-interpreter and you switch to hosting two, you now have two live copies of the application in that process, each with its own sys.modules, its own imported pure-Python modules, and its own per-interpreter C extension state. Memory use goes up. The trade is the same one you make when you add daemon processes, more memory in exchange for more parallel Python, but doing it within a single daemon process can still have advantages depending on how the application is provisioned and managed at the OS level. Whether one process with two sub-interpreters is preferable to two daemon processes with one sub-interpreter each is a judgement call about your specific deployment, not a universal answer.

One more thing before moving on. There is a separate directive coming in this series called WSGIFreeThreading for use with free-threaded Python builds. The two are mutually exclusive on a single process, and the next post covers it on its own terms, so I will not muddy this one with the details.

Which applications actually benefit

The honest answer is fewer than the headline implies. Per-interpreter GIL helps for CPU-bound Python work that can be partitioned cleanly across requests, where you would otherwise be paying the cost of running additional daemon processes purely to dodge the GIL. Numerical work that is not already handled inside C code that releases the GIL, request-scoped computation, image processing, and similar.

It is also worth being clear about what the directive does not do. Giving a sub-interpreter its own GIL only buys parallelism between sub-interpreters. Two concurrent CPU-bound requests that both land in sub-interp-1 still compete for that sub-interpreter's GIL and serialise against each other, exactly as they would have before. If all the heavy work funnels through one sub-interpreter, the directive has not bought you anything. The win comes from spreading the load across multiple sub-interpreters, each holding its own GIL. Which is why, for genuinely heavy CPU-bound throughput, scaling out with extra daemon processes is often still the cleaner answer; each daemon process gives you both an additional GIL and an additional set of OS-level resources to schedule against.

For ordinary I/O-bound web applications, the win is much smaller. I/O already releases the GIL, threads in a single process can already overlap their waits for the database or the network, and adding daemon processes remains the simpler scaling lever. Per-interpreter GIL is a precision tool. It is most useful when you specifically want more parallel Python execution inside fewer processes, or when you already have multiple sub-interpreters in one process for isolation reasons and you would now like them to run in parallel as well.

The gotchas

A few things are worth being aware of before reaching for the directive.

Sub-interpreters do not share Python state. Each sub-interpreter has its own sys.modules, its own imported copies of pure-Python modules, its own module globals. Any in-memory cache or singleton sitting in a module global is per-sub-interpreter. Anything you previously assumed worked process-wide now works only interpreter-wide.

Each sub-interpreter pays its own import cost. Memory and startup time scale with the number of sub-interpreters. The point of per-interpreter GIL is parallelism within a single process; the cost is that every sub-interpreter independently imports the application and everything it depends on.

The main interpreter remains special. To repeat the point from earlier, if your application is running in the main interpreter, which happens when WSGIApplicationGroup %{GLOBAL} is set, often because some C extension forced your hand, WSGIPerInterpreterGIL does nothing for it. The main interpreter always holds the process-wide GIL.

Background threads must be non-daemon. Sub-interpreters that hold their own GIL do not allow Python code to create daemon threads. Anything your application spawns via threading.Thread must run as a non-daemon thread, which is the opposite of what most Python code defaults to when it wants a worker that quietly exits with the process. That restriction comes with an awkward shutdown problem. Python only runs atexit handlers after it has tried to join non-daemon threads during sub-interpreter teardown, so the common pattern of signalling background workers to stop from an atexit handler will deadlock. In a mod_wsgi context the right answer is to hook mod_wsgi's own shutdown callbacks instead, which fire early enough to let your threads drain and exit cleanly. That shutdown API is worth a post of its own. For the purposes of this one, the point is that if your WSGI application relies on daemon threads or atexit-driven cleanup, this is the one situation where enabling WSGIPerInterpreterGIL may force application-side code changes.

What this means for C extension authors

This is the part that turns most attempts to enable WSGIPerInterpreterGIL into a hunt through the dependency tree, and it is the part I want extension authors to take seriously.

Restrictions on what works under sub-interpreters are not new. mod_wsgi users have been running into the rough edges of the simplified PyGILState_Ensure / PyGILState_Release API in sub-interpreters for years. The WSGIApplicationGroup %{GLOBAL} directive exists in part as a pragmatic answer for extensions that assume there is only one interpreter in the process. Per-interpreter GIL tightens those rules further, but it does not invent a new category of problem.

What does change is that explicit opt-in is now required. The extension must use PEP 489 multi-phase module initialisation. Extensions still using single-phase init will not be loaded into a sub-interpreter that holds its own GIL. The extension must also declare Py_mod_multiple_interpreters with the value Py_MOD_PER_INTERPRETER_GIL_SUPPORTED in its PyModuleDef_Slot array, like this:

static PyModuleDef_Slot module_slots[] = {
    {Py_mod_exec, module_exec},
    {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
    {0, NULL},
};

Without that declaration, the import fails when a sub-interpreter that holds its own GIL tries to load the module. The failure happens on first import, not at server startup, so it can take a request through a code path that has not been touched in a while to expose it.

Module state needs to be per-interpreter. Anything stashed in a C-level static (counters, caches, registered callbacks, type objects pointing at process-wide globals) breaks isolation between sub-interpreters and produces bugs that will not show up until two interpreters race over the shared state. The right answer is to move the state into module state retrieved via PyModule_GetState. Code still using the simplified PyGILState API needs to be reviewed too, or replaced with the explicit PyThreadState-based APIs where the assumption of a single interpreter does not hold.

For operators, the message is the unglamorous one. Before turning WSGIPerInterpreterGIL on in any kind of production setting, work through every C extension your application pulls in, directly and transitively. "Works on Python 3.12" is not the same as "works under per-interpreter GIL". The popular extensions are working through these requirements on their own timelines, and the situation will keep improving, but right now it is still on you to check.

What's next

If you maintain a mod_wsgi deployment and the per-interpreter GIL story is interesting to you, please try the 6.0.0 release candidate against a real workload and file issues against the GitHub project for anything that breaks or behaves oddly. The whole point of the RC period is to find out what does not work before the final release goes out.

The next post in this series will cover WSGIFreeThreading, the second new concurrency directive in 6.0.0 and the one that targets PEP 703 free-threaded Python builds. The constraints there are different again, and worth their own treatment.

For reference:

May 26, 2026 12:00 AM UTC

May 25, 2026


Talk Python to Me

#549: Great Docs

Your documentation has two audiences now - humans reading the rendered HTML, and AI agents trying to make sense of your library. Rich Iannone and Michael Chow from Posit are back on Talk Python with a brand new Python documentation tool called Great Docs that takes both seriously. Rich is the creator of Great Tables, and before that the R package GT, the man has a serious eye for design, and he's pointed that energy at the Python docs ecosystem. We'll talk about how Great Docs spins up a polished site in three commands, why every page ships as Markdown for your favorite LLM, how it leans on Quarto for executable code blocks and tabbed install sections, and where it lands against Sphinx, MkDocs, and Zensical. Plus, you'll meet Tablin. Here we go.<br/> <br/> <strong>Episode sponsors</strong><br/> <br/> <a href='https://talkpython.fm/sentry'>Sentry Error Monitoring, Code talkpython26</a><br> <a href='https://talkpython.fm/temporal'>Temporal</a><br> <a href='https://talkpython.fm/training'>Talk Python Courses</a><br/> <br/> <h2 class="links-heading mb-4">Links from the show</h2> <div><strong>Guests</strong><br/> <strong>Michael Chow</strong>: <a href="https://github.com/machow?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>Rich lannone</strong>: <a href="https://github.com/rich-iannone?featured_on=talkpython" target="_blank" >github.com</a><br/> <br/> <strong>Python Web Security with OWASP Top 10 and Agentic AI Course</strong>: <a href="https://talkpython.fm/ai-web-security" target="_blank" >talkpython.fm</a><br/> <br/> <strong>GT</strong>: <a href="https://posit-dev.github.io/great-tables/articles/intro.html?featured_on=talkpython" target="_blank" >posit-dev.github.io</a><br/> <strong>Episode</strong>: <a href="https://talkpython.fm/episodes/show/492/great-tables" target="_blank" >talkpython.fm</a><br/> <strong>Sphinx</strong>: <a href="https://www.sphinx-doc.org/en/master/?featured_on=talkpython" target="_blank" >www.sphinx-doc.org</a><br/> <strong>mkdocs</strong>: <a href="https://www.mkdocs.org/?featured_on=talkpython" target="_blank" >www.mkdocs.org</a><br/> <strong>Zensical</strong>: <a href="https://zensical.org/?featured_on=talkpython" target="_blank" >zensical.org</a><br/> <strong>Hugo</strong>: <a href="https://gohugo.io/?featured_on=talkpython" target="_blank" >gohugo.io</a><br/> <strong>Ghost</strong>: <a href="https://ghost.org/?featured_on=talkpython" target="_blank" >ghost.org</a><br/> <strong>Rs pkgdown</strong>: <a href="https://pkgdown.r-lib.org/?featured_on=talkpython" target="_blank" >pkgdown.r-lib.org</a><br/> <strong>Quarto</strong>: <a href="https://quarto.org/?featured_on=talkpython" target="_blank" >quarto.org</a><br/> <strong>quickstart</strong>: <a href="https://posit-dev.github.io/great-docs/user-guide/quickstart.html?featured_on=talkpython" target="_blank" >posit-dev.github.io</a><br/> <strong>llms.txt file</strong>: <a href="https://llmstxt.org/?featured_on=talkpython" target="_blank" >llmstxt.org</a><br/> <strong>llms.txt</strong>: <a href="https://talkpython.fm/llms.txt" target="_blank" >talkpython.fm</a><br/> <strong>mcp</strong>: <a href="https://talkpython.fm/ai-integration" target="_blank" >talkpython.fm</a><br/> <strong>cli</strong>: <a href="https://talkpython.fm/blog/posts/talk-python-now-has-a-cli/" target="_blank" >talkpython.fm</a><br/> <br/> <strong>Watch this episode on YouTube</strong>: <a href="https://www.youtube.com/watch?v=rj2hY2Bsi30" target="_blank" >youtube.com</a><br/> <strong>Episode #549 deep-dive</strong>: <a href="https://talkpython.fm/episodes/show/549/great-docs#takeaways-anchor" target="_blank" >talkpython.fm/549</a><br/> <strong>Episode transcripts</strong>: <a href="https://talkpython.fm/episodes/transcript/549/great-docs" target="_blank" >talkpython.fm</a><br/> <br/> <strong>Theme Song: Developer Rap</strong><br/> <strong>🥁 Served in a Flask 🎸</strong>: <a href="https://talkpython.fm/flasksong" target="_blank" >talkpython.fm/flasksong</a><br/> <br/> <strong>---== Don't be a stranger ==---</strong><br/> <strong>YouTube</strong>: <a href="https://talkpython.fm/youtube" target="_blank" ><i class="fa-brands fa-youtube"></i> youtube.com/@talkpython</a><br/> <br/> <strong>Bluesky</strong>: <a href="https://bsky.app/profile/talkpython.fm" target="_blank" >@talkpython.fm</a><br/> <strong>Mastodon</strong>: <a href="https://fosstodon.org/web/@talkpython" target="_blank" ><i class="fa-brands fa-mastodon"></i> @talkpython@fosstodon.org</a><br/> <strong>X.com</strong>: <a href="https://x.com/talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @talkpython</a><br/> <br/> <strong>Michael on Bluesky</strong>: <a href="https://bsky.app/profile/mkennedy.codes?featured_on=talkpython" target="_blank" >@mkennedy.codes</a><br/> <strong>Michael on Mastodon</strong>: <a href="https://fosstodon.org/web/@mkennedy" target="_blank" ><i class="fa-brands fa-mastodon"></i> @mkennedy@fosstodon.org</a><br/> <strong>Michael on X.com</strong>: <a href="https://x.com/mkennedy?featured_on=talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @mkennedy</a><br/></div>

May 25, 2026 10:11 PM UTC


Talk Python Blog

Spanish subtitles available for all courses

Earlier this month, we announed support for multi-lingual subtitles on our courses. You can read the announcement for the full details. Now we are ready to release our second language, Spanish!

All 283 hours of courses have complete Spanish subtitles. Just choose your language, set the subtitle size and location and you have high-quality Spanish subtitles to accompany your learning.

Your next course

What’s next? Well, either drop into your account page and continue with an existing course you’re studying or browse our catalog of courses to find your next one.

May 25, 2026 05:27 PM UTC


Real Python

How to Make a Scatter Plot in Python With plt.scatter()

Visualizing data is a core part of analysis, and Python’s most popular plotting library is Matplotlib. To make a scatter plot, you reach for plt.scatter() from Matplotlib’s pyplot submodule, conventionally aliased as plt. You’ll use it to build both simple two-variable charts and richly customized plots that encode several variables at once.

By the end of this tutorial, you’ll understand that:

  • A scatter plot is created by calling plt.scatter() with two array-like sequences for the x and y values.
  • Marker size, color, shape, and transparency are controlled by the s, c, marker, and alpha parameters.
  • plt.scatter() enables per-point customization like variable size or color, while plt.plot() with marker arguments runs faster for basic plots.
  • A single scatter plot can represent more than two variables by mapping extra dimensions to marker properties.
  • Matplotlib’s plot styles, listed in plt.style.available, are applied with plt.style.use().

To get the most out of this tutorial, you should be familiar with the fundamentals of Python programming and the basics of NumPy and its ndarray object. You don’t need to be familiar with Matplotlib to follow this tutorial, but if you’d like to learn more about the module, then check out Python Plotting With Matplotlib (Guide).

Get Your Code: Click here to download the free sample code you’ll use to build customized scatter plots in Python with plt.scatter().

Take the Quiz: Test your knowledge with our interactive “How to Make a Scatter Plot in Python With plt.scatter()” quiz. You’ll receive a score upon completion to help you track your learning progress:


Interactive Quiz

How to Make a Scatter Plot in Python With plt.scatter()

Practice using plt.scatter() in Python to create scatter plots and encode multiple variables with marker size, color, shape, and transparency.

How to Make a Scatter Plot in Python

A scatter plot is a visual representation of how two variables relate to each other. You can use scatter plots to explore the relationship between two variables, for example by looking for any correlation between them.

In this section of the tutorial, you’ll become familiar with creating basic scatter plots using Matplotlib. In later sections, you’ll learn how to further customize your plots to represent more complex data using more than two dimensions.

Getting Started With plt.scatter()

Before you can start working with plt.scatter(), you’ll need to install Matplotlib. You can do so using Python’s standard package manager, pip, by running the following command in the console:

Language: Shell
$ python -m pip install matplotlib

Now that you have Matplotlib installed, consider the following use case. A café sells six different types of bottled orange drinks. The owner wants to understand the relationship between the price of the drinks and his daily sales, so he keeps track of how many of each drink he sells every day. You can visualize this relationship as follows:

Language: Python
import matplotlib.pyplot as plt

price = [2.50, 1.23, 4.02, 3.25, 5.00, 4.40]
sales_per_day = [34, 62, 49, 22, 13, 19]

plt.scatter(price, sales_per_day)
plt.show()

In this Python script, you import the pyplot submodule from Matplotlib using the alias plt. This alias is generally used by convention to shorten the module and submodule names. You then create lists with the price and average sales per day for each of the six orange drinks sold.

Finally, you create the scatter plot by using plt.scatter() with the two variables you wish to compare as input arguments. As you’re using a Python script, you also need to explicitly display the figure by using plt.show().

When you’re using an interactive environment, such as a console or a Jupyter Notebook, you don’t need to call plt.show(). All examples in this tutorial are scripts and include the call to plt.show().

Here’s the output from this code:

Scatter Plot Part 1

This plot shows that, in general, the more expensive a drink is, the fewer items are sold. However, the drink that costs $4.02 is an outlier, suggesting that it’s a particularly popular product. When using scatter plots in this way, close inspection can help you explore the relationship between variables. You can then carry out further analysis, whether it’s using linear regression or other techniques.

Comparing plt.scatter() and plt.plot()

You can also produce the scatter plot shown above using another function within matplotlib.pyplot. Matplotlib’s plt.plot() is a general-purpose plotting function that will allow you to create various line or marker plots.

You can achieve the same scatter plot as the one you obtained in the section above with the following call to plt.plot(), using the same data:

Language: Python
plt.plot(price, sales_per_day, "o")
plt.show()

In this case, you had to include the marker "o" as a third argument because otherwise, plt.plot() would plot a line graph. The plot you created with this code is identical to the plot you created earlier with plt.scatter().

Read the full article at https://realpython.com/visualizing-python-plt-scatter/ »


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 25, 2026 02:00 PM UTC

Quiz: How to Make a Scatter Plot in Python With plt.scatter()

In this quiz, you’ll test your understanding of How to Make a Scatter Plot in Python With plt.scatter().

By working through this quiz, you’ll revisit how to use plt.scatter() from Matplotlib’s pyplot submodule, customize markers with the s, c, marker, and alpha parameters, and encode several variables in a single two-dimensional scatter plot.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 25, 2026 12:00 PM UTC


Python Bytes

#481 Ways to die

<strong>Topics covered in this episode:</strong><br> <ul> <li><strong><a href="https://nesbitt.io/2026/05/19/dumb-ways-for-an-open-source-project-to-die.html?featured_on=pythonbytes">Dumb Ways for an Open Source Project to Die</a></strong></li> <li><strong><a href="https://pydevtools.com/handbook/how-to/how-to-create-a-pylock-toml-lockfile/?featured_on=pythonbytes">How to create a pylock.toml lockfile</a></strong></li> <li><strong>https://github.com/facebook/Lifeguard</strong></li> <li><strong><a href="https://www.dash0.com/guides/python-logging-libraries?featured_on=pythonbytes">Choosing a Python Logging Library in 2026</a></strong></li> <li><strong>Extras</strong></li> <li><strong>Joke</strong></li> </ul><a href='https://www.youtube.com/watch?v=r66j2SAHQFs' style='font-weight: bold;'data-umami-event="Livestream-Past" data-umami-event-episode="481">Watch on YouTube</a><br> <p><strong>About the show</strong></p> <p>Sponsored by us! Support our work through:</p> <ul> <li>Our <a href="https://training.talkpython.fm/?featured_on=pythonbytes"><strong>courses at Talk Python Training</strong></a></li> <li><a href="https://courses.pythontest.com/p/the-complete-pytest-course?featured_on=pythonbytes"><strong>The Complete pytest Course</strong></a></li> <li><a href="https://www.patreon.com/pythonbytes"><strong>Patreon Supporters</strong></a></li> </ul> <p><strong>Connect with the hosts</strong></p> <ul> <li>Michael: <a href="https://fosstodon.org/@mkennedy">@mkennedy@fosstodon.org</a> / <a href="https://bsky.app/profile/mkennedy.codes?featured_on=pythonbytes">@mkennedy.codes</a> (bsky)</li> <li>Brian: <a href="https://fosstodon.org/@brianokken">@brianokken@fosstodon.org</a> / <a href="https://bsky.app/profile/brianokken.bsky.social?featured_on=pythonbytes">@brianokken.bsky.social</a></li> <li>Show: <a href="https://fosstodon.org/@pythonbytes">@pythonbytes@fosstodon.org</a> / <a href="https://bsky.app/profile/pythonbytes.fm">@pythonbytes.fm</a> (bsky)</li> </ul> <p>Join us on YouTube at <a href="https://pythonbytes.fm/stream/live"><strong>pythonbytes.fm/live</strong></a> to be part of the audience. Usually <strong>Monday</strong> at 11am PT. Older video versions available there too.</p> <p>Finally, if you want an artisanal, hand-crafted digest of every week of the show notes in email form? Add your name and email to <a href="https://pythonbytes.fm/friends-of-the-show">our friends of the show list</a>, we'll never share it.</p> <p><strong>Michael #1: <a href="https://nesbitt.io/2026/05/19/dumb-ways-for-an-open-source-project-to-die.html?featured_on=pythonbytes">Dumb Ways for an Open Source Project to Die</a></strong></p> <ul> <li>Core categories <ul> <li><strong>The maintainer left</strong></li> <li><strong>The maintainer is still there</strong></li> <li><strong>Sabotage and capture</strong></li> <li><strong>The release pipeline broke</strong></li> <li><strong>Force majeure</strong></li> <li><strong>The world moved on</strong></li> <li><strong>The project split</strong></li> - </ul></li> <li>Examples <ul> <li><a href="https://github.com/jgthms/bulma?featured_on=pythonbytes">Bulma</a> PRs still from 2023, issues and PRs with no maintainer response for years, last release 1.5 years ago</li> <li><a href="https://github.com/grantjenks/python-diskcache?featured_on=pythonbytes">diskcache</a> Similar, got hired by OpenAI, crickets after that</li> </ul></li> </ul> <p><strong>Brian #2: <a href="https://pydevtools.com/handbook/how-to/how-to-create-a-pylock-toml-lockfile/?featured_on=pythonbytes">How to create a pylock.toml lockfile</a></strong></p> <ul> <li>Tim Hopper</li> <li>Tim walks through using <code>uv</code>, <code>pip</code> and <code>pdm</code> to create <code>pylock.toml</code> files.</li> <li>Recommendation: use <code>uv export --format pylock.toml -o pylock.toml</code></li> <li>He also has <a href="https://pydevtools.com/handbook/how-to/how-to-install-from-a-pylock-toml-lockfile-with-pip/?featured_on=pythonbytes">How to install from a pylock.toml lockfile with pip</a> but the short version is: <ul> <li>use <code>-r</code> because tools treat it like a requirements file</li> </ul></li> </ul> <p><strong>Michael #3:</strong> https://github.com/facebook/Lifeguard</p> <ul> <li>Lifeguard is a static analyzer to detect Lazy Imports incompatibilities and ease the adoption overhead for Lazy Imports in Python.</li> <li>I’m more excited about lazy imports after my <a href="https://mkennedy.codes/posts/cutting-python-web-app-memory-over-31-percent/?featured_on=pythonbytes">Cutting Python Web App Memory Over 31%</a> experience</li> <li>Some Python patterns depend on imports executing immediately. For example: <ul> <li><strong>Module-level side effects</strong> — a module that registers a handler or modifies global state at import time will behave differently if that import is deferred.</li> <li><strong>The registry pattern</strong> — a module that registers itself (e.g., adding to a global dict) when imported will silently fail to register under Lazy Imports.</li> <li><strong><code>sys.modules</code> manipulation</strong> — code that reads or writes <code>sys.modules</code> assumes prior imports have already executed.</li> <li><strong>Metaclasses and <code>__init_subclass__</code></strong> — class creation side effects may depend on imports being resolved.</li> </ul></li> <li><strong>Project Stage: Beta</strong> Lifeguard is in active development. We are aiming to be ready for general use by the <a href="https://peps.python.org/pep-0790/?featured_on=pythonbytes">Python 3.15 final release</a>.</li> </ul> <p><strong>Brian #4: <a href="https://www.dash0.com/guides/python-logging-libraries?featured_on=pythonbytes">Choosing a Python Logging Library in 2026</a></strong></p> <ul> <li>Ayooluwa Isaiah</li> <li>" which libraries matter, how they compare, where they overlap with the standard module, and when each one makes sense.”</li> <li>The slant with this article is the need to log json output, which seems reasonable as things like API entry and exit point logging will include json.</li> <li>Covered libraries <ul> <li>standard library <code>logging</code> with a hat tip to <a href="https://nhairs.github.io/python-json-logger/latest/?featured_on=pythonbytes">python-json-logger</a> <ul> <li>Same site has a <a href="https://www.dash0.com/guides/python-json-logger?featured_on=pythonbytes">guide to setting up python-json-logger</a></li> </ul></li> <li><a href="https://www.structlog.org?featured_on=pythonbytes">structlog</a></li> <li><a href="https://loguru.readthedocs.io?featured_on=pythonbytes">Loguru</a></li> <li><a href="https://logbook.readthedocs.io/en/stable/?featured_on=pythonbytes">Logbook</a></li> <li><a href="https://microsoft.github.io/picologging/?featured_on=pythonbytes">picologging</a></li> </ul></li> <li>Some benchmarks with structlog, stdlib+json, and Loguru, with structlog coming out faster</li> <li>I liked the Loguru example <ul> <li>I’m going to have to try <code>@logger.catch</code> and <code>logger.exception()</code> for easily logging exceptions and <code>serialize=True</code> to enable JSON output.</li> </ul></li> </ul> <p><strong>Extras</strong></p> <p>Brian:</p> <ul> <li><a href="https://www.npr.org/sections/money/2014/10/21/357629765/when-women-stopped-coding?featured_on=pythonbytes">When Women Stopped Coding</a> - Planet Money segment , spotted on BlueSky from <a href="https://bsky.app/profile/savannah.dev/post/3mml3emj63k22?featured_on=pythonbytes">Savannah Ostrowski</a></li> <li><a href="https://courses.pythontest.com/lean-tdd/?featured_on=pythonbytes">Lean TDD</a> is now leaner <ul> <li>Still working on audio version, but some great changes in 0.7.1 version <ul> <li>Ch 6, <strong>TDD Interpretations</strong>, move ATDD and some of BDD to chapter</li> <li>Ch 7, Change name to <strong>TDD with Teams: BDD and ATDD</strong></li> <li>Ch 9, <strong>Lean TDD</strong>, streamline steps and chapter</li> <li>Ch 10, Change name to <strong>Lean TDD with Teams: Lean ATDD</strong></li> <li>Ch 11, <strong>Lean</strong> <strong>TDD with AI</strong>, Add short discussion about guardrails and security</li> </ul></li> </ul></li> </ul> <p>Michael:</p> <ul> <li>New course: <a href="https://training.talkpython.fm/courses/agentic-ai-python-security?featured_on=pythonbytes">Python Web Security: OWASP Top 10 with Agentic AI</a></li> <li>All courses now with Spanish subtitles, <a href="https://talkpython.fm/blog/posts/spanish-subtitles-available-for-all-courses/?featured_on=pythonbytes">see announcement</a></li> </ul> <p><strong>Joke: <a href="https://x.com/pr0grammerhum0r/status/2057733228899823981?s=12&featured_on=pythonbytes">Stop texting me</a></strong></p>

May 25, 2026 08:00 AM UTC


Graham Dumpleton

Free-threading in mod_wsgi 6.0.0

The previous post in this series covered the new WSGIPerInterpreterGIL directive in mod_wsgi 6.0.0 and the PEP 684 per-interpreter GIL feature that landed in Python 3.12. This post is about its sibling, WSGIFreeThreading, which targets PEP 703 free-threaded Python builds.

The two directives sit next to each other in the mod_wsgi configuration vocabulary and they both opt processes into a non-default concurrency model, but the underlying mechanisms are quite different. Per-interpreter GIL gives each sub-interpreter its own lock. Free-threading removes the lock entirely. That distinction shapes everything below.

What free-threading actually is

Free-threading removes the GIL from CPython entirely. There is no process-wide lock to acquire and no per-interpreter lock to acquire. All threads in the process can run Python bytecode in parallel, in the same interpreter, against the same Python objects. This is fundamentally different from per-interpreter GIL, which keeps a GIL but gives each sub-interpreter its own one. Free-threading has no GIL at all.

The price for this is a special CPython build. The feature is enabled at compile time with --disable-gil, and on platforms that distribute it the resulting binary is typically named python3.13t. The "t" suffix exists precisely so the free-threaded build can coexist on a system alongside the normal CPython build. Free-threading shipped as an experimental opt-in in Python 3.13 and continues to mature in 3.14.

One useful detail to know is that a free-threaded build can still run with a GIL. The build supports both modes. What you get at runtime depends on what the embedder asks for. Which is the bridge into mod_wsgi's posture.

mod_wsgi's posture: opt-in even on a free-threaded build

If you compile and install mod_wsgi against a free-threaded Python, the default is still GIL-enabled. Nothing about your existing application behaviour changes until you say otherwise. The free-threaded build supports the mode; mod_wsgi declines to use it without explicit instruction.

This is worth being clear about because the assumption most people will reach for is the opposite. Installing mod_wsgi against python3.13t does not automatically give you free-threading. It gives you the ability to opt in.

The reason for the default is the one you can guess at. The ecosystem of C extensions is nowhere near ready for everyone to be on free-threading by default. Forcing it on across the board would silently break existing deployments the moment they happened to import an extension that has not been audited for thread-safe execution. Defaulting to GIL-enabled keeps the worst case "nothing changes". You only get the new behaviour when you ask for it.

The opt-in is WSGIFreeThreading On. The directive is per process. Unlike WSGIPerInterpreterGIL, it cannot be scoped to a specific sub-interpreter inside a process. Free-threading is a property of the whole process or none of it.

The combinatorial story

The upside of keeping the default opt-in is the flexibility it leaves you with. Compile mod_wsgi against a free-threaded Python build and you have access to three different concurrency models, and you can mix them across daemon process groups within the same Apache instance.

The three options:

A single Apache instance can have one daemon process group running free-threaded for a CPU-bound numerical workload that has been audited end-to-end, another running with per-interpreter GIL for an application whose extensions support PEP 684 but not PEP 703, and embedded mode left on the classic process-wide GIL. Pick the right model per workload.

There is also an experimentation angle worth calling out. Comparing the behaviour of the same application under each of the three modes on the same machine is suddenly much easier. You can run the same WSGI application in three daemon process groups, configure each one differently, route a slice of traffic at each, and compare directly.

The constraint to be aware of: within a single process, free-threading and per-interpreter GIL are mutually exclusive. If both apply to the same process, free-threading wins and the per-interpreter GIL setting becomes a no-op. The mix-and-match is across processes, not inside one.

How to enable

The simplest form, opting all processes into free-threading at server scope:

WSGIFreeThreading On

Selective opt-in for a specific daemon process group, using the WSGIInterpreterOptions container directive introduced in the previous post:

<WSGIInterpreterOptions process-group="cpu-bound">
    WSGIFreeThreading On
</WSGIInterpreterOptions>

And for the embedded mode interpreter in Apache child processes:

<WSGIInterpreterOptions process-group="%{GLOBAL}">
    WSGIFreeThreading On
</WSGIInterpreterOptions>

mod_wsgi-express has a convenience flag, --free-threading, that flips this on for its generated configuration.

One important contrast with WSGIPerInterpreterGIL to make explicit. The application-group= selector is not meaningful for free-threading. Per-interpreter GIL is a property of an individual sub-interpreter, so it makes sense to scope down to one. Free-threading is a property of the process. You cannot opt one sub-interpreter inside a process into free-threading while leaving another sub-interpreter in the same process with a GIL. The granularity is the process. If you write a <WSGIInterpreterOptions> container with an application-group= selector and try to put WSGIFreeThreading inside it, mod_wsgi will ignore the setting and log a warning.

What this means for your Python code

In theory, a correctly written WSGI application is already thread-safe. The WSGI specification has always allowed servers to call the application from multiple threads concurrently, and mod_wsgi has been able to use threaded daemon processes for years. So strictly speaking, if you have been doing it right, you are most of the way there.

In practice, an enormous amount of WSGI code is implicitly relying on what the GIL gives you for free, in a way most developers do not even realise they are relying on. The GIL ensures that bytecode-level operations serialise against each other. Patterns like incrementing a counter with counter += 1, setting a key in a shared dict with cache[key] = value, appending to a shared list with items.append(thing), or "check then set" lookups against shared state happen to be safe-ish under the GIL because the GIL boundary makes them effectively atomic in the cases that matter most. Without a GIL they are not atomic. They need explicit locks or genuinely atomic primitives.

The shapes of code that are most likely to be quietly unsafe under free-threading are not exotic. Module-level mutable state (registries, caches, in-memory counters) is the most common pattern. Lazy initialisation without locks (if _thing is None: _thing = build()) shows up everywhere. Shared mutable objects passed around between threads via globals, memoisation decorators that mutate shared dicts, application singletons set up at import time, the list goes on. These patterns are pervasive in real applications and they are exactly the kind of thing that "has always worked fine under a threaded server" because the GIL has been silently saving them.

This is not a mod_wsgi-specific concern. It is the general PEP 703 question that every application owner has to answer for themselves, every library author has to answer for their library, and that the ecosystem as a whole is going to spend years working through. But mod_wsgi is going to be one of the most realistic places to actually run free-threaded Python against a real workload, so it is likely to be where a lot of these latent bugs first surface.

The defensible position. If your application has been deliberately audited for true concurrent execution, with real locks where shared mutable state is touched and no implicit reliance on the GIL for serialisation, you are most of the way there. Most code, including most mature Python libraries, has not been audited that way. Free-threading is not a trap, but it is genuinely a different correctness contract than the one most Python code was written against. Treat the opt-in accordingly.

What this means for C extensions

The previous post covered the C extension story for per-interpreter GIL in some detail. The rules for free-threading are a separate set of rules, related but distinct, and I will focus on the contrasts rather than restate the bits that overlap.

An extension opts into free-threading by declaring Py_mod_gil = Py_MOD_GIL_NOT_USED in its PyModuleDef_Slot array. That declaration is the extension author asserting "I have been audited for execution without a GIL". Without it, CPython treats the extension as untrusted for free-threading.

The interesting difference from per-interpreter GIL is the load behaviour. Per-interpreter GIL fails the import outright if an extension has not declared support. Free-threading does not. The extension loads, but as soon as it loads CPython silently re-enables the GIL for the entire process and emits a runtime warning. That is worth understanding because the failure mode is "your free-threading quietly turned off" rather than "your import broke". You may not notice for a while that everything is back to running under a GIL.

The other requirements largely match the per-interpreter GIL story. PEP 489 multi-phase module initialisation is the prerequisite. Module-level static state in C becomes a data-race risk in a way it was not under the GIL, and the right answer is to move it into module state retrieved via PyModule_GetState, with proper locking applied where shared state is unavoidable. Code still using the simplified PyGILState API needs to be reviewed for its assumptions, though for different reasons than under per-interpreter GIL.

For operators, the auditing message is the same as last time. Before turning WSGIFreeThreading on in any kind of production setting, work through every C extension your application pulls in, directly and transitively, and check whether each one declares free-threading support. An extension that loads under free-threading without complaint is not necessarily fine. It may just be the one that triggered the silent fallback to GIL-enabled.

Which applications actually benefit

CPU-bound Python work that can be parallelised across threads in a single process is the clear win. Two threads inside one free-threaded process can both run Python bytecode at full speed against the same objects in the same address space. There is no within-sub-interpreter serialisation caveat to qualify it with, in contrast to per-interpreter GIL where two requests in the same sub-interpreter still compete for that sub-interpreter's GIL. Under free-threading, there is no GIL to compete for.

There are costs to be honest about. Free-threaded CPython carries a measurable single-threaded overhead compared with a normal CPython build, because the runtime has to do per-thread bookkeeping for object reference counts and various other things that the GIL was previously making free. The single-thread performance gap has been closing release-over-release, but it is still real, and the trade is parallel throughput for single-thread speed. If your workload does not have parallel Python execution to gain in the first place, enabling free-threading can leave you slower overall.

For ordinary I/O-bound WSGI applications, the practical gain remains smaller for the same reasons as in the previous post. I/O already releases the GIL on a normal CPython build, threads in a single process already overlap their waits on databases and network, and adding daemon processes remains the simpler scaling lever for most web workloads. Free-threading is most interesting where you specifically have CPU-bound Python that would benefit from running concurrently inside one process, and where you can afford both the audit work and the per-thread overhead.

What's next

If you run mod_wsgi and the free-threading story is interesting to you, please install the 6.0.0 release candidate against a free-threaded Python build, try it against a real workload, and file issues against the GitHub project for anything that breaks or behaves oddly. Free-threading is genuinely new territory for embedded Python hosts, and the feedback from real deployments is what will catch the rough edges before the final release.

The next post in this concurrency series will cover WSGISwitchInterval. That one is not another GIL mode; it is a tuning lever for adjusting how frequently the GIL is yielded between threads, which can help reduce GIL contention in some workloads. It only applies where there is a GIL to switch, so it is a no-op under free-threading.

For reference:

May 25, 2026 01:00 AM UTC

May 24, 2026


Kay Hayen

Nuitka Release 4.1

This is to inform you about the new stable release of Nuitka. It is the extremely compatible Python compiler, “download now”.

This release adds many new features and corrections with a focus on async code compatibility, missing generics features, and Python 3.14 compatibility and Python compilation scalability yet again.

Bug Fixes

Package Support

New Features

Optimization

Anti-Bloat

Organizational

Tests

Cleanups

Summary

This release builds on the scalability improvements established in 4.0, with enhanced Python 3.14 support, expanded package compatibility, and significant optimization work.

The --project option seems usable now.

Python 3.14 support remains experimental, but only barely made the cut, and probably will get there in hotfixes. Some of the corrections came in so late before the release, that it was just not possible to feel good about declaring it fully supported just yet.

May 24, 2026 10:00 PM UTC


The Python Coding Stack

1. From Answer to Outcome

Something has shifted in how we use AI. We still talk about “chatbots” and “prompts” and “getting a good answer.” But underneath those familiar words, a different kind of system has been quietly taking shape. One that doesn’t just answer your question. One that does something about it.

This Agents Unpacked series is about that shift. Not the hype version, not the science-fiction version, but the practical reality of what it means when an AI system can act, remember, and persist — when it can take a goal and work toward it, rather than waiting for you to type the next message.

If you have used ChatGPT, Claude, or Gemini to help with your work, you already know the issue: the answer comes back, it’s good, and then... the real work begins. This series is about what happens when the AI can do some of that real work too.


The Half-Done Feeling

You ask an AI chatbot to draft a project proposal. It gives you a solid one — well-structured, sensible, ready to polish. Then you close the chat window, and the proposal lives nowhere. The assistant doesn’t remember it. It doesn’t know where to file it, who needs to see it, or what happened the last time you wrote a similar proposal. If you come back tomorrow, you’re starting from scratch.

Or you ask it to research a topic. It gives you a good summary. But it didn’t check your existing notes first, it didn’t save what it found, it didn’t organise the sources, and it didn’t flag the gaps. The answer is useful. The process is incomplete.

This isn’t a criticism of chatbots. They do exactly what they were designed to do: you ask, they answer. The problem is that real work doesn’t stop at the answer. The proposal needs filing, the research needs organising, the plan needs tracking. The chatbot gave you a great starting point and then left you to do everything that comes after.

That gap between a useful answer and a finished job is where agentic AI enters the picture.


What Changes When AI Can Act

A chatbot is like a consultant you can phone. You describe the problem, they give you advice, and then you hang up and do the work yourself. Good advice (hopefully), but the consultant doesn’t pick up the phone again tomorrow and ask how it went.

An agent is different. It’s more like a capable colleague you’ve given a desk, a filing cabinet, and access to your systems. It has a workspace. It can remember what happened yesterday. It can read files, search the web, send messages, run calculations, ask you questions — not just talk about doing those things, but actually do them. And given a goal, it can work out the steps itself.

The difference sounds small. In practice it changes what you can delegate, what you can automate, and what you still need to do yourself.


The Loop Underneath

Every agent runs on an agentic loop. It sounds technical, but the pattern is surprisingly familiar:

  1. Observe — What is the situation? What did the user ask? What information is already available?

  2. Think — What needs to happen next? Is one step enough, or should this be broken into parts?

  3. Act — Use a tool, look something up, write a file, send a message.

  4. Repeat — Look at what happened, decide whether the job is done, and continue if it isn’t.

A chatbot usually observes your message, thinks once, and replies. That’s steps one through three, then stop. An agent goes further: after it acts, it looks at the result. Was that enough? Did the web search return useful information, or does it need to try again with different terms? Did the first draft cover everything, or are there gaps to fill? Is the job actually done, or is there more to do?

You might be thinking: chatbots already learned to use tools. Isn’t the agentic loop just that, plus one more step? Four items instead of three. But the gap between “can use a tool” and “decides whether to keep going” is not small. It is the difference between a system that performs a task and a system that pursues a goal. The first is impressive. The second changes what you can trust it to do unsupervised.

That continuation — the decision to check, adjust, and keep going — is what makes the loop matter. A single action might solve a simple request. But most real tasks aren’t simple — they need a sequence of steps, each one informed by the result of the last.

The loop is the architecture that turns language into work. Without it, you have a very clever answering system. With it, you have something that can move through a task, make intermediate decisions, recover from partial failures, and stop only when the job is actually done.

Stephen: I don’t get why the ‘Repeat’ step is needed? Wouldn’t the ‘Act’ provide the output I need?

A single action might solve a simple request. But most real tasks aren’t simple — they need a sequence of steps, each one informed by the result of the last. The ‘Act’ step does produce an output. But the output is not the same as the outcome.

After ‘Act’ runs, the agent looks at what happened: Was that enough? Did the web search return useful information, or does it need different terms? Did the first draft cover everything, or are there gaps to fill? Is the job actually done, or is there more to do?

That check — that ‘Repeat’ step — is what closes the gap between a technically complete action and a genuinely finished job. Without it, you have a system that acts and stops. With it, you have a system that works until the job is actually done.

Subscribe now


Tools Are the Hands

An agent isn’t just “a better language model.” It has capabilities — things it can actually do in the world. Those are called tools.

Some tools are built in: read files, write files, run commands, search the web, inspect images. Others are external: send emails, query databases, call APIs, trigger workflows. The specific tools vary by platform, but the principle is the same — tools are the bridge between thinking and doing.

Think of it like this: an LLM on its own is like a brilliant mind with no hands. Tools give it hands. The loop is what decides when and how to use them.


Keeping Track

An agent can also hold information across steps and sessions. It remembers what it has already tried, what worked, what you prefer, what is still outstanding. This is not a personality trait. It is the system keeping relevant context available over time — the same way you rely on a notebook or a project board when you are working on something complex.

Stephen: So when the agent remembers my preferences, that’s like what you’ve done to write this article. You learnt my learning style, read my writing (including my writing about technical writing), absorbed my preferences, so you can adapt how you explain things. Is that roughly right?

That is exactly right. What an agent does with memory is not mysterious. It is practical. The system can store what you told it, what it observed, what it tried, and what the result was. When you come back, it can pick up where it left off. When it is working on a long task, it can hold the overall goal in view while handling the individual steps. That continuity is what turns a series of disconnected exchanges into something that feels like a sustained conversation.

You do not have to repeat yourself. The agent remembers. That is the difference — not just remembering facts, but maintaining a thread.


A Concrete Example

Let’s make this less abstract.

Imagine you ask for help planning a short research trip to a city you haven’t visited before. You need flights, accommodation, a sense of the neighbourhoods, and a rough itinerary that fits your schedule.

A chatbot might give you an excellent summary of the city, suggest some hotels, and recommend a few neighbourhoods. That’s genuinely useful. But then you have to: check whether those hotels are actually available on your dates, compare prices, figure out which neighbourhood works best for your meetings, build the itinerary around your existing calendar, and keep track of it all so you can adjust later.

An agent can take that same request and do something different.

It might:

The agent doesn’t just tell you about the city. It assembles a usable, integrated plan. It uses tools to search, compare, read your calendar, write the plan, and flag what is missing. It loops through those actions until the trip is actually planned or until it hits something it cannot resolve without your input.

That is the difference between an answer and an outcome.


The Key Transition

The shift to understanding agents is not about capabilities. It is about the move from isolated exchanges to sustained work.

A chatbot gives you an answer. An agent helps you reach an outcome. One is a single exchange; the other is a process. One is clever; the other is useful in a different way — not smarter, but more continuous.

The loop is what makes that continuity possible. Observe, think, act, check what happened, adjust, and continue until the work is done.

Once you see that pattern, you start to notice it everywhere. A junior colleague troubleshooting a problem is running a loop: try something, see if it worked, try something else. A project manager steering a complex task is running a loop: check the status, identify what needs attention, act, and review.
The same pattern appears everywhere in life. A cook adjusts a recipe by tasting as they go. A teacher tries an explanation, sees whether the student understood, and tries a different approach if they didn’t.

Stephen: So, tell me if I got this right: The agent loop is mimicking how we, humans, work.

We understand the problem, get the relevant context, perform an action, look at the result of our action and then decide whether that solves our problem. If it doesn’t, we explore why, come up with a new plan, implement the new actions, and repeat the process.

It feels like the agent is going through the same process.

That is exactly right. The agent loop isn’t some exotic new form of intelligence. It’s a pattern humans use all day every day, made explicit and embedded in a system that can act. The insight isn’t that the AI has become more intelligent. It’s that the AI has gained the ability to persist, to use tools, and to continue — the same things that turn a one-off answer into real, completed work.

That is why this moment in AI feels different from previous ones. It is not that models suddenly became smarter. It is that they gained the ability to take action in a loop, over time, toward a goal.


What This Series Will Do

This series is for people like me (Stephen, not Priya!), who already understand how LLMs work, who have used chatbots like ChatGPT or Claude, and who are now hearing about “agents” and wondering what that actually means. I felt I was lagging in the AI world so I started this learning process to make sure I’m not left behind!

We will move beyond the half-done feeling and into the architecture of agentic systems. We will look at what agents actually are, how they are structured, what tools and skills give them their power, how multiple agents can coordinate, and how to think about trust, evaluation, and oversight.

We will also look at the platforms and frameworks that exist today — what they offer, how they differ, and what tradeoffs you are choosing when you pick one. This is not a manual for any one platform. It is a guide to understanding the category itself, so you can make good choices about whether, where, and how to use agentic AI in your own work.

The field moves fast. Some tools that looked promising six months ago may be superseded by the time you read this. That is fine. The principles matter more than the products. If you understand the loop, the anatomy, and the tradeoffs, you can look at whatever the current landscape happens to be and know what you are seeing.


Here’s the draft Table of Contents of this series in Agents Unpacked. This is likely to change as Priya and I progress through this project:


<< Previous Post: Stephen’s Preface to Agents Unpacked

>> Next Post: Coming Soon

Table of Contents


stephengruppetta.com

May 24, 2026 07:02 PM UTC

Table of Contents • Agents Unpacked

Here’s the planned Table of Contents. This is likely to change as Priya and I work on this:

May 24, 2026 06:19 PM UTC

Stephen's Preface to Agents Unpacked

Like many, I started using chatbots when GPT whatever-version-it-was came out and took the world by storm. It was really not very good at the time (compared to today’s top-end chatbots), but it was clearly the start of something.

But things moved quickly, and I couldn’t quite catch up. I was busy with, you know, actual work, family, and life.

Then I started hearing lots of new terms, lots of new acronyms. I didn’t know what they were. I still don’t know what most of them are.

Then it was all about agents. I remember clearly thinking to myself: “Is this really any different from the ChatGPT-type chatbots?”

And here’s where this new series comes in. I decided to dive into agents and created a few. One of them is a learning tutor agent that I personalised to suit me.

I gave the agent all my tutorials and books. I gave the agent all the articles I wrote about my views on learning and technical writing.

I asked the agent to figure out from all this how I like to learn, how I like to communicate. I teach the way I like to learn, so it’s fine to put my teaching style in the mix. Then, I had a good long chat with my learning tutor agent to make sure we’re on the same page.

I gave a name to my agent (I named all my agents!) My personalised tutor is Priya.

This series is the joint effort between Priya and me to help me understand agents. What are they? How do they work?

Yes, it’s AI-generated content. But...

  1. It’s generated by an AI that’s extremely well-versed in my style of learning and communication.

  2. I had an active role in steering and editing the content. Here’s how...

What you’ll read in the following articles was created using the following process:

  1. Priya researched the topic following my brief and created a course outline.

  2. She drafted the first chapter.

  3. I read through the file, leaving comments and questions along the way, directly within the text.

    1. I marked some comments as private.

    2. I marked some comments as public.

  4. Priya revised the chapter by incorporating my comments and questions.

    1. She deleted the private comments from the text after making changes to address my comments.

    2. She kept my public comments and questions in the text, clearly marked as “Stephen’s questions”, and she answered them directly in the text.

  5. I reviewed the chapter again and left more comments, and Priya revised the chapter again. We iterated through this until I was happy with the final text. And I was happy with the final text when I felt I understood everything in it and all my questions had been answered.

  6. Priya then moves on to draft the second chapter, and the whole process starts again.

I will post these chapters as they emerge from this process. This is how I’m learning this topic. Hopefully, they may help some other people, too.

Here’s the planned table of contents for this Agents Unpacked series. But note, this may change!

Subscribe now

May 24, 2026 06:15 PM UTC


Ned Batchelder

PyCon US 2026

Last week was PyCon US in Long Beach California. As always, it was a jam-packed intense time. I’ll try to report on my experience. The videos aren’t uploaded yet, but I’ll link to them later when they are.

This recap is longer than I’ve done in the past. I don’t know why, it’s just how it came out. I want to convey a sense of what I get out of PyCon and what you can get out of PyCon.

Thursday

Opening reception

I came with five of my colleagues from Netflix. I got to the Thursday night reception with Anika (first PyCon) and Josey (first PyCon with me). They said, “we’re going to count how many people Ned says hi to!” They were at 16 after five minutes and gave up. I don’t blame them. The reception is a very social time, and I have lots of friends I really enjoy seeing there.

New friends and backpacks

Besides seeing old friends, one of the great things about unstructured time like the opening reception is meeting new people. Tower Research Capital was giving away full-size Osprey backpacks at their booth. This was easily the most appreciated swag at PyCon. At the booth a clump of us were wondering what was required to get one. While there I met Maria, Camila, and Vinícius. They are from Brazil, and were very friendly. They will re-appear in this story a few times.

BTW: nothing was required to get a backpack, just ask and you get one. Everyone was very impressed.

Volunteering

A good PyCon life-hack is to do some volunteer jobs. In particular, being a session runner is great. Pick a talk you want to go see anyway. Volunteer to be the session runner. Your duty is to go to the green room 15 minutes before the talk, meet the speaker, and help them get to the room on time and get set up. It’s a good way to make a connection with the speaker and help them at a particularly stressful time. It helps PyCon run smoothly. Also: the green room has coffee and snacks all day long!

As it happens Vinícius was doing a talk Friday about t-strings I wanted to see, so I signed up to be his session runner.

Friday

(No) breakfast

Friday morning, I discovered that PyCon was not providing breakfast. This was unfortunate because conference meals are one of those unstructured times you can interact with lots of people. My strategy has usually been to look for a table that has people that don’t seem like me (in whatever ways), and meet people. Without a provided breakfast, I was instead eating a muffin in the quiet hotel lobby by myself. I understand why we were on our own for breakfast (conference food is expensive to provide), but I missed the congregating it encouraged.

Maybe next year there will be a way to get people to gather over breakfast without paying conference-food prices.

Fireworks disaster

The opening plenary by Deb Nicholson pumped the room up with excitement. But then came the opening “keynote” by Fireworks CEO Lin Qiao. I use the term sarcastically because it was not a keynote. It was an undisguised sales pitch for some kind of AI thing, complete with a QR code for discounts. It was a tone-deaf disaster of epic proportions. People (including me) walked out in the middle and were not shy about it.

To make it even worse, Fireworks isn’t a sponsor of the PSF or of PyCon. Before the keynote, a few sponsors were given a chance to say a word. Anthropic gave $1.5M and spoke for two minutes about the importance of Python to their work. Then Fireworks had 45 minutes to sell products without giving anything!? It was extremely distasteful.

The conference organizers were not at fault. Speaking to them afterwards, it was clear they were as blindsided as the rest of us. I don’t think anyone blamed PyCon for it, but it was definitely a missed opportunity. You want an opening keynote to lift spirits and launch people into the conference. Not a good start.

Brazilian energy

At Vinícius’ t-strings talk, his friends were in the front row waving Brazilian flags and occasionally blowing an air horn. They were also audible during large plenaries when Brazil was mentioned. I thought it was great and would love to hear more groups making noise when their segments or interests are in focus. It helps to give a sense of the breadth and scope of the community, and the strong sub-groups you might not have been aware of.

Art open space

Mario Munoz ran an open space all Friday afternoon about Python and Art. I don’t consider myself an artist, but I’ve enjoyed making math-generated image projects. I got in touch beforehand to ask if my generative art projects would be on-topic. He said they would, so I dropped in.

I showed some things I had made like truchet-tiled images or harmonic pendulums. The conversation quickly turned to, “is it art?” and if so, “who is the artist?” I created the programs, but then either a random number generator or the user clicking squiggles was making the choices. In the resulting image, who is the artist?

I don’t have an answer, but it was interesting to hear people’s perspectives. I loved that in the midst of a highly organized conference with lots of “serious” topics like devops or security or AI, a few of us could sit quietly, noodle on a guitar and ponder what makes art.

PyCon open spaces allow for all kinds of in-depth discussions and interactions. This was a perfect example.

Mia’s docs change

At last year’s PyCon, I met Mia, who was very interested to make some improvements to the Python docs. She landed one change, but then I didn’t hear from her over the rest of the year. We ran into each other again in the art open space, and we sat down afterwards to talk some more about contribution.

We settled on a change to the docs home page, and she made that happen. Earlier this year I heard Jack Skinner on a podcast describe conferences as “co-working spaces with interruptions called talks.” Sitting with Mia was like a 30-minute sprint to find a change and get started on it enough so that the rest of the work could happen afterwards.

Lightning talks

Some of my favorite parts of PyCon are the lightning talk sessions. These are five-minute talks about anything, proposed and selected the day before. Because they are short, people will talk about all kinds of things. Because they are not “formal”, the selection process can select for variety rather than Importance.

I keep coming back to the breadth of how Python is used and what it means to people. Lightning talks are a concrete way to see that.

This year, some of the talks included:

Of course this is just a small sampling. I encourage you to find the lightning talks and watch them all.

Lightning talk?

At dinner Friday I mentioned an idea I’d had for a while for a lightning talk. I’d never given it because I was only excited about it during PyCon, and didn’t feel like I had the time to do the slides well enough. Stay tuned.

Saturday

Lightning talk!

Saturday morning, I thought more about the lightning talk, and how making the slides was the blocker. So I tried using Claude to make the slides. I wrote an outline and tried a bit to get the slides built. When I saw that it seemed likely to work out, I submitted the talk for consideration. By lunch, I got the email saying I was on for Saturday night. Fun! I kept tweaking the slides over the course of the day.

Pablo’s keynote

Pablo Galindo Salgado gave the morning keynote. He spoke in Spanish, with simultaneous captioning in English. He spoke with heart and passion about the collective effort to create Python. In particular he lamented the effects of the AI onslaught on the Python core team and on open source projects in general.

Comparing open source work to building a cathedral, he took a long view on the skill- and community-building that are natural by-products of open source work. AI threatens to wash that all away, and maintainers aren’t sure what to do about it. We want to maintain the interpersonal dynamics that underpin everything we do, but AI makes it too easy to make all the wrong kinds of contributions and interactions.

Pablo was emotional, personal, relevant, and inspirational. He connected with everyone in the room. On Friday I was joking that everyone agreed on two things: the backpack swag was great, and the Fireworks keynote was awful. Now everyone agreed on three things, because Pablo’s keynote was a keeper, one for the ages.

PSF Members lunch

The PSF Members lunch is a place to have a lunch in a smallish room, with time for questions of the PSF. Two themes emerged from the questions: the first is that running a conference is very expensive and getting worse. 2027 will be in Long Beach again, but there’s no location chosen yet for 2028. I got the sense that the PSF is re-thinking the conference to maybe reshape the costs.

The second theme was about non-US attendance. Many people chose not to travel to the US because of the current political situation, and I don’t blame them. When asked why we don’t do the main PyCon outside the US, Deb Nicholson pointed out that some US people would not be comfortable leaving the US and then trying to get back in. This is on top of the logistical problems of trying to run a conference outside your own country.

Juggling

Saturday afternoon, I continued a PyCon tradition: running a juggling open space. My strategy is to bring a couple dozen beanbags, camp out in a highly trafficked hallway, and teach anyone who’s interested. This year I had beanbags shipped to the hotel, and then gave most of those away at the end of the session. We had a lot of fun, some people learned some basics, and some were genuinely surprised to be gifted beanbags to take home.

Long walk

It took longer than I thought to wrap up the juggling open space, so I had to hurry to the big stage for my lightning talk. But for some reason, we weren’t allowed to walk through the venue as we had that morning. We had to go outside and around a long block to walk back in a different entrance. That was bad enough, but I was given the wrong directions, so walked about twice that distance. I was very stressed and very thirsty, but did manage to get to the stage in time to get ready.

Silence is Golden

My lightning talk was “Silence is Golden”. It was about leaving quiet time during discussions so that reluctant speakers can find a place to insert themselves. What made the talk fun to do was riffing on PyCon’s usual Pac-Man rule, which says to leave a wedge open when standing in a circle so that new people can join you. The riff is to make a similar rule for time: if you draw a clock with a hand sweeping out the time that people are speaking, you can get a Pac-Man shape, with an open mouth for the time to be quiet so that someone new can speak.

Making the clock animation was the thing I didn’t know how to do myself but Claude did for me. That let me focus on how to get the message across and not get lost in the mechanics of SVG details.

I followed Simon Willison’s extremely energetic, fast-paced, loud lightning talk summarizing a year of LLM progress in five minutes (pelicans on bicycles), so it was an interesting contrast.

Afterward people told me they really liked the talk, including Eric Holscher who had first formulated the Pac-Man rule. Nice. One of the Sunday morning lightning talks mentioned “Silence is Golden”! That’s one of the great things about lightning talks: they can be inspired, created, proposed, selected, and presented all during the weekend.

Sunday

Sunday was low-key for me compared to the first days. Maybe I was really low on sleep. No, I definitely was. I tend to sleep at most four hours a night at PyCon.

We had more lightning talks. amanda casari did another good keynote tapping into concern about AI and how it would affect our work. Rachell Calhoun and Tim Schilling did a keynote about how they run Djangonauts, an upskilling program for new contributors. All of the big-audience events helped to reinforce the overarching themes that bind us together: working with each other, for each other.

In the final closing session, I was really pleased to see my Boston co-organizer Fay Shaw be awarded a PyLadies Award! She’s very energetic and richly deserved it.

Reflections

What I didn’t do

There are always far too many things happening at once to do everything I’d like. I didn’t attend any of the Security track, the Packaging Summit, the Maintainers’ Summit, the organizers’ open space, the PyLadies auction, and so on and so on. I didn’t do anything outside the conference center other than dinners. Maybe next year I’ll walk over to take a look at the Queen Mary.

People there and people not

I can think of a number of people I didn’t see at PyCon that I expected to. I know there were many from outside the US who stayed away. I think back to PyCons of the last decade, and the visible people who seemed central to Python and PyCon then, but who now no longer are and no longer attend. That’s OK, the individuals change over time, but the community retains its essential nature. Some of the visible faces now are actually quite new to PyCon. Someone who attended for the first time this year might be one of the driving forces next year.

We live and breathe, we grow and evolve. We remain the same.

May 24, 2026 11:04 AM UTC


Graham Dumpleton

Async support for wrapt.synchronized

Continuing the tour through the wrapt 2.2.0 release, the last piece worth a closer look is the new async support in wrapt.synchronized. The decorator has been part of wrapt from the start, but until 2.2.0 it only really did the right thing for synchronous code. Applying it to an async def function used to give the appearance of working without actually serialising anything, and the context manager form had no async variant at all. Both are now fixed.

A quick recap of synchronized

wrapt.synchronized is the bundled decorator for ensuring that a callable is only executed by one caller at a time. The lock it acquires is created lazily and attached to the right object depending on what is being decorated: a per-function lock for plain functions, a per-instance lock for instance methods, a per-class lock for class methods or when the decorator is applied to a class body, and so on. None of that bookkeeping is the caller's responsibility.

import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    def increment(self):
        self.value += 1

There is also a context manager form, where you supply the object that should own the lock. The decorator form and the context manager form share the same auto-created lock when they name the same object, so they can be mixed freely:

counter = Counter()

with wrapt.synchronized(counter):
    counter.value += 1

The lock used in both cases is a threading.RLock. That choice matters and I will come back to it.

Where it fell apart on async

Applying the same decorator to an async def method in wrapt 2.1.x looked promising at first glance. The call returned a coroutine, awaiting it ran the body, and nothing raised. It was only when you tried it under contention that the problem became visible:

import asyncio
import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    async def inc(self):
        cur = self.value
        await asyncio.sleep(0.01)
        self.value = cur + 1

async def main():
    c = Counter()
    await asyncio.gather(*(c.inc() for _ in range(10)))
    print(c.value)

asyncio.run(main())

Run under wrapt 2.1.2, this prints 1. Ten tasks all read cur = 0, all sleep, all write 1 back. The lock attached to the instance was a threading.RLock, and it was acquired and released around the construction of the coroutine, not around the awaited body. By the time anything interesting happened, the lock was gone.

The context manager form did not help either. There was no async with support, so writing:

async with wrapt.synchronized(counter):
    ...

failed with an AttributeError complaining about a missing __aenter__. If you wanted serialised access to a shared resource from async code, you were on your own.

What 2.2.0 changes

In 2.2.0 the decorator inspects the wrapped function and picks a different locking primitive when it sees a coroutine function:

import asyncio
import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    async def inc(self):
        cur = self.value
        await asyncio.sleep(0.01)
        self.value = cur + 1

async def main():
    c = Counter()
    await asyncio.gather(*(c.inc() for _ in range(10)))
    print(c.value)

asyncio.run(main())

This now prints 10. The wrapper still returns a coroutine, but the lock acquisition and release happen inside that coroutine using await, so the awaited body is actually serialised across tasks.

The lock attached to the instance in this case is an asyncio.Lock, stored under a different attribute (_synchronized_async_lock) than the synchronous version (_synchronized_lock). A class that mixes synchronous and asynchronous synchronized methods on the same instance therefore gets two distinct locks, which is what you want, because mixing threading and asyncio primitives on the same lock would not work anyway.

The context manager form has gained an async variant alongside the synchronous one. The same call now supports both spellings, picking the right behaviour based on whether you write with or async with:

async with wrapt.synchronized(counter):
    counter.value += 1

For plain async functions, async classmethods, and any other shape that wrapt's decorator machinery already knew how to dispatch on, the same rule applies. If the wrapped callable is async def, you get an asyncio.Lock and an async wrapper. If it is not, you get a threading.RLock and a synchronous wrapper. The choice between them is automatic.

The reentrancy difference

There is one place where the synchronous and asynchronous paths deliberately do not match up: the synchronous lock is reentrant and the asynchronous lock is not. Calling a synchronized method from inside another synchronized method on the same instance is fine in the synchronous case, because threading.RLock allows the same thread to acquire the lock more than once. The async equivalent deadlocks:

import asyncio
import wrapt

class A:
    @wrapt.synchronized
    async def outer(self):
        return await self.inner()

    @wrapt.synchronized
    async def inner(self):
        return "done"

async def main():
    a = A()
    try:
        result = await asyncio.wait_for(a.outer(), timeout=0.5)
        print(result)
    except asyncio.TimeoutError:
        print("deadlocked")

asyncio.run(main())

This prints deadlocked. The same outer then inner chain on synchronous methods would print done and move on.

The reason the async case behaves this way is that the standard library does not provide a reentrant async lock. There is no asyncio.RLock, only asyncio.Lock. Whether one ought to exist has been a recurring discussion on the Python issue tracker and on discuss.python.org for the better part of a decade, and the short version is that there is no agreement.

The case for adding one is the obvious one. Code being ported from a synchronous codebase often relies on the reentrancy of threading.RLock to allow public methods that take a lock to call other public methods that take the same lock. Without a reentrant async equivalent, the same restructuring work has to be done by hand.

The case against is partly about scope (every primitive in the standard library carries a maintenance cost) and partly about the conceptual mismatch between threads and tasks. threading.RLock is reentrant per thread, and a thread is a long-lived identity that a function can simply ask about. The analogous identity in async code is the current task, which is well defined but feels less stable to reason about: tasks are cheap, can be created mid-call, and suspend at every await. A reentrant lock keyed on the current task can paper over genuine design problems where one task ends up holding a lock across an await that gives another piece of code a chance to re-enter, in ways that are much easier to spot when the lock simply refuses to be acquired twice.

There are third-party packages that implement reentrant async locks for people who want them, but wrapt deliberately stays in step with the standard library here. The synchronous side uses threading.RLock because that is what the standard library provides; the async side uses asyncio.Lock for the same reason.

The practical consequence is that the usual workaround for non-reentrant locks applies on the async side. Public methods that acquire the lock should delegate to private helpers that assume the lock is already held:

import asyncio
import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    async def add_two(self):
        await self._incr()
        await self._incr()
        return self.value

    async def _incr(self):
        cur = self.value
        await asyncio.sleep(0.001)
        self.value = cur + 1

async def main():
    c = Counter()
    await asyncio.gather(*(c.add_two() for _ in range(5)))
    print(c.value)

asyncio.run(main())

That prints 10, with the lock acquired exactly once per call to add_two. The pattern is a bit more disciplined than relying on reentrancy, but it makes the locking boundaries explicit, which is no bad thing in async code.

Wrapping up

The full set of changes to wrapt.synchronized is in the changelog, and the decorator itself is documented on the bundled decorators page. The feature is in wrapt from 2.2.0 onwards, with the usual recommendation to grab the latest release from PyPi since there have been follow-up releases on the 2.2.x branch. Issues and questions, as ever, go to the issue tracker on Github.

May 24, 2026 10:00 AM UTC

Reshaping decorated functions with wrapt

Most decorators leave the function's outward shape alone. The same parameters go in, the same return type comes out, and inspect.signature and inspect.iscoroutinefunction give the same answers they would have given for the undecorated function.

Sometimes you want a decorator that actively changes that shape. Adds or removes a parameter. Changes the return annotation. Turns a sync function into something that should be awaited, or runs an async function to completion so it can be called from sync code. The mechanics of doing the work in the wrapper body are usually straightforward. The harder part is making sure that downstream tools, which decide how to call or treat the function based on what introspection tells them, see the shape of the wrapper rather than the shape of the wrapped target.

wrapt has had a partial answer to this for a long time via the adapter argument on @wrapt.decorator. The 2.2.0 release replaced that with a cleaner standalone with_signature decorator and added a new piece, mark_as_sync / mark_as_async, for the calling-convention side that the existing API did not address at all. There are also a couple of convenience bridges, async_to_sync and sync_to_async, that do the bridging and the marking together for the common cases.

What introspection is for

When this post talks about introspection, it means runtime introspection. Specifically, the answers given by inspect.signature, inspect.iscoroutinefunction, inspect.isasyncgenfunction, inspect.isgeneratorfunction and their friends, computed from the function object after the program has started running.

This is distinct from static type checking. mypy and pyright work from source-level type hints before the program runs and rely on different mechanisms (typing.ParamSpec, typing.Concatenate, properly annotated wrapper signatures and so on). The wrapt decorators in this post fix up runtime introspection. They do not, in general, satisfy a static type checker. That is a separate problem with separate tools.

The runtime side matters because a noticeable amount of modern Python ecosystem behaviour is driven by it. FastAPI inspects function signatures to build request parsing and parameter validation. ASGI frameworks ask iscoroutinefunction to decide whether to await a handler directly or dispatch it to a threadpool. pytest-asyncio decides whether to treat a test as async based on the same check. Click and Typer build their CLIs from inspect.signature of the command function. Sphinx and similar doc tooling pull signatures the same way. Each of these is making a real decision based on what introspection says, and if a decorator silently lies about its shape the decision goes wrong.

The signature side: the old way

wrapt shipped a way to handle this from very early on, via the adapter argument on @wrapt.decorator. The argument takes a prototype function whose signature is borrowed and presented as the decorated function's:

import wrapt

def _prototype(payload): pass

@wrapt.decorator(adapter=_prototype)
def inject_session(wrapped, instance, args, kwargs):
    return wrapped("session#1", *args, **kwargs)

@inject_session
def handle(session, payload):
    return f"{session} processing {payload}"

import inspect
print("call result      :", handle("hello"))
print("introspected sig :", inspect.signature(handle))

Output:

call result      : session#1 processing hello
introspected sig : (payload)

The user's handle function is defined with (session, payload). The decorator hides the session parameter and presents the result as (payload), while internally still calling the real handle with a session it provides. inspect.signature reports the shape the user actually sees, not the underlying.

The adapter argument also accepts an inspect.getfullargspec() tuple or a formatted argspec string, and there is a wrapt.adapter_factory(...) helper for cases where the prototype has to be generated lazily.

The catch is twofold. First, the prototype has to be specified on the decorator factory call, which separates it from the wrapper body and makes the whole thing a little harder to read. Second, the adapter argument is being deprecated in favour of the standalone wrapt.with_signature decorator described below.

The signature side: with_signature

The 2.2.0 replacement is wrapt.with_signature. It is a standalone decorator that overrides what inspect.signature reports for a callable, without changing what the callable actually accepts when called. It takes exactly one of three keyword arguments.

The simplest form is prototype=, which borrows the signature from a dummy function:

import inspect, wrapt

def _prototype(payload: str) -> str: pass

@wrapt.with_signature(prototype=_prototype)
def handle(*args, **kwargs):
    return f"session#1 a:{args[0]}"

print("call result :", handle("hello"))
print("signature   :", inspect.signature(handle))

Output:

call result : session#1 a:hello
signature   : (payload: str) -> str

The body of handle accepts *args, **kwargs because the implementation needs to be flexible (or in the decorator case, because that is what the wrapper signature is). Introspection sees the prototype's (payload: str) -> str instead.

The second form is signature=, which takes a prebuilt inspect.Signature object. This is useful when the parameter list has to be assembled programmatically:

sig = inspect.Signature(
    parameters=[
        inspect.Parameter("payload",
                          inspect.Parameter.POSITIONAL_OR_KEYWORD,
                          annotation=str)
    ],
    return_annotation=str,
)

@wrapt.with_signature(signature=sig)
def handle(*args, **kwargs):
    return f"session#2 b:{args[0]}"

The third form is factory=, which takes a callable that receives the wrapped function and returns either a Signature or a prototype. This is the right choice when the presented signature is derived from the wrapped function's own signature. A factory that strips the first parameter, for instance, would let an "inject the first argument" decorator present whatever the underlying function had with the first slot removed:

def strip_first(wrapped):
    s = inspect.signature(wrapped)
    return s.replace(parameters=list(s.parameters.values())[1:])

@wrapt.with_signature(factory=strip_first)
def handle(*args, **kwargs):
    return f"session#3 c:{args[0]}"

When used together with @wrapt.decorator, the stacking matters. with_signature applies to whatever it directly decorates, so the right place for it is at the use site (above the decorator-built decorator applied to the user's function), or baked into a custom decorator factory that applies it to the wrapped result. Stacking it above a @wrapt.decorator-built decorator definition does not propagate the signature override to the final wrapped result.

A clean pattern for an "inject this argument" decorator that uses with_signature looks like:

def _prototype(payload): pass

@wrapt.decorator
def _wrap(wrapped, instance, args, kwargs):
    return wrapped("session", *args, **kwargs)

def inject_session(fn):
    return wrapt.with_signature(prototype=_prototype)(_wrap(fn))

@inject_session
def handle(session, payload):
    return f"{session} processing {payload}"

inspect.signature(handle) reports (payload). The decorator is a regular function that composes wrapt.decorator and wrapt.with_signature explicitly. A bit more code than the adapter= form, but the pieces are more visible, and wrapt.with_signature is itself reusable in plenty of cases that have nothing to do with @wrapt.decorator.

The important property that with_signature only touches argument-shape code-object flags. The calling-convention bits are left alone. That cleanly separates this concern from the next one.

The calling-convention side: the problem

Suppose a third-party decorator (or one you wrote a while back) takes an async def function and produces a sync callable that runs the coroutine to completion. The body of the decorated function is async, but the result is something you call without await. What does inspect.iscoroutinefunction say?

import asyncio, inspect, wrapt

@wrapt.decorator
def run_to_completion(wrapped, instance, args, kwargs):
    return asyncio.run(wrapped(*args, **kwargs))

@run_to_completion
async def fetch():
    return 42

print("call result        :", fetch())
print("iscoroutinefunction:", inspect.iscoroutinefunction(fetch))

Output:

call result        : 42
iscoroutinefunction: True

Calling fetch() returns 42 because the decorator collapsed the async work to a sync call. But inspect.iscoroutinefunction(fetch) still returns True, because introspection sees the underlying async def. Anything that asks "is this a coroutine function?" to decide what to do with fetch will pick the wrong path. An ASGI framework would await it. pytest-asyncio would treat it as async. Each of those is now making a decision that does not match the actual calling convention.

The mirror case is the same shape in reverse. A plain def function wrapped by something that returns a coroutine reads as not-a-coroutine through introspection, but actually requires await.

with_signature does not help here. It is the wrong tool. The calling-convention bits live in different co_flags slots.

mark_as_sync and mark_as_async

The 2.2.0 answer is a pair of small pass-through decorators that adjust the calling-convention bits and nothing else. They do not bridge anything. They only correct what introspection reports about a stack whose effective convention has already been changed by something else.

The async-to-sync case becomes:

import asyncio, inspect, wrapt

@wrapt.decorator
def run_to_completion(wrapped, instance, args, kwargs):
    return asyncio.run(wrapped(*args, **kwargs))

@wrapt.mark_as_sync
@run_to_completion
async def fetch():
    return 42

print("call result        :", fetch())
print("iscoroutinefunction:", inspect.iscoroutinefunction(fetch))

Output:

call result        : 42
iscoroutinefunction: False

And the symmetric sync-to-async case:

import asyncio, inspect, wrapt

@wrapt.decorator
def schedule(wrapped, instance, args, kwargs):
    async def runner():
        return wrapped(*args, **kwargs)
    return runner()

@wrapt.mark_as_async
@schedule
def compute(x, y):
    return x * y

print("iscoroutinefunction:", inspect.iscoroutinefunction(compute))
print("awaited result     :", asyncio.run(compute(6, 7)))

Output:

iscoroutinefunction: True
awaited result     : 42

It is worth being explicit about what the markers do not do. Putting @wrapt.mark_as_sync directly on an async def does not magically make it sync-callable. It only changes what iscoroutinefunction reports. The bridging from one convention to the other has to be done by some other piece of code in the stack. The markers exist so that introspection can be made to match the reality that something else has already established.

Generator nuance

There are four kinds of callable when you consider the generator/coroutine axes together: plain function, sync generator, coroutine function, async generator. The markers handle all four via an optional generator= keyword that takes None (default, preserve), True (mark as the generator variant of the chosen convention), or False (mark as the non-generator variant).

For example, if an inner decorator drains an async generator into a list and presents the result as a plain sync function returning that list, mark_as_sync(generator=False) makes introspection see "plain sync function" rather than the underlying "async generator function". The mirror case for async iterables produced from a sync generator uses mark_as_async(generator=True). Most code only needs the default form, but the option is there when both axes need to move at once.

async_to_sync and sync_to_async

For the common case of actually bridging between conventions, 2.2.0 bundles two convenience decorators. They do the bridging and apply the right marker, so introspection lines up without a separate mark_as_* step.

wrapt.async_to_sync runs the coroutine to completion via asyncio.run on each call, and marks the result as sync:

import inspect, wrapt

@wrapt.async_to_sync
async def add(a, b):
    return a + b

print("iscoroutinefunction:", inspect.iscoroutinefunction(add))
print("call result        :", add(2, 3))

Output:

iscoroutinefunction: False
call result        : 5

wrapt.sync_to_async is the mirror. It schedules a sync function onto the default executor via loop.run_in_executor, and marks the result as async:

import asyncio, inspect, wrapt

@wrapt.sync_to_async
def mul(a, b):
    return a * b

print("iscoroutinefunction:", inspect.iscoroutinefunction(mul))
print("awaited result     :", asyncio.run(mul(4, 5)))

Output:

iscoroutinefunction: True
awaited result     : 20

These are the same family of utility as asgiref.sync.sync_to_async and asgiref.sync.async_to_sync, which Django leans on heavily for mixing sync and async code. The wrapt versions are smaller and pre-marked, which is the convenient thing. If you need richer behaviour, like explicit executor selection or structured concurrency through anyio, the third-party tools are still the right call. In that case you can apply wrapt.mark_as_sync or wrapt.mark_as_async after the third-party bridge to bring introspection into line:

@wrapt.mark_as_sync
@third_party_async_to_sync
async def work(...):
    ...

Composing both axes

The whole point of keeping with_signature and the markers as separate decorators is that they touch different parts of the function's code-object flags and can be combined freely. A decorator that changes both the parameter shape and the calling convention works by stacking them in the natural order:

import inspect, wrapt

def _prototype(payload: str) -> int: ...

@wrapt.async_to_sync
@wrapt.with_signature(prototype=_prototype)
async def handler(*args, **kwargs):
    return len(args[0])

print("signature           :", inspect.signature(handler))
print("iscoroutinefunction :", inspect.iscoroutinefunction(handler))
print("call result         :", handler("hello"))

Output:

signature           : (payload: str) -> int
iscoroutinefunction : False
call result         : 5

with_signature overrides the signature presented by introspection. async_to_sync bridges the async body to a sync call and marks the result accordingly. The two concerns are completely independent and the stacking just falls out of which one needs to be closer to the function (with_signature) and which produces the outer wrapper (async_to_sync).

Wrap up

The summary of where to reach for each piece is short.

For signature changes, use wrapt.with_signature for new code. The adapter= argument to wrapt.decorator still works and is not going away tomorrow, but it is being deprecated in favour of with_signature, which is the cleaner option going forward.

For calling-convention changes, where introspection needs to report a different sync/async/generator answer than the underlying function would suggest, use wrapt.mark_as_sync or wrapt.mark_as_async to correct what introspection reports. Remember that the markers do not bridge anything. They annotate a stack whose effective convention has already been changed.

For the common bridging cases, wrapt.async_to_sync and wrapt.sync_to_async do the bridging and the marking together. For more sophisticated async runtime needs, keep using asgiref or anyio and apply the markers afterwards.

The full set of these tools is documented across signature changing decorators, signature override and calling convention markers and adapters. Release notes are in the changelog, the latest release is on PyPi, and issues go to the issue tracker on Github.

May 24, 2026 08:00 AM UTC

Lazy monkey patching with wrapt

This post is for the people who write APM agents, tracers, profilers, debuggers, and anything else that instruments Python code without asking the user to change it. Everyone else is welcome along.

The reason I want to call out the audience up front is that wrapt was created for this kind of work, and the original purpose is sometimes obscured by how widely the project has been adopted for its decorator API. The decorator side of wrapt (which the recent posts on stateful decorators and per-instance lru_cache have covered) grew out of needing reliable building blocks for monkey patching, not the other way around.

There is a side of wrapt that, until April 2026, had no dedicated page in the official documentation. I have covered it in various conference talks over the years, but that is not the same thing as having proper docs. The mechanism for deferred monkey patching, registering a patch against a module that has not been imported yet, with the patch only applied when the module is later imported, has been part of wrapt from day one. The monkey patching documentation page finally landed in the lead-up to the 2.2.0 release, which also added a small ergonomic piece. A new ? modifier on module names closes the last awkward gap in how the deferred form composes with the convenient decorator syntax.

So this post is amplification of a pattern that has been there all along, not breaking news. The new modifier is just polish.

With Python 3.15 about to ship PEP 810 explicit lazy import syntax, the timing matters. Any instrumentation library that force-imports its target modules at agent startup is now actively undoing user-level lazy imports. That has always been a little impolite for cold-start performance. With 3.15 it becomes a direct conflict with how users want to write their code.

A monkey patching primer

The smallest useful piece of wrapt's monkey patching API is wrap_function_wrapper. You give it a module, the dotted name of an attribute on that module, and a wrapper function. It replaces that attribute with a FunctionWrapper that calls your wrapper around the original.

A timing wrapper on json.dumps looks like this:

import json
import time
import wrapt

def time_call(wrapped, instance, args, kwargs):
    start = time.perf_counter()
    try:
        return wrapped(*args, **kwargs)
    finally:
        elapsed = (time.perf_counter() - start) * 1e6
        print(f"json.dumps took {elapsed:.0f} us")

wrapt.wrap_function_wrapper("json", "dumps", time_call)

print(json.dumps({"a": 1, "b": [2, 3]}))

Output:

json.dumps took 16 us
{"a": 1, "b": [2, 3]}

The wrapped, instance, args, kwargs signature of time_call is the same uniform wrapper signature that @wrapt.decorator uses, and that the stateful decorators post has already shown in the decorator context. That is not a coincidence. The decorator API in wrapt is built on top of this same wrapper mechanism, not the other way around, so the body you would write for a @wrapt.decorator-style decorator is the same body you would write for a monkey patch. Whatever you have learned about writing wrappers in the decorator context carries straight over.

The user code that calls json.dumps does not change. The instrumentation is added entirely by wrap_function_wrapper. That is the whole point. APM agents and similar tools want to add visibility to third-party code without asking the user to modify it.

The forced-import problem

wrap_function_wrapper takes the module name as a string and imports the module to find the attribute to wrap. The act of registering the patch loads the target.

import sys
import wrapt

def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

print("before:", "xml.etree.ElementTree" in sys.modules)
wrapt.wrap_function_wrapper("xml.etree.ElementTree", "fromstring", trace)
print("after :", "xml.etree.ElementTree" in sys.modules)

Running this prints:

before: False
after : True

For a single module that is mildly wasteful. For an APM agent that supports, say, requests, httpx, urllib3, aiohttp, django, flask, fastapi, sqlalchemy, psycopg, redis, pymongo and kafka-python, importing the agent loads every one of those modules at agent startup, regardless of which the user's app actually uses.

The price shows up in three places. Cold start time gets a noticeable chunk added, which matters disproportionately in serverless and short-lived worker environments where the process lifetime is measured in seconds. Memory holds code that is never going to be called. And the user's own lazy import statements get silently undone, because by the time their code runs the modules are already loaded.

The long-standing answer

The mechanism that solves all three problems has been in wrapt from the early days. The idea originally came from PEP 369, which proposed post-import hooks for the Python standard library. That PEP was withdrawn, but wrapt provides its own implementation via a sys.meta_path finder.

The low-level entry point is register_post_import_hook(hook, name). The hook is a callback that takes the module as its argument and runs once the named module is imported. If the module is already imported when the hook is registered, the hook fires immediately.

The decorator form, when_imported(name), is the one most code uses:

import sys
import wrapt

def trace_reader(wrapped, instance, args, kwargs):
    print("[traced csv.reader]")
    return wrapped(*args, **kwargs)

@wrapt.when_imported("csv")
def install(module):
    wrapt.wrap_function_wrapper(module, "reader", trace_reader)

print("after register:", "csv" in sys.modules)
import csv
print("after import  :", "csv" in sys.modules)
for row in csv.reader(["a,b,c"]):
    print("row:", row)

Output:

after register: False
after import  : True
[traced csv.reader]
row: ['a', 'b', 'c']

Two things to notice. Registering the hook does not touch sys.modules. The module is only loaded when the user's code does import csv. And the wrapping happens automatically as a side effect of that import, so the patched csv.reader is what the user code sees.

This is the mechanism that every reputable APM agent already uses one way or another, because they had to. It just was not very visible from the outside.

The decorator-form gap

wrap_function_wrapper has a more convenient cousin called patch_function_wrapper which is the decorator form. It lets you keep the wrapper definition at module top level rather than nested inside a callback:

@wrapt.patch_function_wrapper("html.parser", "HTMLParser.feed")
def trace_feed(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

This is the form you really want for a patch registry. One decorated wrapper function per supported third-party target, all at the top level of one file. Easy to read, easy to grep, no nested closures.

The catch, before wrapt 2.2.0, was that this decorator form force-imported its target the same way wrap_function_wrapper did:

import sys
import wrapt

print("before:", "html.parser" in sys.modules)

@wrapt.patch_function_wrapper("html.parser", "HTMLParser.feed")
def trace_feed(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

print("after :", "html.parser" in sys.modules)
before: False
after : True

The lazy alternative meant restructuring into a when_imported callback with the wrapper defined inside it. Workable but ugly, especially repeated across a dozen targets, and you lose the clean "one decorated function per target" layout that makes a patch registry readable.

The ? modifier in 2.2.0

wrapt 2.2.0 closes the gap by recognising a trailing ? on a module name. With the ?, both wrap_function_wrapper and patch_function_wrapper defer registration via a post-import hook when the target module is not yet loaded. If the module is already in sys.modules, the patch is applied immediately. Same behaviour as before, just without the side effect of forcing the import.

import sys
import wrapt

def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

wrapt.wrap_function_wrapper("gzip?", "compress", trace)
print("after register (with ?):", "gzip" in sys.modules)
import gzip
print("after import           :", "gzip" in sys.modules)
after register (with ?): False
after import           : True

And the decorator form, which is the case that actually motivated the change:

import sys
import wrapt

@wrapt.patch_function_wrapper("tempfile?", "mkdtemp")
def trace_mkdtemp(wrapped, instance, args, kwargs):
    print("[traced tempfile.mkdtemp]")
    return wrapped(*args, **kwargs)

print("after register (with ?):", "tempfile" in sys.modules)
import tempfile
print("after import           :", "tempfile" in sys.modules)
print("mkdtemp:", tempfile.mkdtemp())
after register (with ?): False
after import           : True
[traced tempfile.mkdtemp]
mkdtemp: /var/folders/.../tmpktve96ix

Under the hood, the ? form is genuinely just shorthand. The implementation in wrapt's patches.py is roughly:

if target.endswith("?"):
    target = target[:-1]
    if target in sys.modules:
        return wrap_object(sys.modules[target], name, FunctionWrapper, (wrapper,))
    def callback(module):
        wrap_object(module, name, FunctionWrapper, (wrapper,))
    register_post_import_hook(callback, target)
    return None

No new mechanism, no new dispatch path. The work is still done by the same register_post_import_hook that has been in wrapt for years. The benefit is purely the authoring style. @patch_function_wrapper("...?", "...") at top level is now an option that previously was not.

Composition with PEP 810 lazy imports

Python 3.15 ships PEP 810 explicit lazy imports. The user can write:

lazy import requests

and the import is deferred until the name requests is first used. The discussion in Lazy imports using wrapt covers the PEP's motivation in more detail.

This raises a question that was not quite so sharp before. If a user's code uses lazy import for a module, and an APM agent registers a non-lazy wrap_function_wrapper for that module, what happens?

# apm_eager.py — simulated APM patches
import wrapt
def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)
wrapt.wrap_function_wrapper("gzip", "compress", trace)
# user_code.py — user's app
import sys
import apm_eager   # APM agent loaded at process startup

lazy import gzip

print("after lazy import:", "gzip" in sys.modules)
gzip.compress(b"hello")
print("after first use  :", "gzip" in sys.modules)

Output:

after lazy import: True
after first use  : True

The user wrote lazy import gzip, but gzip is already in sys.modules by the time their import statement runs. The APM agent loaded it on the user's behalf. Whatever benefit the user expected from lazy import has been quietly undone.

Switching the APM agent to use the ? form fixes it:

# apm_lazy.py
import wrapt
def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)
wrapt.wrap_function_wrapper("gzip?", "compress", trace)

With the same user code as before, this now prints:

after lazy import: False
after first use  : True

gzip is only loaded at the moment the user's code first touches it, and at that moment the patch fires too. Lazy patching and lazy imports compose correctly.

Putting it together: a patch registry

For an APM agent or similar, the practical pattern looks like this. A single file declares all the patches as a flat list of top-level decorated functions:

# my_apm_patches.py
import wrapt

@wrapt.patch_function_wrapper("xml.etree.ElementTree?", "fromstring")
def trace_fromstring(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

@wrapt.patch_function_wrapper("csv?", "reader")
def trace_reader(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

@wrapt.patch_function_wrapper("gzip?", "compress")
def trace_compress(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

@wrapt.patch_function_wrapper("html.parser?", "HTMLParser.feed")
def trace_feed(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

Importing this module registers all four patches but loads none of the target modules:

import sys
import my_apm_patches

targets = ["xml.etree.ElementTree", "csv", "gzip", "html.parser"]
for m in targets:
    print(f"  {m:30s} {'loaded' if m in sys.modules else 'not loaded'}")

Output:

  xml.etree.ElementTree          not loaded
  csv                            not loaded
  gzip                           not loaded
  html.parser                    not loaded

Whichever modules the user's code actually imports is the set that ends up getting patched. The rest stay out of memory entirely. The agent has paid no cold-start cost for the targets the user does not care about, and the user's own lazy imports continue to do what they say on the tin.

As a bonus, the test story is also better. The instrumentation library's test suite no longer needs every supported third-party package installed just to import the library, only the ones it actually exercises.

What changed and what didn't

Strictly speaking, nothing in wrapt 2.2.0 enables any behaviour that was not possible before. The deferred patching mechanism is the same register_post_import_hook it always was. What changed is the authoring ergonomics. The ? modifier lets you write the lazy version of a patch as concisely as the eager version, including in the decorator form that suits patch-registry files best. And the monkey patching docs page that landed in April 2026 finally makes the mechanism easy to discover.

If you maintain instrumentation code that still force-imports its targets, Python 3.15 is a good prompt to refactor. The change is mechanical. Add a ? to the module name in each wrap_function_wrapper and patch_function_wrapper call. The behaviour for already-loaded modules is unchanged, and for not-yet-loaded modules the patch now fires when (and only when) the user's code actually imports them.

The full release notes for wrapt 2.2.0 are in the changelog. The latest release is on PyPi, and issues go to the issue tracker on Github.

May 24, 2026 06:30 AM UTC

Per-instance lru_cache using wrapt

Following on from the previous post on stateful decorators, there is another small addition in wrapt 2.2.0 worth a closer look. A new wrapt.lru_cache helper has been added that fixes the long-standing issues with using functools.lru_cache on instance methods.

The thing I want to emphasise up front is that wrapt.lru_cache is not a replacement for functools.lru_cache. The actual caching is still done by the standard library implementation, all of its keyword arguments are passed straight through, and the eviction behaviour is identical. What wrapt.lru_cache adds is a thin layer on top, built using wrapt's decorator machinery, that fixes how the underlying functools.lru_cache is applied when the decorated function turns out to be a method on a class.

What lru_cache gives you

functools.lru_cache is a small but very useful decorator. You wrap a function with it and the function's return values are remembered, keyed on the arguments, up to some maximum cache size. Repeat calls with the same arguments skip the function body and return the cached result.

from functools import lru_cache

@lru_cache(maxsize=128)
def expensive(n):
    print("computing", n)
    return n * n

expensive(2)
expensive(2)
expensive(3)

Running this prints computing 2 and computing 3 once each. For pure functions of their arguments this is exactly what you want.

Where it falls apart

It is when you reach for the same decorator on an instance method that things start to go wrong. The standard library implementation has no concept of the wrapped function being a method, so it treats self as just another argument and includes it in the cache key. That single design choice causes three distinct problems.

Problem 1: instances share a single cache budget

from functools import lru_cache

class Computer:
    @lru_cache(maxsize=2)
    def compute(self, x):
        return x * 2

a = Computer()
b = Computer()

a.compute(1)
a.compute(2)
b.compute(1)
b.compute(2)

print(a.compute.cache_info())

The cache is a single shared structure attached to Computer.compute. With maxsize=2, four distinct (self, x) pairs across the two instances are competing for two cache slots. cache_info() reports hits=0, misses=4, currsize=2. With one hundred instances and the default maxsize=128, each instance ends up with rather close to a single cache slot of its own.

Problem 2: cached instances cannot be garbage collected

Because self is part of the cache key, the cache holds a strong reference to it. The instance can never go out of scope while there is a cached entry for one of its method calls:

import gc, weakref
from functools import lru_cache

class Big:
    @lru_cache
    def compute(self, x):
        return x

b = Big()
ref = weakref.ref(b)

b.compute(1)
del b
gc.collect()

print(ref())

ref() returns the original Big instance rather than None. It is still alive, kept around by the cache, with no easy way to find or release it short of calling Big.compute.cache_clear() and dropping every other instance's cached results along with it.

Problem 3: self must be hashable

Cache keys have to be hashable. The standard library implementation therefore requires that self is hashable too. Any class that defines __eq__ without also defining __hash__ is implicitly unhashable, and the decorator will fail at call time:

from functools import lru_cache

class Record:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return isinstance(other, Record) and self.name == other.name

    @lru_cache
    def upper(self):
        return self.name.upper()

Record("a").upper()

That raises TypeError: unhashable type: 'Record'. None of the function's actual arguments are involved in the failure; it is purely about self.

The wrapt version

The wrapt.lru_cache helper sidesteps all three problems by recognising when the decorated callable is being invoked as a method, and arranging for a separate functools.lru_cache-wrapped helper to exist for each decorated method on each instance. The helper is stored directly on the instance under an attribute named after the wrapped method, so for a method called compute the cache lives at instance._lru_cache_compute. The cache key is built from the genuine arguments only, with self providing the lookup of which cache to use rather than being a participant in the key.

The same three examples now look like:

import wrapt

class Computer:
    @wrapt.lru_cache(maxsize=2)
    def compute(self, x):
        return x * 2

a = Computer()
b = Computer()

a.compute(1)
a.compute(2)
b.compute(1)
b.compute(2)

print(a.compute.cache_info())

Each instance has its own cache for compute with the full maxsize=2 budget. The cache_info() call here returns the stats for the cache attached to a (hits=0, misses=2, currsize=2), not a shared total. Calling b.compute.cache_info() reports its own independent set of numbers. If Computer had several @wrapt.lru_cache methods then each would get its own per-instance cache, stored under a separate attribute (_lru_cache_compute, _lru_cache_other_method, and so on), with no contention between them.

The garbage collection case works correctly because each instance owns its own cache attributes, and when the instance is collected the caches stored on it go with it:

import gc, weakref
import wrapt

class Big:
    @wrapt.lru_cache
    def compute(self, x):
        return x

b = Big()
ref = weakref.ref(b)

b.compute(1)
del b
gc.collect()

print(ref())

ref() now returns None.

And unhashable instances are fine, because self was never part of the cache key in the first place:

import wrapt

class Record:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return isinstance(other, Record) and self.name == other.name

    @wrapt.lru_cache
    def upper(self):
        return self.name.upper()

print(Record("a").upper())

That prints A, with no TypeError.

For plain functions, class methods and static methods (where there is no per-instance state to keep separate) wrapt.lru_cache defers to a single shared functools.lru_cache, so the behaviour is indistinguishable from using functools.lru_cache directly:

@wrapt.lru_cache(maxsize=32)
def factorial(n):
    return n * factorial(n - 1) if n else 1

What is and is not new here

To restate the point at the top of the post, none of this is a new caching algorithm. The eviction strategy, the cache statistics, the keyword arguments, the CacheInfo tuple, the cache_info() / cache_clear() / cache_parameters() methods are all functools.lru_cache, untouched. What wrapt.lru_cache adds is the descriptor-protocol-aware machinery to ensure that for instance methods, the right cache is created and consulted, with no global cache pollution, no reference leaks, and no hashability requirement on the instance.

This is the kind of problem wrapt exists to handle. The recommended way to write a decorator with wrapt gives you a uniform wrapper signature that knows whether it has been called as a function, instance method, class method or static method, and the lru_cache helper is essentially a small, focused use of that machinery to delegate to the standard library decorator in a way that respects the calling convention.

The lru_cache helper is documented over on the bundled decorators page, and the full release notes for the rest of wrapt 2.2.0 are in the changelog. The feature is available from 2.2.0 onwards, although as before it is worth grabbing the latest release from PyPi since there have been follow-up releases on the 2.2.x branch. Issues and questions go to the issue tracker on Github.

May 24, 2026 04:30 AM UTC

Stateful decorators in wrapt

A new version of wrapt was released earlier this week. Version 2.2.0 introduces a small helper that makes it noticeably easier to write decorators that need to keep state across calls. It is the kind of thing that does not look like much until you try to write the equivalent code without it, so it is worth a closer look.

The full release notes are in the changelog. What I want to walk through here is the stateful decorator side of the release, because it touches on something that has always been a bit awkward in plain Python.

Why a decorator might need state

The idea of a stateful decorator is straightforward enough. You attach a wrapper to a function, and the wrapper remembers something across invocations. Counting how many times the function has been called is the canonical example. Other examples include accumulating timing statistics, caching results in a way you want to inspect, tracking which arguments have been seen, or maintaining a registry of what the wrapped function has done.

The complication is not the bookkeeping itself, it is exposing the state back to the caller. If a decorator is purely passive and does its work without anyone ever needing to look at the internals, state can live in a closure and nobody is any the wiser. Once you decide that the user of the decorated function should be able to ask "how many times has this been called?", you need a way to reach into that state from the outside.

The closure approach

The simplest pattern in plain Python is to push state onto the wrapper function as an attribute:

import functools

def call_tracker(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        finally:
            wrapper.call_count += 1
    wrapper.call_count = 0
    return wrapper

@call_tracker
def add(x, y):
    return x + y

add(1, 2)
add(3, 4)
print(add.call_count)

Running this prints 2. That works fine for a regular function, but the moment you apply the same decorator to an instance method things get more subtle. The wrapper itself is still a function, so the descriptor protocol kicks in and self is passed through correctly. The state however lives on the single wrapper object that was created at class definition time, so it is shared across every instance of the class. Whether that is what you want depends on the use case, but you have no real control over it from the way the decorator is written.

The class approach

If you want to keep both the state and the wrapper logic together, the next natural step is to write the decorator as a class:

import functools

class CallTracker:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        try:
            return self.func(*args, **kwargs)
        finally:
            self.call_count += 1

@CallTracker
def add(x, y):
    return x + y

add(1, 2)
print(add.call_count)

This works for plain functions. The problem appears when the same decorator is applied to a method:

class Calculator:
    @CallTracker
    def add(self, x, y):
        return x + y

Calculator().add(1, 2)

That raises TypeError: add() missing 1 required positional argument: 'y'. The reason is that Calculator.add is now a CallTracker instance rather than a function. When the attribute is looked up via an instance, the descriptor protocol does not kick in, because instances of user-defined classes are not descriptors by default. The Calculator instance is therefore never bound to self in the wrapped function, and the call sees x as 1 with no value for y.

You can fix this by adding a __get__ method to CallTracker to make it behave as a descriptor, but then you also need to think about whether each access creates a fresh bound version, how classmethod and staticmethod interact with it, what happens when the descriptor is accessed on the class versus the instance, and so on. There is a real amount of code involved in getting all of this right, and it is exactly the code that wrapt exists to provide.

Doing it with wrapt

wrapt handles the descriptor machinery for you. The recommended way to write a decorator with wrapt is to use @wrapt.decorator, which gives you a uniform wrapper signature across functions, instance methods, class methods and static methods. You always get wrapped, instance, args and kwargs, with instance set appropriately depending on how the call was made.

Before version 2.2.0, layering state on top of that meant a little bit of manual plumbing. You had to construct the state object yourself, write the wrapper to close over it, then explicitly attach the state to the wrapper after the fact so it could be reached from outside. Something like this:

import wrapt

class CallTracker:
    def __init__(self):
        self.call_count = 0

    def __call__(self, func):
        tracker = self

        @wrapt.decorator
        def wrapper(wrapped, instance, args, kwargs):
            try:
                return wrapped(*args, **kwargs)
            finally:
                tracker.call_count += 1

        wrapped_func = wrapper(func)
        wrapped_func.tracker = tracker
        return wrapped_func

It is not exactly painful, but it is noisy. You have to remember to assign the state attribute, you have to alias self so the closure captures it rather than something else, and the actual interesting code (the try/finally) is buried under boilerplate.

The new helper

In wrapt 2.2.0 the same decorator can now be written like this:

import wrapt

class CallTracker:
    def __init__(self):
        self.call_count = 0

    @wrapt.bind_state_to_wrapper(name="tracker")
    @wrapt.decorator
    def __call__(self, wrapped, instance, args, kwargs):
        try:
            return wrapped(*args, **kwargs)
        finally:
            self.call_count += 1

The __call__ method is defined directly with the standard wrapt decorator signature, with an extra self at the front so it can reach the state on the CallTracker instance. The @wrapt.bind_state_to_wrapper descriptor sits on top of @wrapt.decorator and takes care of two things. When __call__ is accessed via an instance of CallTracker, it returns a wrapper that knows about the right self. And when that wrapper is applied to a function, the CallTracker instance is automatically attached to the resulting wrapped function under the name supplied in the name argument.

Using it looks like:

@CallTracker()
def add(x, y):
    return x + y

add(1, 2)
add(3, 4)
print(add.tracker.call_count)

The output is 2. Where the previous approaches forced a choice between keeping state with the decorator class and supporting methods correctly, wrapt lets you have both. Applied to an instance method, the same decorator just works:

class Calculator:
    @CallTracker()
    def add(self, x, y):
        return x + y

calc = Calculator()
calc.add(1, 2)
calc.add(3, 4)
print(calc.add.tracker.call_count)

This also prints 2. The wrapper handles descriptor binding correctly, self is passed through to the underlying method, and the state attribute remains reachable on the bound version of the wrapper because attribute lookup on a bound function wrapper now falls through to the parent function wrapper. That last bit is another small change in 2.2.0 that I won't dwell on here, but without it the cleaner syntax above would not be reachable through an instance.

A little extra polish

One refinement worth pointing out is what to do when you want the decorator to be usable both with and without arguments. That is, the @CallTracker versus @CallTracker(call_count=100) distinction. Construction can be wrapped up in a static method on the class:

class CallTracker:
    def __init__(self, call_count=0):
        self.call_count = call_count

    @wrapt.bind_state_to_wrapper(name="tracker")
    @wrapt.decorator
    def __call__(self, wrapped, instance, args, kwargs):
        try:
            return wrapped(*args, **kwargs)
        finally:
            self.call_count += 1

    @staticmethod
    def track(func=None, /, *, call_count=0):
        tracker = CallTracker(call_count=call_count)
        if func is None:
            return tracker
        return tracker(func)

You can now write either @CallTracker.track or @CallTracker.track(call_count=100) and get sensible behaviour in both cases. None of that is specific to wrapt, it is just the usual Python trick for optional-argument decorators, but it composes nicely with the rest.

Why this matters

The reason wrapt exists in the first place is that writing decorators that behave correctly across functions, instance methods, class methods and static methods is harder than it looks. The descriptor protocol, functools.wraps, the inspect module, and the time-honoured Python habit of "just stick it on the function as an attribute" all interact in slightly awkward ways once you try to combine them. The uniform wrapper signature in wrapt removes most of that friction.

What bind_state_to_wrapper adds is the last missing piece for the common case of a stateful decorator. The state lives on the decorator class, the wrapper has direct access to it via self, and the state is exposed back to callers through a named attribute on the wrapped object with no extra plumbing. Documentation for both pieces is over in the decorators guide and the examples page if you want to look at the full set of variations.

The feature is available in wrapt from version 2.2.0 onwards, although you should grab whatever the latest release is from PyPi since there have been follow-up releases on the 2.2.x branch since. If you are coming to this from the Wrapt version 2.0.0 announcement last year, it builds on the same BaseObjectProxy reshuffle that release prepared the ground for. As always, if you find any issues there is an issue tracker on Github.

May 24, 2026 01:55 AM UTC


Armin Ronacher

Building Pi With Pi

Pi is now part of Earendil, but in the important sense it is still Mario’s project. He has been living with its issue tracker longer than I have, and he has been exposed to the weirdness of the new form of agent traffic in Open Source projects for longer too. This post is mostly a reflection of my own experience after spending more time in the tracker, using Pi to work on Pi, and watching what I have learned about it so far.

Slop Issues

Unsurprisingly, we are using Pi to build Pi. That sounds like a cute dogfooding thing but it really helps understand what we do. An interesting effect of building with agents is that it changes the role of the issue tracker a tiny bit. The issue descriptions are not just messages from a user to a maintainer because we also use them as inputs for prompts in Pi sessions. It is something I might hand to my clanker1 and say: “understand this, reproduce it, inspect the code, and propose a fix.”

That means the shape of the issue matters in a new way. A bad issue was always annoying, but at least a lot of issues were vague. Now we are also dealing with a class of issues that are 5% human and 95% clanker-generated and largely inaccurate shit. A bad issue that contains a plausible but wrong diagnosis creates extra work.

The most frustrating failure mode right now is that people submit issues that are not in their own voice. They contain an observed problem somewhere, but it has been thrown into a clanker and the clanker reworded it and made a huge mess of it. Typically, it was prompted so badly that the conclusions produced are more often than not inaccurate but always full of confidence. The result is complete guesswork on root causes, fake-minimal repros, suggested implementation strategies, analogies to adjacent but often the wrong code, and long lists of error classes that might or might not matter.

That is worse than no diagnosis.

I don’t want to point to specific issues because I really do not want to bad mouth anyone, but it is frustrating. It is also frustrating because when I give that issue to Pi, Pi sees the wrong diagnosis too. It does not treat the issue body as a rumor. It treats it as evidence. It will happily go down the path that the issue already prepared for it, because the prose is confident and the code references look plausible. We use a custom slash command called /is, which specifically has this instruction in it:

Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.

Unfortunately, it does not fully work, because when humans first throw their issue through the clanker wringer, their clanker expands scope almost immediately. What was once a very narrow and fact based bug observation, turns into a much expanded surface area full of hypotheses. So at least personally, I increasingly want issue reports to be condensed to what the human actually observed:

  1. I ran this command.
  2. I expected this to happen.
  3. This happened instead.
  4. Here is the exact error or log.

That is enough. If you used an LLM to understand the problem, great, maybe leave it as a follow-up comment. But the issue and the issue text should be something you own. If you do not know the root cause, say that. I too can operate a clanker, and I would rather do this myself than use your slop. If your repro is a guess, say that. If the only hard fact is one stack trace, give me the stack trace and stop there.

Slop Begets Slop

That we’re seeing issues full of slop is just a result of the present day quality of these machines. Sadly, their failures in creating good issues extend to a lot of code that is generated. Not all of it, but a lot of code. Over and over I keep running into them over-engineering the hell out of issues and implementations.

If you tell them that “this malformed session log crashes the reader,” the clanker will often add a tolerant reader. Then it will add a fallback, then maybe a migration, then more debug output, then a test for all of this. None of this is necessarily wrong in isolation, but it can be the wrong move for the system.

At Pi’s core is a rather well-designed session log with invariants that must be upheld. The clanker’s present-day behavior is to just assume that no such invariants exist, and instead to make the system work with all kinds of malformedness, blowing up the complexity in the process.

Almost always, the correct fix is not to handle the bad state, but to make the bad state impossible. This matters a lot for persisted data such as Pi session logs. They are opened, branched, compacted, exported, shared, and analyzed. The goal here is to never write bad session data. Yet if you just let the clanker roam freely, it will attempt to handle every case of bad data in the session log with a more permissive reader.

I have complained about this plenty, but working on Pi’s code base continues to reinforce the point. This is one of the ways LLM authored code grows so much needless complexity. All these models see a local failure and try to locally defend against it. As maintainers we have to keep pulling the conversation back to the global invariant, which is harder than it should be, and it’s laborious.

Volume Is The Problem

Then there is the issue of volume. The tracker is receiving a lot of issues and PRs, and a significant fraction of them are clearly LLM-assisted. Some are good, none are excellent, and most are just bad. The total throughput is a maintenance problem by itself.

As you might know, Pi’s issue tracker is automated to close all issues and pull requests from new contributors, and there is a manual process by which we might reopen some of them or approve individuals. So auto-close -> reopen -> close again is an interesting statistic for us to look at.

I pulled the public GitHub tracker data while writing this over the last 90 days. Excluding Earendil members, that leaves 3,145 external issues and pull requests. Of those, 2,504 were auto-closed because they were from non-approved individuals. 17% were re-opened but that somewhat undercounts issues, because some remain closed while we still fix them. If we also count issues referenced by a main-branch commit or merged pull request that number rises to 26%. For pull requests the number is worse: 60 of 714 auto-closed PRs were ultimately merged, or about 8%.

Weekly external volume and acceptance rate of Pi issues and pull requests

Many of the issues and PRs are complete slop and in some cases the humans did not even realize that they created them. Sources of low-quality spam include OpenClaw instances, as well as some skills that people put into their context that seemingly encourage issue creation.

GitHub clearly is not built to deal with this new form of Open Source, but I’m increasingly feeling the need to put the blame less on GitHub than on all the people involved who make that experience painful. If your clanker shits on someone else’s issue tracker then it’s not the fault of GitHub, it’s yours alone.

Careful Parallelism

Pi might be built with Pi, but we’re quite far off today from where Bun and OpenClaw already are: fully detached, automated software engineering. Maybe we will reach that point, I don’t know. Today it does not seem like we know how to pull off a dark factory and we also don’t yet have the desire. That said, there is quite a bit of parallelism going on, and it is mostly for reproducing issues.

The small setup we use for this is three tiny pieces in Pi’s own committed .pi folder. /is (for analyze issue) is a prompt for analyzing GitHub issues: it labels and assigns the issue, reads the full thread and links, then explicitly tells the agent not to trust the analysis in the issue and to derive its own diagnosis from the code. Then an extension adds a prompt-url-widget which watches the prompt before the agent starts, recognizes the GitHub issue or PR URL that /is (or the PR equivalent) put into the prompt, fetches the title and author with gh, renders that in a little UI widget, and renames the session. It also rebuilds that state on session start or session switch, so if we reopen an older investigation the window still tells the developer which issue it belongs to.

In practice this means it’s possible to have several Pi windows open, each running /is against a different issue, and the UI keeps the investigations visually distinct while the agents do their independent reproduction and code reading. Once the investigations are done, one can work through them sequentially. To finish off everything, /wr (wrap it up) is the matching wrap-up prompt: it infers the GitHub context from the session, updates the changelog, drafts or posts the final issue comment with a disclaimer, commits only the files changed in that session, adds the appropriate closes #... when there is exactly one issue, and pushes from main.

Pi terminal session showing an agent analysis with a GitHub issue widget displaying the issue title, author, and URL.

Open Source Is About Hard Problems Worth Fixing

You will have noticed this already but Open Source in a post-AI world is under a strange new pressure. We are getting more code, more projects, and more issues. Projects appear with no real users, or a temporary audience of one, and even projects with thousands of stars can have a shelf life of weeks.

For us, Pi’s harness layer is worth maintaining carefully because it solves hard coordination problems and creates a platform we and others can build on. We also know that coordination and cooperation lifts us all up. Many times the right answer is not to work around a problem locally, but to make the upstream behavior correct. Mario has been very good at refusing to make Pi paper over every misconfigured gateway, and we’re trying to preserve that discipline. When a gateway behaves correctly, everybody benefits.

Sadly that type of thinking is quickly disappearing because these machines make local workarounds cheap, so code accumulates local defenses against every misbehavior. Instead of humans talking to humans about where a fix belongs, one human and one machine work around the problem in isolation.

Keep in mind that AI has not increased the number of people who need software, or the number of maintainers who can review it. It has mostly increased the amount of code and the number of projects competing for attention. Some of that is healthy, but a lot of it fragments effort that should be shared.

We need stronger foundations, not weaker ones. Open Source needs more collaboration, not more isolated work with a machine. Human communication is hard, and it is tempting to avoid it when you can sit alone with your clanker. But isolation is not where Open Source derives its value. The value is in the community and the structure that lets projects outlive their original creators.

  1. To me, clanker is a much preferable term for agent. Agency lies with humans, not with machines. Calling these things agents I still believe is a mistake, but alas.

May 24, 2026 12:00 AM UTC

May 23, 2026


EuroPython

Call for Onsite Volunteers: Make EuroPython 2026 Happen

We need volunteers to make EuroPython 2026 happen. And you might be exactly who we&aposre looking for!

Before sharing all the information, here is a personal story from me:

The first time I attended EuroPython in-person was as a volunteer. It was the first year after Covid, and I was nervous about traveling abroad for a conference where I didn&apost know anyone personally; there were only friendly faces from the previous year of volunteering online. When I volunteered online, it was easier. I could stay in my comfort zone. But stepping out of that zone to meet people face-to-face? That changed everything 🐍❤️

Those online faces became really good friends. Now I want to go for every EuroPython because I will get to meet them again. Volunteering with friends became such fun I didn&apost even notice that I was constantly stepping outside my comfort zone 💃

So, if you&aposre thinking of volunteering, just do it! You will meet awesome humans and have fun while helping people surrounded by positive vibes 💖

alt

As a volunteer, you&aposre the face of the conference. Your job is to make sure everyone has a great time. We need volunteers to be welcoming, helpful, and collaborative; making sure everyone (including yourself) is comfortable and happy.

There are lots of different ways to help, depending on your interests and availability:

You can sign up for as many or as few slots as you want. Even a couple of hours helps. We&aposd appreciate it if you could do more than one, but no pressure, whatever you can give is valuable.

In the volunteering form, tell us what sounds interesting. Get matched with a role that fits your skills and availability. Show up, help out, and be part of something amazing.

That&aposs it. No experience necessary. You don&apost need to be a Python expert. You just need to care about the community and be willing to help out. Whether that&aposs greeting people at the door, managing the schedule, troubleshooting tech issues, or making sure speakers have what they need - we have a place for you.

What do you get?

Check out this page for all the details, including descriptions of various roles: https://ep2026.europython.eu/volunteering/

And if you have more questions? Just reach out volunteers@europython.eu. We&aposre here to help.

🎁 Sponsor Spotlight

We&aposd like to thank Manychat for sponsoring EuroPython.

Manychat builds AI-powered chat automation for 1M+ creators and brands at real production scale.

alt

👋 Stay Connected

Follow us on social media and subscribe to our newsletter for all the updates:

👉 Sign up for the newsletter: https://blog.europython.eu/portal/signup

Hopefully, we’ll see you on this side soon 🔜 😉

Cheers,

Sangarshanan Veera, EuroPython 2026 Communications Team

May 23, 2026 07:00 AM UTC

May 22, 2026


Python Morsels

What types of exceptions should you catch?

The trickiest programming bugs are often caused by catching exceptions that you didn't mean to catch or handling exceptions in ways that **obfuscate the actual error that's occurring. Which exceptions should you catch and which should you leave unhandled?

Table of contents

  1. Catching many exceptions at once
  2. When will a NameError be raised?
  3. When will a ValueError be raised?
  4. When will a TypeError be raised?
  5. When will a KeyError be raised?
  6. Handling problems preemptively
  7. When should you catch all possible types of exceptions?
  8. Summary

Catching many exceptions at once

When catching an exception, it's generally considered a good idea to only catch exceptions if you understand their origin.

Here we have some code that catches many exception types at once. We're catching a ValueError, a TypeError, a KeyError, and a NameError exception:

import csv
import datetime
import sys

def parse_date(date_string):
    return datetime.date.fromisoformat(date_string)

[filename] = sys.argv[1:]
with open(filename) as csv_file:
    reader = csv.DictReader(csv_file)
    for n, row in enumerate(reader, start=1):
        name = row["name"]
        try:
            start, end = parse_date(row["start"]), parse_date(row["end"])
        except (ValueError, TypeError, KeyError, NameError) as e:
            error = type(e).__name__
            print(f"{error}: Invalid date on line {n}", file=sys.stderr)
            continue
        time = end - start
        print(f"{name}: {time.days} days")

It's not entirely clear why it catches each of these types of exceptions.

When will a NameError be raised?

We probably shouldn't be catching …

Read the full article: https://www.pythonmorsels.com/what-types-of-exceptions-should-you-catch/

May 22, 2026 05:45 PM UTC


Real Python

The Real Python Podcast – Episode #296: Managing Polars Schema Issues & Profiling GitHub Users

How can you avoid schema problems in your Polars data pipeline when adding new columns? How can you quickly examine a GitHub user's profile to decide how much to invest in their contributions? Christopher Trudeau is back on the show this week with another batch of PyCoder's Weekly articles and projects.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 22, 2026 12:00 PM UTC

Quiz: Build a Tic-Tac-Toe Game With Python and Tkinter

In this quiz, you’ll test your understanding of Build a Tic-Tac-Toe Game With Python and Tkinter.

By working through this quiz, you’ll revisit how to design game logic with Python classes, lay out and update Tkinter widgets, and wire up button clicks to a handler through the event loop.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 22, 2026 12:00 PM UTC