26 May 2026

feedPlanet Python

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 per-interpreter GIL and free-threading configurations from earlier in this series and look at how performance under each of them 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:

26 May 2026 1:38am GMT

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:

26 May 2026 12:00am GMT

25 May 2026

feedPlanet Python

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>

25 May 2026 10:11pm GMT

22 May 2026

feedDjango community aggregator: Community blog posts

Issue 338: Django 6.1 alpha 1 released

News

Django 6.1 alpha 1 released

Django 6.1 alpha 1 has been released, signaling the next round of framework updates headed your way. Plan a quick test run in a staging environment so you can catch compatibility issues early as 6.1 develops.


Wagtail CMS News

Wagtail accessibility statistics for GAAD 2026

Wagtail accessibility statistics for GAAD 2026 give a focused look at how well your CMS setup supports real accessibility needs. Use the figures to spot gaps and prioritize the most impactful improvements.


Updates to Django

Today, "Updates to Django" is presented by Pradhvan from Djangonaut Space! 🚀

Last week we had 16 pull requests merged into Django by 11 different contributors - including 2 first-time contributors!

Congratulations to somi and Kasey for having their first commits merged into Django - welcome on board! 🥳

This week's Django highlights: 🦄

That's all for this week in Django development! 🐍🦄


Sponsored Link

Middleware, but for AI agents

Django middleware composes request handlers. Harnesses do the same for AI agents - Claude Code, Codex, Gemini in one coordinated system. Learn what a harness actually is, why it's a new primitive, and how to engineer one that holds in production. Apache 2.0, open source.


Articles

My experience at PyCon US 2026

A first-person look at PyCon US 2026 with takeaways for developers who care about Python and the community around it. Expect practical impressions from talks and the conference vibe, not a generic recap.

PyCon US 2026 Recap

Will Vincent from PyCharm (and this newsletter!) shares seven days of talks, sprints, and hallway track conversations from this year's event.

My First PyConUS Experience

Jon Gould from Foxley Talent relates his first experience, takeaways, and comparisons to DjangoCons.

PostgreSQL 19 Beta: The Four Features You'll Actually Feel

PostgreSQL 19 Beta brings four changes highlighted for real-world impact, with a focus on what developers will actually notice. Expect a practical walkthrough rather than a long list of release notes.

Core Dispatch #4

Core Dispatch recaps a packed few weeks in the Python core world, including the arrival of Python 3.15 beta 1, free-threading improvements, PEP 788 landing in CPython, and a wave of new core developer activity.

Anything that could go wrong, will. The excuse is optional.

A thoughtful take on Murphy's Law in software engineering: resilient teams don't avoid risk or ignore it, they design systems assuming failure will happen and plan accordingly.

My PyCon US 2026

A chronological recap of PyCon US 2026 in Long Beach, with live notes ranging from the first AI track talk on AI-assisted contributions and maintainer load to security updates, community building, and Djangonaut Space. Expect practical takeaways about how AI affects review and conflict in open source, plus plenty of Django community moments including "Django on the Med."


Events

Organizing DjangoCon Europe 2026: The Afterthoughts | Blog with LOGIC

Find practical after-the-fact takeaways from organizing DjangoCon Europe 2026, focused on the details people usually only notice after the event. A useful read for anyone planning Django community events or sharpening their conference workflow.


Videos

Tech Hiring has got a FRAUD problem!

Tech hiring can attract fraud, from fake postings to misleading recruiting signals. Keep an eye on red flags in job listings and interview processes so you can spot scams early and protect candidates.


Podcasts

Django Chat #204:How France Ditched Microsoft with Samuel Paccoud

France's shift away from Microsoft is tied to decisions and experiences Samuel Paccoud discusses. The focus is on what prompted the move and what it meant operationally for organizations involved.


Django Job Board

Founding Engineer at MyDataValue

Junior Software Developer (Apprentice) at UCS Assist

Technical Lead at UCS Assist

Web Developer at Crossway

PyPI Sustainability Engineer at Python Software Foundation


Projects

mliezun/caddy-snake

Caddy plugin to serve Python apps

AvaCodeSolutions/django-email-learning

An open source Django app for creating email-based learning platforms with IMAP integration and React frontend components.

ehmatthes/gh-profiler

Examine a GitHub user's profile, to help quickly decide how much to invest in their contributions. Was discussed by many maintainers at PyCon US sprints.

