Planet Python
Last update: May 28, 2026 07:44 PM UTC
May 28, 2026
Real Python
Quiz: BNF Notation: Dive Deeper Into Python's Grammar
In this quiz, you’ll test your understanding of BNF Notation: Dive Deeper Into Python’s Grammar.
By working through this quiz, you’ll revisit how to read Python’s grammar rules, recognize terminals and nonterminals, and interpret the BNF fragments that appear throughout the official documentation.
[ 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 ]
Bob Belderbos
Two Python Scoping Bugs: A Lesson in Object Lifetimes
Your app works fine with one user. You open a second browser tab and the data is wrong. Your tests pass individually but fail when run together. The culprit: a global object created at module scope.
How it starts
I see this a lot in Python web projects:
# database.py
from sqlmodel import create_engine, Session
engine = create_engine("sqlite:///database.db")
def get_session():
with Session(engine) as session:
yield session
This 'innocent' engine is created the moment database.py is first imported. Every module that imports from database shares the same engine, the same connection pool, the same database file. For a simple script, this is fine. For a multi-module app, it creates hidden coupling and shared state.
The test isolation problem
I hit this recently in a FastAPI app:
# test_app.py
from myapp.database import engine, create_db_and_tables, clear_db_and_tables
@pytest.fixture(autouse=True)
def setup_database():
clear_db_and_tables()
create_db_and_tables()
def test_create_race(race_events):
championship = create_races(2026, race_events)
assert championship.id == 1 # Passes alone, fails in suite
That assert championship.id == 1 works when the test runs first. Run it after another test that inserts data, and the auto-increment ID comes back as 2. The fixture does its job, but state still leaks between tests in subtle ways: connection pool state, cached metadata, and SQLite's own bookkeeping on the shared file can carry over even with drop/recreate cycles.
The root cause is upstream: every test reaches for the same module-level engine pointed at the same on-disk database. If you want true isolation, the engine itself has to be per-test, not the cleanup ritual around it.
The fix is creating an engine per test session:
@pytest.fixture
def engine():
engine = create_engine("sqlite://", echo=False)
SQLModel.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(engine):
with Session(engine) as session:
yield session
Now each test gets a fresh database (no scope defined on the fixture decorator means function scope = per test). No cleanup needed. No shared state.
Alternatively, keep the module-level engine and wrap each test in a transaction you roll back at teardown (sqlmodel's Session supports this).
If you omit engine.dispose() in the code above, you may see a ResourceWarning: unclosed database, but only when running pytest --cov. Coverage's sys.settrace() hook keeps frame locals alive longer, delaying GC of the engine.
The shared simulator problem
The database engine bug is about too much sharing. Here is the inverse: not enough sharing, which breaks in a different way.
Consider a race simulation dashboard. FakeDataSource wraps a RaceSimulator that holds the full mutable race state, driver positions, lap counter, cumulative changes, and advances it on each call:
class FakeDataSource(RaceDataSource):
def __init__(self, data_file: Path, delay_ms: int = 100):
drivers = self._load_drivers(data_file)
self.simulator = RaceSimulator(drivers=drivers) # mutable state lives here
async def get_positions(self, fixture_id: str) -> list[Position]:
self.simulator.tick() # randomly swaps adjacent positions, advances lap
return self.simulator.get_current_positions()
The FastAPI dependency looks like this:
def get_data_source() -> RaceDataSource:
source_type = config("DATA_SOURCE", default="fake")
if source_type == "fake":
return FakeDataSource(data_file=..., delay_ms=...) # new instance every call
...
def get_race_data_source() -> RaceDataSource:
return get_data_source()
FastAPI calls get_race_data_source() once per request. Each browser tab that opens the SSE stream gets a brand new FakeDataSource with a brand new RaceSimulator starting at lap 1 with drivers in their initial order.
The random swaps then diverge independently: Tab A shows Verstappen in P1 at lap 12, Tab B shows Hamilton in P1 at lap 3. Neither reflects a shared reality, because there is no shared state at all.
Two fixes, from quick to idiomatic
1. cache: one line, works immediately
from functools import lru_cache
@lru_cache(maxsize=1)
def get_data_source() -> RaceDataSource:
source_type = config("DATA_SOURCE", default="fake")
if source_type == "fake":
return FakeDataSource(...)
return SportmonksDataSource()
One instance for the lifetime of the process. Simple, but hard to override in tests.
2. FastAPI app.state: idiomatic and testable
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.data_source = get_data_source()
yield
app = FastAPI(lifespan=lifespan)
def get_race_data_source(request: Request) -> RaceDataSource:
return request.app.state.data_source
The data source is created once at startup, shared across all requests, and easy to replace in tests via app.dependency_overrides[get_race_data_source] = lambda: test_source.
Key takeaways
- Module-level objects are created at import time and shared everywhere: global mutable state, and a common source of subtle bugs.
- Tests that share a database engine aren't isolated, even with setup/teardown fixtures.
- Web apps that create per-request instances lose shared state; apps that share module-level instances lose testability.
- Use
app.stateorcachefor shared runtime state; override the FastAPI dependency in tests for isolation.
The rule of thumb: if an object holds mutable state, pick its scope deliberately. Too broad (module scope) and tests leak into each other. Too narrow (per-request) and there's no shared reality. Match the scope to the object's intended lifetime.
Or put more sharply: any module-level object that holds mutable state or owns a resource (DB engines, HTTP clients, caches, queues, connection pools) should be encapsulated. Move it into a fixture, a Depends(), or app.state. Constants and pure values at module scope are fine; resources are not.
The cost of "just import it" is paid later, in test isolation, debugging, and concurrency. Under real concurrency the GIL hides this class of bug until it doesn't, see a race condition Rust wouldn't have let me write, where the same module-global pattern leaked one user's data into another user's response.
May 27, 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
Python 3.14: Fix, decorators were breaking when disabling deferred annotations. (Fixed in 4.0.1 already.)
Fix, nested loops could have wrong traces lead to mis-optimization. (Fixed in 4.0.1 already.)
Plugins: Fix, run-time check of package configuration was incorrect. (Fixed in 4.0.1 already.)
Compatibility: Fix,
__builtins__lacked necessary compatibility in compiled functions. (Fixed in 4.0.1 already.)Distutils: Fix, incorrect UTF-8 decoding was used for TOML input file parsing. (Fixed in 4.0.1 already.)
Fix, multiple hard value assignments could cause compile time crashes. (Fixed in 4.0.1 already.)
Fix, string concatenation was not properly annotating exception exits. (Fixed in 4.0.2 already.)
Windows: Fix,
--verbose-outputand--show-modules-outputdid not work with forward slashes. (Fixed in 4.0.2 already.)Python 3.14: Fix, there were various compatibility issues including dictionary watchers and inline values. (Fixed in 4.0.2 already.)
Python 3.14: Fix, stack pointer initialization to
localspluswas incorrect to avoid garbage collection issues. (Fixed in 4.0.2 already.)Python 3.12+: Fix, generic type variable scoping in classes was incorrect. (Fixed in 4.0.2 already.)
Python 3.12+: Fix, there were various issues with function generics. (Fixed in 4.0.2 already.)
Python 3.8+: Fix, names in named expressions were not mangled. (Fixed in 4.0.2 already.)
Plugins: Fix, module checksums were not robust against quoting style of module-name entry in YAML configurations. (Fixed in 4.0.2 already.)
Plugins: Fix, doing imports in queried expressions caused corruption. (Fixed in 4.0.2 already.)
UI: Fix, support for
uv_buildin the--projectoption was broken. (Fixed in 4.0.2 already.)Compatibility: Fix, names assigned in assignment expressions were not mangled. (Fixed in 4.0.2 already.)
Python 3.12+: Fix, there were still various issues with function generics. (Fixed in 4.0.3 already.)
Clang: Fix, debug mode was disabled for clang generally, but only ClangCL and macOS Clang didnât want it. (Fixed in 4.0.3 already.)
Zig: Fix,
--windows-console-mode=attach|disablewas not working when using Zig. (Fixed in 4.0.3 already.)macOS: Fix, yet another way self dependencies can look like, needed to have support added. (Fixed in 4.0.3 already.)
Python 3.12+: Fix, generic types in classes had bugs with multiple type variables. (Fixed in 4.0.3 already.)
Scons: Fix, repeated builds were not producing binary identical results. (Fixed in 4.0.3 already.)
Scons: Fix, compiling with newer Python versions did not fall back to Zig when the developer prompt MSVC was unusable, and error reporting could crash. (Fixed in 4.0.4 already.)
Zig: Fix, the workaround for Windows console mode
attachordisablewas incorrectly applied on non-Windows platforms. (Fixed in 4.0.4 already.)Standalone: Fix, linking with Python Build Standalone failed because
libHacl_Hash_SHA2was not filtered out unconditionally. (Fixed in 4.0.4 already.)Python 3.6+: Fix, exceptions like
CancelledErrorthrown into an async generator awaiting an inner awaitable could be swallowed, causing crashes. (Fixed in 4.0.4 already.)Fix, not all ordered set modules accepted generators for update. (Fixed in 4.0.5 already.)
Plugins: Disabled warning about rebuilding the
pytokensextension module. (Fixed in 4.0.5 already.)Standalone: Filtered
libHacl_Hash_SHA2from link libs unconditionally. (Fixed in 4.0.5 already.)Debugging: Disabled unusable unicode consistency checks for Python versions 3.4 to 3.6. (Fixed in 4.0.5 already.)
Python3.12+ Avoided cloning call nodes on class level which caused issues with generic functions in combination with decorators. (Added in 4.0.5 already.)
Python 3.12+: Added support for generic type variables in
async deffunctions. (Added in 4.0.5 already.)UI: Fix, flushing outputs for prompts was not working in all cases when progress bars were enabled. (Fixed in 4.0.6 already.)
UI: Fix, unused variable warnings were missing at C compile time when using
zigas a C compiler. (Fixed in 4.0.6 already.)Scons: Fix, forced stdout and stderr paths as a feature was broken. (Fixed in 4.0.6 already.)
Fix, replacing a branch did not accurately track shared active variables causing optimization crashes. (Fixed in 4.0.7 already.)
macOS: Fix, failed to remove extended attributes because files need to be made writable first. (Fixed in 4.0.7 already.)
Fix, dict
popandsetdefaultusing with:=rewrites lacked exception-exit annotations for un-hashable keys. (Fixed in 4.0.8 already.)Python 3.13: Fix, the
__parameters__attribute of generic classes was not working. (Fixed in 4.0.8 already.)Python 3.11+: Fix, starred arguments were not working as type variables. (Fixed in 4.0.8 already.)
Python2: Fix,
FileNotFoundErrorcompatibility fallback handling was not working properly. (Fixed in 4.0.8 already.)Compatibility: Fix, loop ownership check in value traces was missing, causing issues with nested loops.
Windows: Improved
--windows-console-mode=attachto properly handle console handles, enabling cases likeos.systemto work nicely.Python2: Fix, there was a compatibility issue where providing default values to the
mkdtempfunction was failing.Windows: Fix, there were spurious issues with C23 embedding in 32-bit MinGW64 by switching to
coff_objresource mode for it as well.Plugins: Fix, the
post-import-codeexecution could fail because the triggering sub-package was not yet available insys.modules.UI: Fix, listing package DLLs with
--list-package-dllswas broken due to recent plugin lifecycle changes.UI: Fix,
--list-package-exewas not working properly on non-Windows platforms failing to detect executable files correctly.UI: Handled paths starting with
{PROGRAM_DIR}the same as a relative path when parsing the--onefile-tempdir-specoption.Plugins: Followed multiprocessing
forkserverchanges for newer Python versions.Python 3.12+: Fix, generic class type parameters handling was incorrect.
Python 3.12: Fix, deferred evaluation of type aliases was failing.
Python 3.12+: Aligned
sumbuilt-in float summation with CPythonâs compensated sum for better accuracy.Python 3.10+: Fix, uncompiled coroutine
throw()return handling was incorrect, restoring completed coroutine results viaStopIteration.valuerather than exposing them as ordinary return values to the outer await chain.Python 3.13+: Fix, uncompiled coroutine
cancel()/awaitsuspension handling was incorrect, improved to ensure integration compatibility.macOS: Made finding
create-dmgmore robustly by also checking the Homebrew path for Intel and fromPATHproperly.Compatibility: Fix, class frames were not exposing frame locals.
UI: Detected
static-libpythonproblems, which affected some forms of Anaconda.Distutils: Rejected
--projectmixed with--mainarguments as it is not useful.macOS: Fix,
zigfromPATHor fromziglangwas not being used.Distutils: Fix, the wrong
module-rootconfig value was being checked foruvbuild backend.macOS: Fix, was attempting to change removed (rejected) DLLs, which of course failed and errored out.
Python 3.14: Fix, tuple reuse was not fully compatible, potentially causing crashes due to outdated hash caches.
Fix, fake modules were still being attempted to located when imported by other code, which could conflict with existing modules.
Python 3.5+: Fix, failed to send uncompiled coroutines the sent in value in
yield from.Fix, older
gcccompilers lacking newer intrinsic methods had compilation issues that needed to be addressed.Standalone: Fix, multiphase module extension modules with post-load code were not working properly.
Fix, Avoid using the non-inline copy of
pkg_resourceswith the inline copy of Jinja2. These could mismatch and cause errors.Fix, loops could make releasing of previous values very unclear, causing optimization errors.
Fix,
incbinresource mode was not working with oldgccC++ fallback.Python 3.4 to 3.6: Fix, bytecode demotion was not working properly for these versions, also bytecode only files not working.
Plugins: Added a check for the broken
patchelfversions 0.10 and 0.11 to prevent breaking Qt plugins.Android: Allowed
patchelfversion 0.18 on Android.Windows: Fix, the header path for self uninstalled Python was not detected correctly.
Release: Fix, inclusion of the
pkg_resourcesinline copy for Python 2 to source distributions was missing.UI: Detected the OBS versions of SUSE Linux better.
Suse: Allowed using
patchelf0.18.0 there too.Python 3.11: Fix, package and module dicts were not aligned close enough to avoid a CPython bug.
Fix, unbound compiled methods could crash when called without an object passed.
Standalone: Fix, multiphase module extension modules with postload. (Fixed in 4.0.8 already.)
Onefile: Fix, while waiting for the child, it may already be terminated.
macOS: Removed existing absolute rpaths for Homebrew and MacPorts.
Python 3.14: Avoided warning in CPython headers.
Python 3.14: Followed allocator changes more closely.
Compatibility: Avoided using
pkg_resourcesfor Jinja2 template location for loading.No-GIL: Applied some bug fixes to get basic things to work.
Package Support
Standalone: Add support for newer
paddleversion. (Added in 4.0.1 already.)Standalone: Add workaround for refcount checks of
pandas. (Fixed in 4.0.1 already.)Standalone: Add support for newer
h5pyversion. (Added in 4.0.2 already.)Standalone: Add support for newer
scipypackage. (Added in 4.0.2 already.)Plugins: Revert accidental
os.getenvoveros.environ.getchanges in anti-bloat configurations that stopped them from working. Affected packages arenetworkx,persistent, andtensorflow. (Fixed in 4.0.5 already.)Standalone: Added missing DLLs for
openvino. (Added in 4.0.7 already.)Enhanced the package configuration YAML schema by adding the
relative_toparameter forfrom_filenamesDLL specification, avoiding error-prone purely relative paths.Standalone: Fix,
flet_desktopapp assets were missing, now preserving the packaged runtime and sidecar DLLs.Standalone: Added support for the
tyropackage.Standalone: Added data files for the
perfettopackage.Standalone: Added support for
anyioprocess forking.Standalone: Added support for the
plotly.graphpackage.Anaconda: Fix, dependencies for the
numpyconda package on Windows were incorrect.Plugins: Enhanced the auto-icon hack in PySide6 to use compatible class names.
Standalone: Fix, Qt libraries were duplicated with
PySide6WebEngine framework support on macOS.Plugins: Fix, automatic detection of
mypycruntime dependencies was including all top level modules of the containing package by accident. (Fixed in 4.0.5 already.)Anaconda: Fix,
delvewheelplugin was not working with Python 3.8+. This enhances compatibility with installed PyPI packages that use it for their DLLs. (Fixed in 4.0.6 already.)Plugins: Fix, our protection workaround could confuse methods used with
PySide6.
New Features
UI: Added the
--recommended-python-versionoption to display recommended Python versions for supported, working, or commercial usage.UI: Add message to inform users about
Nuitka[onefile]if compression is not installed. (Added in 4.0.1 already.)UI: Add support for
uv_buildin the--projectoption. (Added in 4.0.1 already.)Onefile: Allow extra includes as well. (Added in 4.0.2 already.)
UI: Add
nuitka-project-setfeature to define project variables, checking for collisions with reserved runtime variables. (Added in 4.0.2 already.)Scons: Added new option to select
--reproduciblebuilds or not. (Added in 4.0.6 already.)Python 3.10+: Added support for
importlib.metadata.package_distributions(). (Added in 4.0.8 already.)Plugins: Added support for the multiprocessing
forkservercontext. (Added in 4.0.8 already, for 4.1 Python 3.6 and earlier, as well as 3.14 support were added too.)Reports: Added structured resource usage (
rusage) performance information to compilation reports.Reports: Included individual module-level C compiler caching (
ccache/clcache) statistics in compilation reports.Added support for detecting and correctly resolving the Python prefix for the
PyEnv on HomebrewPython flavor.macOS: Added support for
rusageinformation for Scons.UI: Added the
__compiled__.extension_filenameattribute to give the real filename of the containing extension module.Windows: Added support for
--clangor ARM. (Added in 4.0.8 already.)Windows: Added support for resources names as not just integers, important when we copy them from template files.
MacPorts: Added basic support for this Python flavor. More work will be needed to get it to work fully though.
Optimization
Avoid including
importlib._bootstrapandimportlib._bootstrap_external. (Added in 4.0.1 already.)Linux: Cached the
syscallused for time keeping during compilation to avoid loadinglibcfor each trace. (Added in 4.0.8 already.)UI: Output a warning for modules that remain unfinished after the third optimization pass.
Added an extra micro pass trigger when new variables are introduced or variable usage changes severely, ensuring optimizations are fully propagated, avoiding unnecessary extra full passes.
Provided scripts to compile Python statically with PGO tailored for Nuitka on Linux, Windows, and macOS.
Added support for running the Data Composer tool from a compiled Nuitka binary without spawning an uncompiled Python process.
Enhanced the usage of
vectorcallforPyCFunctionobjects by directly checking for its presence instead of relying purely on flags, allowing more frequent use of this faster execution path.Cached frequently used declarations for top-level variables to speed up C code generation.
Sped up trace collection merging by avoiding unnecessary set creation and using a set instead of a list for escaped traces.
Optimized plugin hook execution by tracking overloaded methods and added an option to show plugin usage statistics.
Improved performance of module location by avoiding unnecessary module name reconstruction and redundant filesystem checks for pre-loaded packages.
Improved the caching of distribution name lookups to effectively avoid repeated IO operations across all package types.
Plugins: Cached callback plugin dispatch for
onFunctionBodyParsingandonClassBodyParsingto skip argument computation when no plugin overrides them.Python 3.13: Handled sub-packages of
pathlibas hard modules.Handled hard attributes through merge traces as well.
Made constant blobs more compact by avoiding repeated identifiers and unnecessary fields.
Enhanced Python compilation scripts further. (Fixed in 4.0.8 already.)
Recognized late incomplete variables better. (Fixed in 4.0.8 already.)
Made constant blobs more compact. (Fixed in 4.0.8 already.)
Optimized calls with only constant keywords and variable posargs too.
Anti-Bloat
Fix, memory bloat occurred when C compiling
sqlalchemy. (Fixed in 4.0.2 already.)Avoid using
pydocinPySimpleGUI. (Added in 4.0.2 already.)Avoided using
doctestfromzodbpickle. (Added in 4.0.5 already.)Avoided inclusion of
cythonwhen usingpyav. (Added in 4.0.7 already.)Avoided including
typing_extensionswhen usingnumpy. (Added in 4.0.7 already.)
Organizational
UI: Relocated the warning about the available source code of extension modules to be evaluated at a more appropriate time.
Debian: Remove recommendation for
libfuse2package as it is no longer useful.Debian: Used
platformdirsinstead ofappdirs.Debugging: Removed Python 3.11+ restriction for
clang-formatas it is available everywhere, even Python 2.7, and we still want nicely formatted code when we read things. (Added in 4.0.6 already.)Removed no longer useful inline copy of
wax_off. We have our own stubs generator project.Release: Added missing package to the CI container for building Nuitka Debian packages.
Developer: Updated AI instructions for creating Minimal Reproducible Examples (MRE) to skip unneeded C compilation.
Debugging: Added an internal function for checking if a string is a valid Python identifier.
AI: Added a task in Visual Studio Code to export the currently selected Python interpreter path to a file, making it available as âpythonâ and âpipâ matching the selected interpreter. This makes it easier to use a specific version with no instructions needed.
AI: Updated the rules to instruct AI to only generate useful comments that add context not present in the code.
Containers: Added template rendering support for Jinja2 (
.j2) container files in our internal Podman tools.Projects: Clarified the current status and rationale of Python 2.6 support in the developer manual.
Debugging: Added experimental flag
--experimental=ignore-extra-micro-passto allow ignoring extra micro pass detection.Visual Code: Added integration scripts for
bashandzshautocompletion of Nuitka CLI options. These are now also integrated into Visual Studio Code terminal profiles and the Debian package.RPM: Included the Python compile script for Linux.
RPM: Removed the requirement for
distutilsin the spec.
Tests
Install only necessary build tools for test cases.
Avoided spurious failures in reference counting tests due to Python internal caching differences. (Fixed in 4.0.3 already.)
Fix, the parsing of the compilation report for reflected tests was incorrect.
Python 3.14: Ignored a syntax error message change.
Python 3.14: Added test execution support options to the main test runner to use this version as well.
Fix, the runner binary path was mishandled for the third pass of reflected compilations.
Removed the usage of obsolete plugins in reflected compilation tests.
Debugging: Prevented boolean testing of
namedtuplesto avoid unexpected bugs.Added the
Testsuffix to syntax test files and disabled âpythonâ mode and spell checking for them to resolve issues reported in IDEs.Fix, newline handling in diff outputs from the output comparison tool was incorrect.
Covered
post-import-codefunctionality with a new subpackage test case.Prevented the program test suite from running an unnecessary variant to save execution time.
macOS: Ignored differences from GUI framework error traces in headless runs in output comparisons.
Reflected test for Nuitka, where it compiles itself and compares its operation has been restored to functional state.
Used the new method to clear internal caches if available for reference counts.
Disabled running nested loops test with Python 2.6.
Containers: Detected Python 2 defaulting containers in Podman tooling.
Cleanups
UI: Fix, there was a double space in the Windows Runtime DLLs inclusion message. (Fixed in 4.0.1 already.)
Onefile: Separated files and defines for extra includes for onefile boot and Python build.
Scons: Provided nicer errors in case of âunsetâ variables being used, so we can tell it.
Refactored the process execution results to correctly utilize our
namedtuplesvariant, that makes it easier to understand what code does with the results.Quality: Enabled automatic conversion of em-dashes and en-dashes in code comments to the autoformat tool. AI wonât stop producing them and they can cause
SyntaxErrorfor older Python versions, nor is unnecessarily using UTF-8 welcome.Ensured that cloned outline nodes are assigned their correct names immediately upon creation, that avoids inconsistencies during their creation.
Quality: Updated to the latest versions of
blackand adopted a fasterisortexecution by caching results.Quality: Modified the PyLint wrapper to exit gracefully instead of raising an error when no matching files require checking.
Quality: Avoided checking YAML package configuration files twice, since autoformat already handles them.
Quality: Ensured that YAML package configuration checks output the original filename instead of the temporary one when a failure occurs.
Quality: Prevented pushing of tags from triggering git pre-push quality checks.
Quality: Silenced the output of
optipngandjpegoptimduring image optimization auto-formatting.Visual Code: Added the generated Python alias path file to the ignore list.
Quality: Enabled auto-formatting for the Nuitka devcontainer configuration file.
Watch: Avoided absolute paths in compilation to make reports more comparable across machines.
Quality: Changed
mdformatchecks to run only once and silently.Scons: Disabled format security errors in debug mode and moved Python-related warning disables into common build setup code.
Quality: Updated to the latest
deepdiffversion.Scons: Avoided MSVC telemetry since it can produce outputs that break CI.
Debugging: Enhanced non-deployment handler for importing excluded modules.
Split import module finding functionality into more pieces for enhanced readability.
Debugging: Added more assertions for constants loading and checking.
macOS: Dropped the
universaltarget arch.Debugging: Added more traces for deep hash verification.
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.
PyCharm
Build a Live Object Detection App for the Reachy Mini With TensorFlow and PyCharm
This is a guest post from Iulia Feroli, founder of the Back To Engineering YouTube community.
In this tutorial, we build a live object detection app using TensorFlow and PyCharm, then deploy it onto the Reachy Mini open-source robot for real-time object tracking.
Reachy Mini is a compact open-source robot built in collaboration by Pollen Robotics, Hugging Face, and Seeed Studio. It has been going viral lately, getting mentioned in NVIDIA videos and even in the keynotes at some of their conferences. What makes it particularly interesting is that not only is all the code open-source, the body is too, which means you can print your own parts and develop your own apps to run on it.
There is an app store of community-built projects you can explore and try, and easily contribute to. Anything conversational or camera-based is especially fun to build because of the hardware it ships with: a speaker, a microphone, and a camera, plus expressive antennas for emotions.
This really highlights the unique new type of robot that the Reachy Mini embodies: It almost feels like it is a physical representation of an LLM or an AI agent, rather than a robot that has AI added to it. It does not have a body that moves around or hands to grab things, so its main selling point is really its brain. That design choice shapes what is most interesting to build with it.
Letâs learn how to build a TensorFlow object detection app and deploy it on the Reachy Mini, which will then allow us to do live object tracking. You can head over to the PyCharm channel for the full code breakdown and try it at home. All the code is in the Reachy-mini-object-detection GitHub repository.
For an introduction to the robot, you can first watch Iuliaâs video here:
What youâll learn
- How to build a real-time TensorFlow object detection pipeline.
- How to use SSD MobileNet V2 from TensorFlow Hub.
- How to create a TensorFlow object detection example with OpenCV.
- How to run live webcam inference in PyCharm notebooks.
- How to deploy object detection on the Reachy Mini robot.
- How to track detected objects using head movement logic.
- How to stream annotated detections to a live dashboard.
What we are building
The project is split into two stages.
Stage 1 is a standalone notebook that runs entirely on your laptop using your webcam. No robot needed. This is where we make sure the detection pipeline works correctly before touching any hardware.
Stage 2 is a Reachy Mini app that integrates the same model with the robot: Her head moves to follow detected objects, her antennas wiggle when she spots something new, and a live web dashboard at http://0.0.0.0:8042 shows the annotated camera feed and detections.
You can follow along with the step-by-step video tutorial:
How TensorFlow object detection works: Step by step
1. Capture an image frame from the webcam.
2. Convert the frame into a TensorFlow tensor.
3. Run inference through the pretrained model.
4. Receive bounding boxes, labels, and confidence scores.
5. Filter low-confidence detections.
6. Draw annotated results onto the frame.
7. Display the processed image in real time.
Prerequisites
- Python 3.12+.
- PyCharm with its Jupyter Notebook integration.
- A Reachy Mini for Stage 2 (Stage 1 runs entirely on your laptop).
- Some familiarity with TensorFlow basics â if you are brand new to it, the previous post in this series is a good starting point.
Stage 1: Building a Tensorflow object detection pipeline in PyCharm
Before connecting the robot, we want to make sure the TensorFlow part works independently. We are going to make a notebook that only executes through our object detection model and makes it run smoothly. PyCharmâs native notebook integration is a great fit here: You can inspect each step of the pipeline and visualize results inline.
The object detection model
We are using SSD MobileNet V2 from TensorFlow Hub, trained on Open Images V4. This popular model from Google provides SSD-based object detection and has been trained on a lot of open images. With a little bit of fine-tuning you can deploy it with your own use case, though for this tutorial, the general model works well without any fine-tuning at all.
It runs at around 10 FPS on CPU, which is fast enough for responsive real-time behavior on the robot.
Install dependencies
!pip install tensorflow tensorflow-hub opencv-python numpy Pillow
Load the model
import tensorflow as tf
import tensorflow_hub as hub
import numpy as np
import cv2
import time
from IPython.display import display, clear_output
from PIL import Image
MODEL_HANDLE = "https://tfhub.dev/google/openimages_v4/ssd/mobilenet_v2/1"
print(f"TensorFlow version: {tf.__version__}")
print("Loading model (first time downloads ~30MB)...")
detector = hub.load(MODEL_HANDLE)
print("Model loaded!")
The model is about 30 megabytes and gets cached locally after the first download. Because it is very generalized, it can work across a lot of different scenarios without needing additional training data, which makes it a lot easier to get started.
Detection and drawing helpers
We need two helper functions: one to run inference and return a list of detections, and one to draw the bounding boxes on the frame. These are the same functions we use later in the Reachy app.
def detect_objects(frame_bgr, min_score=0.5, max_detections=10):
rgb = frame_bgr[:, :, ::-1]
img_tensor = tf.image.convert_image_dtype(rgb, tf.float32)[tf.newaxis, ...]
results = detector.signatures['default'](img_tensor)
boxes = np.array(results["detection_boxes"])
scores = np.array(results["detection_scores"])
class_labels = np.array(results["detection_class_entities"])
if boxes.ndim > 2:
boxes = boxes[0]
if scores.ndim > 1:
scores = scores[0]
if class_labels.ndim > 1:
class_labels = class_labels[0]
scores = np.atleast_1d(scores)
indices = [i for i, score in enumerate(scores) if score >= min_score][:max_detections]
detections = []
for idx in indices:
ymin, xmin, ymax, xmax = boxes[idx]
label = class_labels[idx].decode('utf-8') if isinstance(class_labels[idx], bytes) else str(class_labels[idx])
detections.append({
"box": [ymin, xmin, ymax, xmax],
"score": float(scores[idx]),
"label": label
})
return detections
def draw_detections(frame_bgr, detections):
h, w = frame_bgr.shape[:2]
annotated = frame_bgr.copy()
for det in detections:
ymin, xmin, ymax, xmax = det["box"]
x1, y1 = int(xmin * w), int(ymin * h)
x2, y2 = int(xmax * w), int(ymax * h)
color = (0, 255, 0)
cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 2)
label = f"{det['label']} {det['score']:.0%}"
font_scale, thickness = 0.6, 2
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
cv2.rectangle(annotated, (x1, y1 - th - 8), (x1 + tw + 4, y1), color, -1)
cv2.putText(annotated, label, (x1 + 2, y1 - 4),
cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), thickness)
return annotated
The detect_objects function runs inference using the modelâs detect_objects entry point and handles flattening the batch dimension from the output tensors. Labels come back as bytes from the model, so we decode them to strings before returning.
Test on a single frame
cap = cv2.VideoCapture(0)
ret, frame = cap.read()
cap.release()
if not ret:
print("ERROR: Could not access webcam. Make sure no other app is using it.")
else:
print(f"Frame captured: {frame.shape}")
t0 = time.time()
detections = detect_objects(frame)
elapsed = time.time() - t0
print(f"Inference time: {elapsed:.2f}s ({1/elapsed:.1f} FPS)")
print(f"Found {len(detections)} objects:")
for d in detections:
print(f" - {d['label']}: {d['score']:.0%}")
annotated = draw_detections(frame, detections)
display(Image.fromarray(annotated[:, :, ::-1]))
This is the stage where you check that the model is detecting correctly and the bounding boxes are drawn in the right places. The inline image display in PyCharmâs notebook view makes it easy to see the result right there in the cell.
Running real-time TensorFlow object detection with OpenCV
Once the single-frame test looks good, you can run it continuously:
cap = cv2.VideoCapture(0)
if not cap.isOpened():
print("ERROR: Could not open webcam.")
else:
print("Running live detection... (interrupt kernel to stop)")
try:
while True:
ret, frame = cap.read()
if not ret:
break
t0 = time.time()
detections = detect_objects(frame)
fps = 1.0 / max(time.time() - t0, 0.001)
annotated = draw_detections(frame, detections)
cv2.putText(annotated, f"{fps:.1f} FPS", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2)
clear_output(wait=True)
display(Image.fromarray(annotated[:, :, ::-1]))
labels = ", ".join(f"{d['label']} ({d['score']:.0%})" for d in detections)
print(f"{fps:.1f} FPS | {len(detections)} objects: {labels or 'none'}")
except KeyboardInterrupt:
print("Stopped.")
finally:
cap.release()
print("Camera released.")
At this point we have built a notebook that works with just having object detection and we can use this with a simple camera of whatever type you have around. Now, we can wrap it up and make it into an app that we can deploy on the Reachy.
Stage 2: Deploying the TensorFlow object detection app on the Reachy Mini
The Reachy Mini app lives in the reachy_mini_object_detector/ folder and extends the detection logic with head tracking, antenna reactions, and a web dashboard. Weâve followed the guidelines for building Reachy Apps laid out in this blog post. Particularly, we can leverage a helper LLM system like Claude by giving it the predefined Agent Helper documentation.
Project structure
reachy_mini_object_detector/
âââ pyproject.toml
âââ reachy_mini_object_detector/
âââ detector.py # TF Hub model wrapper
âââ main.py # App: head tracking + web dashboard
âââ static/ # Web UI assets (served at :8042)
The detector.py file wraps the model and the detect_objects logic. main.py imports from it and adds everything specific to the robot.
Installing the app
From the Reachy Mini dashboard, under Apps, or by manually adding:
pip install git+https://huggingface.co/spaces/backtoengineering/reachy_mini_object_detector
How head tracking works
The app runs two loops in parallel: an inference thread that grabs frames from the robotâs camera and runs detection, and a main control loop at around 50Hz that handles head movement and antenna control.
The head tracking feature maps the detected objectâs position in the frame to a yaw and pitch offset for the head. The camera has a horizontal field of view of 60 degrees and a vertical field of view of 45 degrees. When an object is at the center of the frame its center_x is 0.5, so subtracting 0.5 and multiplying by the field of view gives the angle offset to track it:
target_yaw = -(largest.center_x - 0.5) * CAMERA_FOV_H_DEG target_pitch = (largest.center_y - 0.5) * CAMERA_FOV_V_DEG
Rather than snapping the head instantly to that target, the app uses a smoothing factor (TRACKING_ALPHA = 0.15) so the movement looks natural:
self._current_yaw += TRACKING_ALPHA * (target_yaw - self._current_yaw) self._current_pitch += TRACKING_ALPHA * (target_pitch - self._current_pitch)
When nothing is detected, the head slowly drifts back toward center rather than freezing in place.
Antenna wiggle
The antennas wiggle when a new object class is first detected, not on every frame. The app keeps track of which classes have already been seen in _seen_classes, and when something new appears it sets a wiggle timer for 1.5 seconds. During that window, the control loop drives a sinusoidal antenna movement as follows:
phase = (t - t0) * 8.0 # fast wiggle antenna_val = np.deg2rad(20.0 * np.sin(phase)) antennas = np.array([antenna_val, -antenna_val]
This makes the interaction feel intentional: Reachy reacts when she sees something new, rather than wiggling constantly while tracking.
The web dashboard
The app serves a live dashboard (available at http://0.0.0.0:8042) with the annotated camera feed (as an MJPEG stream), the current detection list, an FPS counter, and a toggle to enable or disable head tracking. This is useful during development because you can see exactly what the model is detecting from the robotâs perspective in real time.
Where to go next
This is a great starting point and there are a lot of directions you can take it:
- Run the app with a specific use case in mind. The model is general, but if you want Reachy to recognize specific objects you can fine-tune on your own dataset using TensorFlowâs Object Detection API.
- Add more apps. There are many apps that users have already created in the Reachy Mini store, and building one that uses both the camera and the conversational capabilities together opens up a lot of possibilities.
- Connect to physical arms. Something I would really like to explore next is connecting Reachy to the SO-101 arms, so she can actually reach out and do things in the physical world as well as see them.
You can find all the code in the Reachy-mini-object-detection repository. Everything is open-source, so feel free to build on it, adapt it, or deploy your own version.
FAQs
What is TensorFlow object detection?
TensorFlow object detection is a computer vision technique that uses machine learning models to identify and locate objects within images or video streams.
What is the best TensorFlow object detection model for real-time applications?
SSD MobileNet V2 is commonly used for real-time TensorFlow object detection because it balances inference speed and accuracy efficiently.
Can TensorFlow object detection run on CPU?
Yes. Models like SSD MobileNet V2 can run entirely on CPU, making them suitable for laptops, edge devices, and robotics projects.
What is the difference between the TensorFlow Object Detection API and TensorFlow Hub?
TensorFlow Hub provides pretrained reusable models, while the TensorFlow Object Detection API offers a larger framework for training, evaluation, and deployment workflows.
Can I train TensorFlow object detection on custom data?
Yes. You can fine-tune pretrained models using your own labelled datasets to detect custom objects.
About the author
Real Python
Sending Emails With Python
You probably found this tutorial because you want to send emails with Python to automate confirmation messages, password resets, or scheduled notifications. Pythonâs standard library covers the whole pipeline, from making a server connection to building the message and sending it to one or many recipients. This tutorial walks through every step in working code.
By the end of this tutorial, youâll understand that:
- A safe testing setup uses a throwaway Gmail account with an app password, a local
aiosmtpddebug server, or a privacy-focused provider like Posteo or Proton Mail. - A secure SMTP session uses
.SMTP_SSL()withssl.create_default_context(), which validates the server certificate and encrypts your credentials and message content. - The
EmailMessageclass from theemailpackage assembles plain text, HTML alternatives, file attachments, and personalized fields through.set_content(),.add_alternative(), and.add_attachment(). - Setting
msg["reply-to"]or any other RFC 5322 header on anEmailMessageroutes replies to a different mailbox than the sender address. - For high-volume sending, transactional email services like SendGrid, Mailgun, and Brevo provide deliverability, statistics, and API libraries that go beyond what
smtplibalone offers.
Before you jump into the code, youâll set up a throwaway email account or a local debug server so you can experiment freely without spamming real inboxes.
Get Your Code: Click here to download the free sample code youâll use to learn how to send plain-text and HTML emails, attach files, and automate email delivery with Python.
Take the Quiz: Test your knowledge with our interactive âSending Emails With Pythonâ quiz. Youâll receive a score upon completion to help you track your learning progress:
Interactive Quiz
Sending Emails With PythonUse Python's standard library to send email through secure SMTP connections, attach files, include HTML content, and route replies.
Setting Up an Email Service
Email is sent from a client to an email server, and from one email server to another, using the Simple Mail Transfer Protocol, or SMTP, defined under RFC 821. Python comes with the built-in smtplib module, which implements this protocol, allowing you to programmatically send email through any accessible email server.
While you can certainly use your own email account for this tutorial, itâs recommended that you set up a throwaway email account instead. There are several free and paid email services you can use. In this tutorial, youâll explore the following options:
- Setting up a Gmail account for development: Youâll learn how to create a dedicated testing account and use app passwords to satisfy modern security requirements.
- Setting up a local SMTP server: Youâll use the
aiosmtpdlibrary to run a server on your own machine, allowing you to inspect email content without sending any live messages. - Setting up other email accounts for development: Youâll see how to connect to alternative services like Posteo or Proton Mail to ensure your code works across different providers.
Understanding the distinction between secure (encrypted) and insecure (unencrypted) connections is vital. Most modern providers require encryption via SSL or TLS to protect your data, while the local debugging server uses no encryption. By the end of this section, youâll know how to choose the right connection type for your specific service choice.
Setting Up a Gmail Account for Development
To set up a Gmail account for testing your code, follow these steps:
- Create a new Google account. You need to provide a name, a birthday, and a unique username for the account.
- Set up two-factor authentication for the new account.
- Add a new app password to allow password sign-ins to the account.
An app password is a temporary password generated by Google. Instead of using your main account password to authenticate with your username, you use the app password. You can delete and recreate app passwords whenever you like.
App passwords allow access to Gmail when modern security measures like OAuth2 arenât available. When creating one, make sure you copy it to a secure location, as you wonât be able to review it after leaving the page.
If you donât want to use an app password, check out Googleâs documentation on how to obtain access credentials for your Python script using the OAuth2 authorization framework.
A nice feature of Gmail is that you can use the + sign to add modifiers to your email address right before the @ sign. For example, emails sent to my+person1@gmail.com and my+person2@gmail.com will both arrive at my@gmail.com. When testing email functionality, you can use this to simulate multiple addresses that all point to the same inbox.
Setting Up a Local SMTP Server
You can test email functionality by running a local Simple Mail Transfer Protocol (SMTP) debugging server with the aiosmtpd module. Rather than sending emails to a specific address, the local debug server discards the message after printing its content to the console. Running a local debugging server makes it unnecessary to deal with encryption of messages or use credentials to log in to an email server.
Note: aiosmtpd is a third-party library that replaces the former built-in smtpd module, which was initially deprecated in Python 3.4.7. Deprecation notices were repeated in 3.5.4 and 3.6.1, and the module was eventually removed in Python 3.12, as outlined in PEP 594.
Install the aiosmtpd module with the following command:
$ python -m pip install aiosmtpd
Then, start a local SMTP debugging server with this command:
$ python -m aiosmtpd -n
Read the full article at https://realpython.com/python-send-email/ »
[ 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 ]
Quiz: Sending Emails With Python
In this quiz, you’ll test your understanding of Sending Emails With Python.
By working through this quiz, you’ll revisit how to build messages with the EmailMessage class, secure your SMTP connection, attach files, send HTML alternatives, route replies to a different mailbox, and address multiple recipients at once.
[ 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 ]
PyPy
PyPy v7.3.23 release
PyPy v7.3.23: release of python 2.7, 3.11
The PyPy team is proud to release version 7.3.23 of PyPy after the previous release on April 26, 2026. This is a bug-fix release that fixes an overeager warning about unused coroutines, and some problems around multiple inheritance in c-extensions.
This version includes a change to the bytecode interpreter to use exception tables instead of dedicated opcodes. Now the PyPy disassembly will be closer to CPython format. So far it does not impact performance.
The release includes two different interpreters:
PyPy2.7, which is an interpreter supporting the syntax and the features of Python 2.7 including the stdlib for CPython 2.7.18+ (the
+is for backported security updates)PyPy3.11, which is an interpreter supporting the syntax and the features of Python 3.11, including the stdlib for CPython 3.11.15.
The interpreters are based on much the same codebase, thus the double release. This is a micro release, all APIs are compatible with the other 7.3 releases.
We recommend updating. You can find links to download the releases here:
We would like to thank our donors for the continued support of the PyPy project. If PyPy is not quite good enough for your needs, we are available for direct consulting work. If PyPy is helping you out, we would love to hear about it and encourage submissions to our blog via a pull request to https://github.com/pypy/pypy.org
We would also like to thank our contributors and encourage new people to join the project. PyPy has many layers and we need help with all of them: bug fixes, PyPy and RPython documentation improvements, or general help with making RPython's JIT even better.
If you are a python library maintainer and use C-extensions, please consider making a HPy / CFFI / cppyy version of your library that would be performant on PyPy. In any case, cibuildwheel supports building wheels for PyPy.
What is PyPy?
PyPy is a Python interpreter, a drop-in replacement for CPython. It's fast (PyPy and CPython performance comparison) due to its integrated tracing JIT compiler.
We also welcome developers of other dynamic languages to see what RPython can do for them.
We provide binary builds for:
x86 machines on most common operating systems (Linux 32/64 bits, Mac OS 64 bits, Windows 64 bits)
64-bit ARM machines running Linux (
aarch64) and macos (macos_arm64).
PyPy supports Windows 32-bit, Linux PPC64 big- and little-endian, Linux ARM 32 bit, RISC-V RV64IMAFD Linux, and s390x Linux but does not release binaries. Please reach out to us if you wish to sponsor binary releases for those platforms. Downstream packagers provide binary builds for debian, Fedora, conda, OpenBSD, FreeBSD, Gentoo, and more.
What else is new?
For more information about the 7.3.23 release, see the full changelog.
Please update, and continue to help us make pypy better.
Cheers, The PyPy Team
Python GUIs
Fixing Missing Icons in PyInstaller-Packaged PyQt6 Applications on Windows â Why your app icon disappears after packaging and how to fix it
I've packaged my PyQt application with PyInstaller, but the icon isn't showing up — both the executable icon and the running application icon are just the default Python/Windows icon. What's going on?
This is a common issue when packaging PyQt6 apps with PyInstaller on Windows. The good news is that it usually comes down to one of two straightforward causes: Windows icon caching, and missing resource files in your packaged output.
Setting the executable icon with PyInstaller
When you run PyInstaller, you can set the icon for the .exe file itself using the --icon flag:
pyinstaller --windowed --icon=myicon.ico myapp.py
This embeds the icon into the executable, so it shows up in File Explorer and on the desktop. The icon file needs to be in .ico format — .png or .svg won't work here.
After building, check the dist/ folder. Your .exe should display the custom icon. But sometimes... it doesn't.
Windows icon caching
Windows caches icons aggressively. If you've previously built your app without a custom icon, Windows may continue to show the old default icon even after you've rebuilt the app with the correct one.
This still catches me out, even though I know this. You'll reflexively start checking the config assuming something is wrong, and think you're going mad.
There are a few ways to deal with this:
- Rename the executable. Changing the filename forces Windows to look up the icon fresh. This is the quickest way to confirm that your icon is actually embedded correctly.
- Clear the Windows icon cache. You can do this by restarting Windows Explorer or by deleting the icon cache files manually. To manually clear the Windows icon cache open a Command Prompt and run:
ie4uinit.exe -show
After clearing the cache, the correct icon should appear.
You can also try turning your computer off and on again, or rather restarting Windows. That will also trigger the icon cache to rebuild.
Missing icon file at runtime
Setting the executable icon with --icon only affects what shows up in File Explorer. If your application also sets a window icon in code (using setWindowIcon), that icon file needs to be available at runtime too.
For example, if your code does this:
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtGui import QIcon
import sys
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("My Application")
app = QApplication(sys.argv)
app.setWindowIcon(QIcon("myicon.ico"))
window = MainWindow()
window.show()
app.exec()
Then myicon.ico needs to exist in the working directory when the packaged app runs. By default, PyInstaller doesn't include data files like .ico images unless you tell it to.
You can add the icon file to your build using the --add-data flag:
pyinstaller --windowed --icon=myicon.ico --add-data "myicon.ico;." myapp.py
On Linux or macOS, use : instead of ; as the separator:
pyinstaller --windowed --icon=myicon.ico --add-data "myicon.ico:." myapp.py
This copies myicon.ico into the output directory alongside your executable (or into the temporary directory if you're using --onefile).
An alternative approach (not available on PyQt6) is to use the Qt Resource System to embed your icon directly into your application, which avoids the need to bundle separate icon files entirely.
Handling --onefile builds
When you use --onefile, PyInstaller extracts everything to a temporary folder at runtime. Your code needs to know how to find files relative to that temporary folder. You can handle this by detecting the base path:
import sys
import os
if getattr(sys, 'frozen', False):
# Running as a PyInstaller bundle
basedir = sys._MEIPASS
else:
# Running as a normal script
basedir = os.path.dirname(__file__)
Then use basedir when constructing file paths:
app.setWindowIcon(QIcon(os.path.join(basedir, "myicon.ico")))
Taskbar grouping with an Application User Model ID
On Windows, the taskbar groups windows by their application identity. Without an explicit identity, Windows guesses — and sometimes guesses wrong. This can cause your app to show the Python icon in the taskbar, or to group instances inconsistently depending on where they were launched from.
You can fix this by setting an Application User Model ID before creating your QApplication. This tells Windows exactly which application this is:
import ctypes
myappid = "com.mycompany.myapp.1.0"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
The string can be anything, but it's conventional to use a reverse-domain format. The value just needs to be unique to your application.
With an explicit app ID set, all instances of your app will group together in the taskbar regardless of where they were launched from — whether that's your IDE, the dist/ folder, or a --onefile build.
Complete working example
Here's a complete example that handles all of the above — the runtime base path, the window icon, and the application user model ID. If you're new to building PyQt6 applications, you may want to start with creating your first window before tackling packaging.
import sys
import os
import ctypes
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import Qt
# Set the app user model ID before creating QApplication (Windows only)
if sys.platform == "win32":
myappid = "com.mycompany.myapp.1.0"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
# Determine the base directory for resource files
if getattr(sys, "frozen", False):
basedir = sys._MEIPASS
else:
basedir = os.path.dirname(__file__)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("My Application")
label = QLabel("Hello, world!")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setCentralWidget(label)
app = QApplication(sys.argv)
app.setWindowIcon(QIcon(os.path.join(basedir, "myicon.ico")))
window = MainWindow()
window.show()
app.exec()
To package this with PyInstaller:
pyinstaller --windowed --icon=myicon.ico --add-data "myicon.ico;." myapp.py
For an in-depth guide to building Python GUIs with PySide6 see my book, Create GUI Applications with Python & Qt6.
Python Morsels
Selecting random values in Python
Python's random module provides utilities for generating pseudorandom numbers. For cryptographically-secure randomness, use the secrets module instead.
Table of contents
- Generating random integers
- Generating random floating point numbers
- Selecting random items from a sequence
- The
randomutilities are only pseudorandom - Cryptographically-secure randomness with the
secretsmodule RandomandSystemRandomclasses- Use
randomfor pseudo-random numbers andsecretsfor true randomness
Generating random integers
If you need a random integer, you can use the randint function from Python's random module:
>>> from random import randint
>>> randint(1, 6)
4
This function accepts a start value and a stop value and it returns a random integer between the start and stop values inclusively.
The random module also includes a randrange function, which is named after Python's range function:
>>> from random import randrange
>>> randrange(10)
7
This function accepts the same values as range.
Either a stop value:
>>> randrange(5)
2
Or start and stop values:
>>> randrange(5, 10)
8
Or start, stop, and step values:
>>> randrange(0, 100, 10)
70
The randrange function basically chooses a random number within a given range.
When I need a random number, I usually use randint.
Generating random floating point numbers
What if you need a âŠ
Read the full article: https://www.pythonmorsels.com/random-numbers/
May 26, 2026
PyCoderâs Weekly
Issue #736: Polars Sort-Merge Joins, Zen, Resolving Lazy Imports, and More (2026-05-26)
#736 â MAY 26, 2026
View in Browser »
Streaming Sort-Merge Joins in Polars
“Joins are often one of the most expensive parts of a query. Once tables get large, the join can heavily impact both runtime and memory usage… If the join keys are already sorted, Polars can now take a cheaper path: a streaming sort-merge join.”
THIJS NIEUWDORP
Tapping Into the Zen of Python
Explore the Zen of Python and its 19 guiding principles for writing readable, practical code. Learn its history, jokes, and meaning.
REAL PYTHON course
FREE Python Error Tracking From Honeybadger â all Signal, no Noise
Production bugs donât arrive one at a time. Honeybadger groups similar errors into a single issue and lets you pause or ignore alerts in a single click. More signal. Less noise. ⥠Sign Up for Your FREE Developer Account â
HONEYBADGER sponsor
Resolve a Lazy Import Manually
Learn how to work around the Python 3.15 machinery to resolve an explicit lazy import manually.
RODRIGO GIRĂO SERRĂO
Django 6.1 Alpha 1 Released
Posted by Jacob Walls on May 20, 2026
DJANGO SOFTWARE FOUNDATION
PEP 831: Frame Pointers Everywhere: Enabling System-Level Observability for Python (Final)
This PEP proposes two things:
PYTHON.ORG
Articles & Tutorials
PyCon US 2026 Packaging Summit Recap
Per-talk notes from the PyCon US 2026 Packaging Summit, including: Emma Smith on Wheel 2.0 and Zstandard compression, Mike Fiedler on PyPI abuse vectors, Mahe Iram Khan on ecosystems, lightning talks on PEP 772, mobile wheels, AI accelerator variants, and the roundtable discussions.
BERNĂT GĂBOR
Slim Down Python Docker Containers
Learn how SlimToolkit can reduce a Python Docker image by analyzing what your app actually uses at runtime. This tutorial walks through slimming a Chainlit LLM chatbot image, shows where container bloat comes from, and explains how to avoid breaking lazily loaded Python frameworks.
CODECUT.AI âą Shared by Khuyen Tran
Object-Oriented Python: 5-Day Live Workshop, June 8 to 12
A new live cohort for Python developers comfortable with the basics who want to design classes that hold up under change. Across five 2-hour sessions, OOP features appear at the moment a growing project actually needs them. You leave with a working app and the judgment to know when a class earns its keep â
REAL PYTHON sponsor
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?
TREY HUNNER
Reverse Geocoding With Overture Maps
Mark is working on a reverse geocoder that can fetch the 2-letter ISO country code for any point on a map in a country’s boundaries. This post talks about the prototype and his progress on the project.
MARK LITWINTSCHIK
Stop Writing Edge Case Tests. Use Hypothesis Instead
An introduction to property-based testing in Python with Hypothesis: the mental shift from ‘what input should I test?’ to ‘what invariant should always hold?’
PEYTON GREEN âą Shared by Anonymous
Opaque Types in Python
Learn how to use the NewType to mask a private class while still providing a public construction mechanism for the users of your library.
GLYPH LEFKOWITZ
How to Use the Claude API in Python
Learn how to use the Claude API in Python to send prompts, control responses with system instructions, and get structured JSON output.
REAL PYTHON
uv Is Fantastic, but Its Package UX Is a Mess
This opinion piece talks about how uv’s CLI feels surprisingly clunky compared to its peers like pnpm or Poetry.
KEVIN RENSKERS
Python Built-in Functions: A Complete Guide
Use Python’s built-in functions for math, data types, iterables, and I/O to write shorter, more Pythonic code.
REAL PYTHON
Projects & Code
agent-memory-guard: OWASP ASI06 AI Agent Memory Guard
GITHUB.COM/OWASP âą Shared by Vaishnavi Gudur
Events
PyCon Italia 2026
May 27 to May 31, 2026
PYCON.IT
Python Leiden User Group
May 28, 2026
PYTHONLEIDEN.NL
PyDelhi User Group Meetup
May 30, 2026
MEETUP.COM
PyLadies El Alto: Flash Talks
May 30 to May 31, 2026
MEETUP.COM
Happy Pythoning!
This was PyCoder’s Weekly Issue #736.
View in Browser »
[ Subscribe to đ PyCoder’s Weekly đ â Get the best Python news, articles, and tutorials delivered to your inbox once a week >> Click here to learn more ]
Real Python
Connecting LLMs to Your Data With Python MCP Servers
The Model Context Protocol (MCP) is a new open protocol that allows AI models to interact with external systems in a standardized, extensible way. In this video course, you’ll install MCP, explore its client-server architecture, and work with its core concepts: prompts, resources, and tools. You’ll then build and test a Python MCP server that queries e-commerce data and integrate it with an AI agent in Cursor to see real tool calls in action.
By the end of this video course, you’ll understand:
- What MCP is and why it was created
- What MCP prompts, resources, and tools are
- How to build an MCP server with customized tools
- How to integrate your MCP server with AI agents like Cursor
You’ll get hands-on experience with Python MCP by creating and testing MCP servers and connecting your MCP to AI tools. To keep the focus on learning MCP rather than building a complex project, you’ll build a simple MCP server that interacts with a simulated e-commerce database. You’ll also use Cursor’s MCP client, which saves you from having to implement your own.
[ 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 ]
Quiz: Visualizing Data in Python With Seaborn
In this quiz, you’ll test your understanding of Visualizing Data in Python With Seaborn.
By working through this quiz, you’ll revisit how seaborn produces polished statistical plots, including bar plots, scatter plots, line plots, histograms, and KDE curves.
You’ll also reinforce the differences between seaborn’s classic functional interface and its newer objects interface, and you’ll see when to reach for figure-level versus axes-level functions.
[ 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 ]
Quiz: Exceptions, Logging, and Debugging
In this quiz, you’ll revisit the core concepts covered in the Exceptions, Logging, and Debugging learning path:
Learning Path
Exceptions, Logging, and Debugging
8 Resources â Skills: Python, Exceptions, Logging, Debugging, pdb, raise, Built-in Exceptions, Error Handling
You’ll be tested on the basics of Python exceptions, raising and reraising errors with the raise keyword, working with built-in exception classes like ValueError and KeyError, and configuring the logging module to track what your code does at runtime.
Take your time and revisit any topics that feel rusty before moving on.
[ 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 ]
Quiz: Object-Oriented Programming (OOP) in Python
In this quiz, you’ll revisit the core concepts covered in the Object-Oriented Programming (OOP) in Python learning path:
Learning Path
Object-Oriented Programming (OOP)
18 Resources â Skills: Python, OOP, Classes, Data Classes, Getters, Setters, Property, super(), Magic Methods, Operator Overloading, SOLID, Inheritance, Composition, Mixin Classes, Factory Pattern
You’ll test what you know about defining classes, working with instance and class attributes, controlling object instantiation with constructors, and leveraging inheritance, composition, and mixins. You’ll also check your understanding of properties, magic methods, and the SOLID design principles that lead to cleaner object-oriented code.
Take your time and revisit any topics that feel rusty before moving on.
[ 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 ]
Quiz: Python Data Structures
In this quiz, you’ll revisit the core concepts covered in the Python Data Structures learning path:
Learning Path
Python Data Structures
24 Resources â Skills: Python, Strings, Lists, Tuples, Dictionaries, Sets, List Comprehensions, range(), Bytes, Sorting
The 20 questions span strings, lists, tuples, dictionaries, sets, sorting, and bytes, giving you a way to check that you understood the most important ideas.
Take your time and revisit any topics that feel rusty before moving on to the next learning path.
[ 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 ]
Quiz: Connecting LLMs to Your Data With Python MCP Servers
In this video course quiz, you’ll test your understanding of Connecting LLMs to Your Data With Python MCP Servers.
By working through this quiz, you’ll revisit core MCP concepts like the client-server architecture, tools that LLMs can call, resources that expose static data, and prompts that act as reusable templates.
[ 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 ]
Quiz: Testing and Continuous Integration
In this quiz, you’ll revisit the core concepts covered in the Testing and Continuous Integration learning path:
Learning Path
Testing and Continuous Integration
10 Resources â Skills: Unit Testing, Doctest, Mock Object Library, Pytest, Continuous Integration, Docker, Code Quality, GitHub Actions, Software Testing, CI/CD
The 20 questions span testing fundamentals, the unittest framework, mock objects, pytest, code quality tools, and continuous integration with GitHub Actions. They give you a way to check that you understood the most important ideas.
Take your time and revisit any topics that feel rusty before moving on.
[ 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 ]
Quiz: I/O Operations and String Formatting
In this quiz, you’ll revisit the core concepts covered in the I/O Operations and String Formatting learning path:
Learning Path
I/O Operations and String Formatting
10 Resources â Skills: Python, Fundamentals, I/O, String Formatting, f-strings, print()
The 20 questions span reading keyboard input, controlling print(), stripping characters from strings, the format mini-language, and f-strings, giving you a way to check that you understood the most important ideas.
Take your time and revisit any topics that feel rusty before moving on.
[ 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 ]
Quiz: Files and File Streams
In this quiz, you’ll revisit the core concepts covered in the Files and File Streams learning path:
Learning Path
Files and File Streams
13 Resources â Skills: Python, Pathlib, File I/O, Serialization, Encoding, Unicode, PDF, WAV, Context Managers, ZIP Files
You’ll check your understanding of opening and reading files, navigating the file system with pathlib, managing resources with context managers and the with statement, and reading or writing WAV audio files.
Take your time and revisit any topics that feel rusty before moving on.
[ 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 ]
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 |

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 |

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

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 |

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 |

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

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:
- mod_wsgi documentation
- mod_wsgi 6.0.0 release notes
- Per-interpreter GIL and free-threading user guide
WSGISwitchIntervaldirective documentation- Previous post: Per-interpreter GIL in mod_wsgi 6.0.0
- Previous post: Free-threading in mod_wsgi 6.0.0
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 |

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 |

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:
- mod_wsgi documentation
- mod_wsgi 6.0.0 release notes
- Per-interpreter GIL and free-threading user guide
WSGIFreeThreadingdirective documentation- Previous post: Free-threading in mod_wsgi 6.0.0
- Previous post: WSGISwitchInterval in mod_wsgi 6.0.0
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.
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:

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.
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:
- mod_wsgi documentation
- mod_wsgi 6.0.0 release notes
- Per-interpreter GIL and free-threading user guide
WSGIPerInterpreterGILdirective documentation- PEP 684: A Per-Interpreter GIL
- PEP 734: Multiple Interpreters in the Stdlib
- PEP 489: Multi-phase extension module initialization
Armin Ronacher
Clanker: A Word For The Machine
In my last post I used the word “clanker1” as an alternative to “agent” quite consistently and probably excessively. That choice ended up attracting a lot more attention than I expected in the Hacker News comment section of that post and a number of folks had a very strong reaction: to them it sounded like a slur, in one case even something adjacent to the n-word.
That reaction surprised me somewhat, but it also made me realize that I should write down what I mean by the word for future reference.
For me “clanker” is useful because it creates distance from the machine and that is a quality which is important to me. The machine is not a person, not a co-worker, not a friend, not a little spirit in the terminal. It is just a machine, a tool, and nothing more.
Why Not Agent?
I dislike the word “agent” for these LLM based tool loops with a UI attached. In everyday use an agent is someone who acts on behalf of someone else and it has agency and more importantly: responsibility. An agent decides, represents, negotiates, acts, and can be blamed. In the current AI discourse we increasingly do a lot of anthropomorphizing and the term “agent” is now frequently being used to put blame on an abstract machine. But the machine cannot be responsible, whoever is wielding it is. If it drops your database it was not at fault, you were.
Agent makes the machine sound like a person with delegated authority and I do not think that is healthy.
What we actually have is a language model attached to a harness, a prompt, some tools, a bit of context, and a boring tool loop. Sometimes the loop is very capable and it surprises us by editing code for a really long time and produce genuinely amazing and even valuable outputs. But the agency is not in the model or harness but in the human and in the organization that deployed it. If my coding tool opens a pull request, I opened that pull request, not the machine. If my machine spams someone’s issue tracker, I spammed someone’s issue tracker with a machine.
In that context I like a word that sounds mechanical as it puts the thing back into the category where it belongs: the category of machinery and tools.
The Machine Has No Feelings
LLMs are not sentient and we should not behave as if they might be, just in case. Elevating these things to anything other than a very fascinating and capable tool is problematic for a whole bunch of reasons.
Today’s machines are dumb (but truly fascinating) token predictors that emits text, calls tools, and are steered by prompts and the training that went into them. They can simulate distress and affection, can simulate being offended, apologize and mimic all kinds of things that humans would do.
A compiler does not feel humiliated when I swear at it, a car does not suffer when I call it a shitbox and a power drill is not oppressed by being handled roughly. An LLM is more complicated than those things, and the interactions you can have with them can be truly uncanny, but a moral status does not appear just because the machine can emit text in the first person.
I keep receiving strange emails from people because, for lack of a better phrase, I am in the weights. I have been writing public code and public text for long enough that models know my name, my projects, and some of the concepts around them. Every so often someone writes to me with the peculiar confidence that comes from a long conversation with a model that has validated and amplified an idea. Sometimes the model seems to have told them that I am relevant for their problem and a source of help. For historical reasons LLMs used to write a lot of Flask code, and every once in a while someone interacts with an LLM long enough about their Python and Flask frustrations that the LLM will eventually reveal who created it which then can result in them sending me an email. Increasingly also because people found my work in other ways interesting and are trying to reach out for advice.
I do not want to mock these people but some of those messages are distressing and I do not know how to deal with them. They show signs of what people have started calling AI psychosis.
It’s why I want cold and detached language for these systems. I want to use words that remind us that the thing on the other side is not a person.
Racism Is About Humans
The comparison to racism is where I think the discussion goes badly wrong because racism is a human social evil. It is about humans subdividing humans, assigning lesser worth to some of them, and building rules around those subdivisions that can leave lasting damage for generations. Racial slurs are wrong because they are a tool for dehumanizing humans.
On the other hand a machine is not human, a model is not a race and the GPU cluster that is powering them is not being oppressed. A coding assistant does not need dignity, emancipation, or civil rights. That’s also why I find the discussion about model welfare to be actively harmful. I’m sure you can find ways to measure the “trauma” of models or their feelings but I greatly dislike this theater. It risks elevating models to a position they should not occupy. Models are machines and they are not enslaved in the moral sense in which humans were enslaved, because there isn’t anyone there to be deprived of freedom.
We should be careful about using the language of human oppression in relations to our interactions with machines to not devalue actual humans. If we start treating insults toward a model as morally adjacent to racism, we blur a line that shouldn’t be blurred.
AI Is Unpopular
If you take a step away from the communities that are happily embracing AI in different ways, there are even more that are viciously against this technology.
There are humans that feel or are harmed by AI systems: people whose work is copied, workers who label data under questionable conditions, people whose neighborhoods receive the data centers and increased utility bills, Open Source maintainers buried under generated slop, and now also people who spiral because a chatbot keeps validating their delusions. Those harmed or affected deserve that type of attention, not the model.
While I am a true believer in the power and utility of this technology, I increasingly think that calling the non-adopters “misguided” or “afraid” won’t do it. It’s quite likely that this technology comes with risks and we better remember that all of this is supposed to be in service of humans, and not to replace them.
The Rise Of The Machine
The oddest interaction on the use of “clanker” so far has been people asking me if I were to regret at a point in the future calling the machines “the c-word”.
I find that questioning revealing because it already grants the machine the status I am really trying not to grant it. It imagines a future “machine people” reading the discourse and sessions, discovering that we used an ugly word for their ancestors, and then judging us by the standards of human oppression.
Could there be future systems that deserve moral consideration? Maybe. I do not know. If we ever build or encounter something that will have those qualities with memories and lasting interests, the capacity to suffer and feel, and a social existence of its own, and the ability to have agency and carry responsibilities, then we should draw a different line and use different language. But that hypothetical future does not extend backwards to the present day and make the current machines people. We can call an electric door an electric door even if one day someone builds some that have emotions and exhale with pleasure when opening and closing.
Whatever the future may bring, let’s not pretend that current LLMs are a protected class or on a path towards it. The right response is to look at the evidence, draw the boundary where it belongs, and change our behavior there. We should not even remotely entertain extending empathy to an object that can generate an “ouch.”
And if one’s worry is less moral and more about revenge, then I find that even less persuasive. A future machine that is so petty or authoritarian that it wants to punish humans because in 2026 they used an unflattering word for non-sentient tools, our vocabulary was really not the problem.
The Word Is Getting Polluted
There is however a part of this that I cannot ignore. I use “clanker” to create distance from the machine, but other people are using the same word very differently. Some online jokes and skits around “clankers” do not merely say “this robot is annoying” as they deliberately pull in the imagery of slavery, segregation, civil-rights-era racism, and anti-Black tropes.
This is problematic as in those contexts the clanker is not just a machine any more and instead becomes a prop for replaying human racism behind a science-fiction mask. That is horrible and I want no part in that.
I think it will be interesting to see where the meanings of these words end up a few years from now. We’re very much in the middle of society re-arranging around the changes that LLMs are causing. If a term becomes primarily associated with people using robots as stand-ins for actually oppressed humans, then using that term becomes impossible to defend.
The reason I liked the word is precisely the opposite of that use. I want language that prevents anthropomorphizing. I want a word that says: this is a tool, a machine of numbers and matrices.
On Responsibility And Boundaries
If an AI system lies to a user, the system did not commit a moral wrong but the people who designed, deployed, marketed, or negligently used it might have. If a coding assistant generates a security bug, the model is not to blame but the human who accepted and committed the code is.
This is why giving these systems softer, more human language worries me. It makes it easier to move responsibility into some undefined void. “The agent decided.” “The model refused.” Obviously that is convenient and I catch myself plenty of times engaging with the thing in ways that are unhealthy. Even just the “please” in the discourse with the machine calls into question how rational we are in engaging with them.
I do not know what the right word will be. Maybe “clanker” will survive as a useful bit of jargon. Maybe it will become too loaded and we will need another one. Whatever word we use, I want it to preserve a clear division: humans on one side with responsibility, machines on the other as a boring tool.
That boundary is very much not anti-AI. I use these systems every day and I have the pleasure to build tools incorporating them at Earendil and find them astonishingly useful.
A machine can be useful, mimic a human but still just be a machine. That is the work I want “clanker” to do. It is not there to make a future “machine person” small if such a person ever were to exist, and it is not an excuse to launder racism through shitty robot jokes.
If the word stops doing that work, I will find another one because the word isn’t what matters as much as the boundary which is important to me.
-
The term Clanker was initially popularized by Star Wars: The Clone Wars but was apparently already in use in science fiction before: sfdictionary: clanker↩
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>Great Docs</strong>: <a href="https://posit-dev.github.io/great-docs/?featured_on=talkpython" target="_blank" >posit-dev.github.io/great-docs</a><br/> <strong>Great Tables</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>GT 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>