22 May 2026 2:00pm GMT

feedPlanet Twisted

Glyph Lefkowitz: Opaque Types in Python

Let's say you're writing a Python library.

In this library, you have some collection of state that represents "options" or "configuration" for a bunch of operations. Such a set of options is a bundle of potentially ever-increasing complexity. Thus, you will want it to have an extremely minimal compatibility surface, with a very carefully chosen public interface, that is either small, or perhaps nothing at all. Such an object conveys state and might have some private behavior, but all you want consumers to be able to do is build it in very constrained, specific ways, and then pass it along as a parameter to your own APIs.

By way of example, imagine that you're wrapping a library that handles shipping physical packages.

There are a zillion ways to do it ship a package. There are different carriers who can ship it for you. There's air freight, and ground freight, and sea freight. There's overnight shipping. There's the option to require a signature. There's package tracking and certified mail. Suffice it to say, lots of stuff.

If you are starting out to implement such a library, you might need an object called something like ShippingOptions that encapsulates some of this. At the core of your library you might have a function like this:

1
2
3
4
5
async def shipPackage(
        how: ShippingOptions,
        where: Address,
    ) -> ShippingStatus:
    ...

If you are starting out implementing such a library, you know that you're going to get the initial implementation of ShippingOptions wrong; or, at the very least, if not "wrong", then "incomplete". You should not want to commit to an expansive public API with a ton of different attributes until you really understand the problem domain pretty well.

Yet, ShippingOptions is absolutely vital to the rest of your library. You'll need to construct it and pass it to various methods like estimateShippingCost and shipPackage. So you're not going to want a ton of complexity and churn as you evolve it to be more complex.

Worse yet, this object has to hold a ton of state. It's got attributes, maybe even quite complex internal attributes that relate to different shipping services.

Right now, today, you need to add something so you can have "no rush", "standard" and "expedited" options. You can't just put off implementing that indefinitely until you can come up with the perfect shape. What to do?

The tool you want here is the opaque data type design pattern. C is lousy with such things (FILE, pthread_*_t, fd_set, etc). A typedef in a header file can easily achieve this.

But in Python, if you expose a dataclass - or any class, really - even if you keep all your fields private, the constructor is still, inherently, public. You can make it raise an exception or something, but your type checker still won't help your users; it'll still look like it's a normal class.

Luckily, Python typing provides a tool for this: typing.NewType.

Let's review our requirements:

  1. We need a type that our client code can use in its type annotations; it needs to be public.
  2. They need to be able to consruct it somehow, even if they shouldn't be able to see its attributes or its internal constructor arguments.
  3. To express high-level things (like "ship fast") that should stay supported as we add more nuanced and complex configurations in the future (like "ship with the fastest possible option provided by the lowest-cost carrier that supports signature verification").

In order to solve these problems respectively, we will use:

  1. a public NewType, which gives us our public name...
  2. which wraps a private class with entirely private attributes, to give us an actual data structure, while not exposing the constructor,
  3. a set of public constructor functions, which returns our NewType.

When we put that all together, it looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from dataclasses import dataclass
from typing import Literal, NewType

@dataclass
class _RealShipOpts:
    _speed: Literal["fast", "normal", "slow"]

ShippingOptions = NewType("ShippingOptions", _RealShipOpts)

def shipFast() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("fast"))

def shipNormal() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("normal"))

def shipSlow() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("slow"))

As a snapshot in time, this is not all that interesting; we could have just exposed _RealShipOpts as a public class and saved ourselves some time. The fact that this exposes a constructor that takes a string is not a big deal for the present moment. For an initial quick and dirty implementation, we can just do checks like if options._speed == "fast" in our shipping and estimation code.

However, the main thing we are doing here is preserving our flexibility to evolve the related APIs into the future, so let's see how we might do that. For example, let's allow the shipping options to contain a concrete and specific carrier and freight method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from dataclasses import dataclass
from enum import Enum, auto
from typing import NewType

class Carrier(Enum):
    FedEx = auto()
    USPS = auto()
    DHL = auto()
    UPS = auto()

class Conveyance(Enum):
    air = auto()
    truck = auto()
    train = auto()

@dataclass
class _RealShipOpts:
    _carrier: Carrier
    _freight: Conveyance

ShippingOptions = NewType("ShippingOptions", _RealShipOpts)

def shipFast() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.FedEx, Conveyance.air))

def shipNormal() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.UPS, Conveyance.truck))

def shipSlow() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.USPS, Conveyance.train))

def shippingDetailed(
    carrier: Carrier, conveyance: Conveyance
) -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(carrier, conveyance))

As a NewType, our public ShippingOptions type doesn't have a constructor. Since _RealShipOpts is private, and all its attributes are private, we can completely remove the old versions.

Anything within our shipping library can still access the private variables on ShippingOptions; as a NewType, it's the same type as its base at runtime, so it presents minimal1 overhead.

Clients outside our shipping library can still call all of our public constructors: shipFast, shipNormal, and shipSlow all still work with the same (as far as calling code knows) signature and behavior.

If you need to build and convey some state within your public API, while avoiding breakages associated with compatibility churn, hopefully this technique can help you do that!


Acknowledgments

Thanks for reading, and thank you to my patrons who are supporting my writing on this blog. If you like what you've read here and you'd like to read more of it, or you'd like to support my various open-source endeavors, you can support my work as a sponsor.


  1. The overhead is minimal, but it is not completely zero. The suggested idiom for converting to a NewType is to call it like a function, as I've done in these examples, but if you are wanting to use this pattern inside of a hot loop, you can use # type: ignore[return-value] comments to avoid that small cost.

22 May 2026 12:33am GMT

21 May 2026

feedDjango community aggregator: Community blog posts

Utrecht (NL) Python meetup summaries

I made summaries at the 4th PyUtrecht meetup (in Nieuwegein, at Qstars this time).

Qstars IT and open source - Derk Weijers

Qstars IT hosted the meeting. It is an infra/programming/consultancy/training company that uses lots of Python.

They also love open source and try to sponsor where possible.

One of the things they are going to open source (next week) is a "cable thermal model", a calculation method to determine the temperature of underground electricity cables. The Netherlands has a lot of net congestion... So if you can have a better grid usage by calculating the real temperature of cables instead of using an estimated temperature, you might be able to increase the load on the cable without hitting the max temperature. Coupled with "measurement tiles" that actually monitor the temperature.

They build it for one of the three big electricity companies in the Netherlands and got permission to open source it so that the other companies can also use it. They hope it will have real impact.

He explained an open source project he started personally: "the space devs". Integrating rocket launch data and providing an API. Now it has five core developers (and got an invitation to the biggest space conference, two years ago!)

Some benefits from writing open source:

  • You build your own portfolio.
  • You can try new technologies. Always nice to have the skill to learn new things.
  • You improve your communication skills (both sending and receiving).
  • You can make your own decisions.
  • You write in the open.
  • Perhaps you help others with your work.
  • You could be part of a cummunity.
  • It is your code.

How to start?

  • Reach out to other communities.
  • Read and improve documentation.
  • Find good first issues.
  • Be proactive.
  • Don't be afraid to ask questions (and don't let negative comments discourage you).

When working on open source, make sure you take security serious. People nowadays like to use supply chain attacks via open source software. So use 2FA and look at your deployment procedure.

Learning Python with Karel - EiEi Tun H

What is Karel <https://github.com/alts/karel>)? A teaching tool/robot for learning programming. You need to steer a robot in an area and have it pick up or dump objects. And... in the meantime you learn how to use functions and loops.

Karel only has a turn_left() function. So if you want to have it turn right, it is handy to add a function for it:

def turn_right():
    turn_left()
    turn_left()
    turn_left()

Simple, but you have to learn it sometime!

In her experience, AI can help a lot when learning to code: it explains stuff to you like you're a five-year-old, and that's perfect.

If you want to play with Karel: https://compedu.stanford.edu/karel-reader/docs/python/en/ide.html

JSON freedom or chaos; how to trust your data - Bart Dorlandt

For this talk, I'm pointing at the PyGrunn summary I made three weeks ago. I liked the talk!

Practical software architecture for Python developers - Henk-Jan van Hasselaar

There are several levels of architecture. Organization level. System level. Application, Code.

Cohesion: "the degree to which the elements inside a module belong together". What does it mean? Working towards the same goal or function. Together means something like distance. When two functions are in separate libraries, they're not together. It is also important for cognitive load.

Coupling: loose coupling versus high coupling. You want loose coupling, so that changes in one module don't affect another module.

You don't really have to worry about coupling and cohesion in existing systems that don't need to be changed. But when you start changing or build something new: take coupling/cohesion into account.

Software architecture is a tradeoff. Seperation of concerns is fine, but it creates layers and thus distance, for instance.

Python is one of the most difficult languages when it comes to clean coding and clean architecture. You're allowed to do so many dirty things! Typing isn't even mandatory...

He showed a simple REST API as an example. Database model + view. But when you change the database model, like a field name, that field name automatically changes in the API response. So your internal database structure is coupled to the function at the customer that consumes the API.

What you actually need to do is to have a better "contract". A domain model. In his example code, it was a Pydantic model with a fixed set of fields. A converter modifies the internal database model to the domain model.

You can also have services, generic pieces of code that work on domain models. And adapters to and from domain models, like converting domain models to csv.

Finding the balance is the software architect's job.

What is the least you should do as a software developer? At least to create a domain layer. Including a validator.

There was a question about how to do this with Django: it is hard. Django's models are everywhere. And you really need a clean domain layer...

21 May 2026 4:00am GMT

My PyCon US 2026

A timeline of my PyCon US 2026 journey, in Long Beach (US), told through the Mastodon posts I shared along the way.

21 May 2026 3:00am GMT

04 Apr 2026

feedPlanet Twisted

Donovan Preston: Using osascript with terminal agents on macOS

Here is a useful trick that is unreasonably effective for simple computer use goals using modern terminal agents. On macOS, there has been a terminal osascript command since the original release of Mac OS X. All you have to do is suggest your agent use it and it can perform any application control action available in any AppleScript dictionary for any Mac app. No MCP set up or tools required at all. Agents are much more adapt at using rod terminal commands, especially ones that haven't changed in 30 years. Having a computer control interface that hasn't changed in 30 years and has extensive examples in the Internet corpus makes modern models understand how to use these tools basically Effortlessly. macOS locks down these permissions pretty heavily nowadays though, so you will have to grant the application control permission to terminal. But once you have done that, the range of possibilities for commanding applications using natural language is quite extensive. Also, for both Safari and chrome on Mac, you are going to want to turn on JavaScript over AppleScript permission. This basically allows claude or another agent to debug your web applications live for you as you are using them.In chrome, go to the view menu, developer submenu, and choose "Allow JavaScript from Apple events". In Safari, it's under the safari menu, settings, developer, "Allow JavaScript from Apple events". Then you can do something like "Hey Claude, would you Please use osascript to navigate the front chrome tab to hacker news". Once you suggest using OSA script in a session it will figure out pretty quickly what it can do with it. Of course you can ask it to do casual things like open your mail app or whatever. Then you can figure out what other things will work like please click around my web app or check the JavaScript Console for errors. Another very important tips for using modern agents is to try to practice using speech to text. I think speaking might be something like five times faster than typing. It takes a lot of time to get used to, especially after a lifetime of programming by typing, but it's a very interesting and a different experience and once you have a lot of practice It starts to to feel effortless.

04 Apr 2026 1:31pm GMT

16 Mar 2026

feedPlanet Twisted

Donovan Preston: "Start Drag" and "Drop" to select text with macOS Voice Control

I have been using macOS voice control for about three years. First it was a way to reduce pain from excessive computer use. It has been a real struggle. Decades of computer use habits with typing and the mouse are hard to overcome! Text selection manipulation commands work quite well on macOS native apps like apps written in swift or safari with an accessibly tagged webpage. However, many webpages and electron apps (Visual Studio Code) have serious problems manipulating the selection, not working at all when using "select foo" where foo is a word in the text box to select, or off by one errors when manipulating the cursor position or extending the selection. I only recently expanded my repertoire with the "start drag" and "drop" commands, previously having used "Click and hold mouse", "move cursor to x", and "release mouse". Well, now I have discovered that using "start drag x" and "drop x" makes a fantastic text selection method! This is really going to improve my speed. In the long run, I believe computer voice control in general is going to end up being faster than WIMP, but for now the awkwardly rigid command phrasing and the amount of times it misses commands or misunderstands commands still really holds it back. I've been learning the macOS Voice Control specific command set for years now and I still reach for the keyboard and mouse way too often.

16 Mar 2026 11:04am GMT