GIL Modes and Free-Threading

This guide covers the three Python GIL configurations mod_wsgi can drive: the classic process-wide shared GIL, PEP 684 per-interpreter GIL (Python 3.12+), and PEP 703 free-threading (Python 3.13+). It describes what mod_wsgi exposes for each, how the modes interact, how to mix them across embedded mode and daemon process groups in one Apache server, and how to wire them up under mod_wsgi-express (which does not have first-class command line options for any of the relevant directives).

For directive-level reference see WSGIFreeThreading, WSGIPerInterpreterGIL, and WSGIInterpreterOptions. The Processes And Threading and Embedded and Daemon Mode guides cover the underlying process and sub interpreter model that this page builds on. Read those first if “MPM” or “sub interpreter” is unfamiliar.

The three GIL modes

mod_wsgi runs Python interpreters inside Apache child processes (embedded mode) and inside daemon processes (daemon mode). Each such process loads CPython into its address space and creates one main interpreter plus one or more sub interpreters for the WSGI applications it hosts. The GIL configuration is a property of the process, not of the request, the application, or the Apache vhost.

Shared GIL (default)

Every Python interpreter in the process, the main interpreter and every sub interpreter, shares a single process-wide GIL. Only one Python frame runs at a time per process. Concurrency across requests comes from running multiple processes (Apache child processes or WSGIDaemonProcess daemon processes), each with its own independent GIL.

This is the historical CPython model and the default mod_wsgi configuration on every supported Python version.

Per-interpreter GIL (PEP 684, Python 3.12+)

The process keeps its process-wide GIL for the main interpreter, and sub interpreters can be configured individually to run with their own independent GIL. Sub interpreters with their own GIL can run Python frames in parallel within a single process, on different OS threads.

Enabled with WSGIPerInterpreterGIL. The setting can be made process-wide or scoped to specific sub interpreters via WSGIInterpreterOptions containers with process-group= and / or application-group= selectors. The main interpreter always uses the process-wide GIL; a setting that resolves to the main interpreter is silently ignored for that interpreter.

A C extension imported by a sub interpreter that has its own GIL must declare PEP 489 multi-interpreter support via Py_mod_multiple_interpreters set to Py_MOD_PER_INTERPRETER_GIL_SUPPORTED. Extensions that do not, or that explicitly declare Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED, fail to import in such a sub interpreter.

Free-threading (PEP 703, Python 3.13+)

The entire process runs with the GIL disabled. Every interpreter, main and sub, executes without any GIL. Every OS thread in the process can execute Python code in parallel, up to the number of available cores.

Free-threading requires a Python build configured with --disable-gil (commonly distributed as python3.13t, python3.14t, and so on). It is a process-wide setting fixed at Py_InitializeFromConfig time, so unlike per-interpreter GIL it cannot be scoped per sub interpreter.

Enabled with WSGIFreeThreading. The setting can be applied at server config scope (every mod_wsgi-managed process) or to individual processes via <WSGIInterpreterOptions process-group=...>. Scoping on application-group= is not valid: a process either runs free-threaded or it does not.

A C extension imported into a free-threaded interpreter should declare Py_mod_gil = Py_MOD_GIL_NOT_USED in its multi-phase init slots. Extensions without the declaration are still imported, but CPython logs a runtime warning per extension to flag that they have not been audited for the no-GIL runtime.

What mod_wsgi provides

Three directives plus one container:

WSGIFreeThreading

Process-wide opt-in to free-threading. mod_wsgi forces the GIL on by default even on free-threaded Python builds; this directive disables it for the matched process.

WSGIPerInterpreterGIL

Per-sub-interpreter opt-in to per-interpreter GIL. Sub interpreters within the directive’s scope are created with their own GIL.

WSGIInterpreterOptions

Container that scopes both of the above (and a few related per-interpreter directives) to a subset of interpreters by process-group= and / or application-group=. Top-level settings serve as defaults; container settings override per match, with the most-specific match winning.

WSGISwitchInterval

Calls sys.setswitchinterval() to tune how often the GIL is yielded. Has no effect when free-threading is active in the process. Can be placed inside <WSGIInterpreterOptions> to vary the value per sub interpreter, but only for sub interpreters that also have their own GIL (under the shared GIL the switch interval is a process-global value).

Selectors

The two selectors map onto mod_wsgi’s existing process and application group concepts:

process-group=NAME

Matches the embedded interpreter (when set to the empty string or %{GLOBAL}) or a specific named WSGIDaemonProcess group.

application-group=NAME

Matches a resolved application group name. %{ENV:VAR} expansion is supported; the match is re-evaluated per request in that case.

Omitting a selector matches every value of that dimension. The empty container <WSGIInterpreterOptions> matches every interpreter.

Precedence and constraints

Free-threading wins. If both directives resolve to the same process and WSGIFreeThreading is on, WSGIPerInterpreterGIL is a no-op (there is no GIL to allocate per interpreter); a warning is logged at sub interpreter creation time (see WSGI0202 — Per-interpreter GIL skipped because free-threading is active).

WSGIFreeThreading is not valid in a container that sets application-group=. The directive in such a container is ignored with a warning at config load (see WSGI0201 — WSGIFreeThreading inside container with application-group= is ignored).

WSGIPerInterpreterGIL resolving to the main interpreter (via application-group=%{GLOBAL}) is silently ignored: the main interpreter cannot be given its own GIL.

WSGIPerInterpreterGIL On on a Python older than 3.12, or WSGIFreeThreading On on a Python build not configured with --disable-gil, are accepted at configuration parse time but log a warning and have no effect (see WSGI0198 — WSGIPerInterpreterGIL requires Python 3.12 or later and WSGI0200 — WSGIFreeThreading On has no effect on this Python build).

Mixing modes across processes

Because the GIL configuration is per-process, different Apache child and daemon processes in the same server can each run in a different mode. The process-group= selector is the mechanism for expressing that.

The examples below assume mod_wsgi has been built against a free-threaded Python build (for instance python3.14t). On a non-free-threaded build the WSGIFreeThreading directive is still accepted but has no effect.

Single mode, everywhere

Classic shared GIL in every process, the historical default:

LoadModule wsgi_module modules/mod_wsgi.so
WSGIDaemonProcess myapp processes=4 threads=15
WSGIScriptAlias / /srv/myapp/wsgi.py \
    process-group=myapp

Same shape with free-threading active in every mod_wsgi-managed process (embedded interpreter and every daemon group):

LoadModule wsgi_module modules/mod_wsgi.so
WSGIFreeThreading On
WSGIDaemonProcess myapp processes=4 threads=15
WSGIScriptAlias / /srv/myapp/wsgi.py \
    process-group=myapp

Same shape with per-interpreter GIL active for every sub interpreter mod_wsgi creates:

LoadModule wsgi_module modules/mod_wsgi.so
WSGIPerInterpreterGIL On
WSGIDaemonProcess myapp processes=4 threads=15
WSGIScriptAlias / /srv/myapp/wsgi.py \
    process-group=myapp

Free-threading for one daemon group only

A single daemon group runs free-threaded; the embedded interpreter and any other daemon groups stay on the shared GIL:

LoadModule wsgi_module modules/mod_wsgi.so

WSGIDaemonProcess freethreaded processes=1 threads=30
WSGIDaemonProcess legacy       processes=4 threads=15

<WSGIInterpreterOptions process-group=freethreaded>
    WSGIFreeThreading On
</WSGIInterpreterOptions>

WSGIScriptAlias /modern /srv/modern/wsgi.py \
    process-group=freethreaded
WSGIScriptAlias /legacy /srv/legacy/wsgi.py \
    process-group=legacy

Useful while a free-threading-safe new application is deployed alongside a classic codebase whose C extensions have not been audited for the no-GIL runtime.

Free-threading for embedded mode only

The process-group=%{GLOBAL} selector targets the embedded interpreter:

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

Mainly useful for auth and dispatch scripts that benefit from intra-process parallelism. The WSGI application itself, if served from a daemon group, is unaffected.

Per-interpreter GIL for one daemon group

A single daemon process runs multiple sub interpreters, each holding its own GIL, while a second daemon group runs unchanged on the shared GIL:

LoadModule wsgi_module modules/mod_wsgi.so

WSGIDaemonProcess parallel processes=1 threads=15
WSGIDaemonProcess simple   processes=4 threads=15

<WSGIInterpreterOptions process-group=parallel>
    WSGIPerInterpreterGIL On
</WSGIInterpreterOptions>

WSGIDispatchScript /etc/apache2/wsgi/dispatch.py
WSGIScriptAlias /api /srv/api/wsgi.py \
    process-group=parallel \
    application-group=%{ENV:APPLICATION_GROUP}
WSGIScriptAlias /www /srv/www/wsgi.py \
    process-group=simple

For the dispatch-script side of the recipe (routing requests across a fixed set of named sub interpreters) see the worked example in WSGIPerInterpreterGIL.

Mixed: free-threaded and per-interpreter GIL groups

The two modes can coexist in the same server, in different daemon groups. The directive resolver picks the right configuration per process:

LoadModule wsgi_module modules/mod_wsgi.so

WSGIDaemonProcess freethreaded processes=1 threads=30
WSGIDaemonProcess parallel     processes=1 threads=15
WSGIDaemonProcess simple       processes=2 threads=15

<WSGIInterpreterOptions process-group=freethreaded>
    WSGIFreeThreading On
</WSGIInterpreterOptions>

<WSGIInterpreterOptions process-group=parallel>
    WSGIPerInterpreterGIL On
</WSGIInterpreterOptions>

WSGIScriptAlias /modern /srv/modern/wsgi.py \
    process-group=freethreaded
WSGIScriptAlias /api    /srv/api/wsgi.py \
    process-group=parallel \
    application-group=%{ENV:APPLICATION_GROUP}
WSGIScriptAlias /www    /srv/www/wsgi.py \
    process-group=simple

A separate daemon process group for each mode is the natural unit of separation because the GIL setting is per-process. Combining free-threading and per-interpreter GIL in the same process is not a supported configuration; see the precedence rules above.

Per-interpreter GIL for one application group only

Inside a single daemon process, only one application group runs with its own GIL; other application groups in the same process keep the shared GIL:

WSGIDaemonProcess mixed processes=1 threads=15

<WSGIInterpreterOptions process-group=mixed
                        application-group=cpu_app>
    WSGIPerInterpreterGIL On
</WSGIInterpreterOptions>

WSGIScriptAlias /cpu /srv/cpu/wsgi.py \
    process-group=mixed \
    application-group=cpu_app
WSGIScriptAlias /io  /srv/io/wsgi.py \
    process-group=mixed \
    application-group=io_app

The same approach works with a dispatch script: application-group=%{ENV:APPLICATION_GROUP} resolves per request, and the container match is evaluated then.

Per-interpreter switch interval

When a sub interpreter has its own GIL, its switch interval can be tuned independently of the rest of the process:

<WSGIInterpreterOptions process-group=parallel
                        application-group=cpu_app>
    WSGIPerInterpreterGIL On
    WSGISwitchInterval 0.002
</WSGIInterpreterOptions>

Setting WSGISwitchInterval inside an application-group= container without per-interpreter GIL on the same match is rejected: under the shared GIL the switch interval is a process-global value, so a per-application setting would silently affect every interpreter in the process. See WSGIInterpreterOptions for the validation rules.

C extension compatibility

The two opt-in modes have different compatibility requirements, with different failure modes when an extension does not meet them. The shared-GIL default also has a long-standing sub-interpreter constraint that is covered separately and summarised first.

Shared GIL: sub-interpreter constraint

C extensions that use the simplified PyGILState_Ensure / PyGILState_Release API assume a single interpreter per process and do not work correctly outside the main interpreter. NumPy, SciPy and modules built on top of them are the prominent examples. WSGI applications that import such extensions have to run in the main interpreter, which under mod_wsgi means setting WSGIApplicationGroup %{GLOBAL} (or, in daemon mode, relying on the daemon process’s own main interpreter). The trade-offs and failure modes are described under “WSGIApplicationGroup and C extension modules” in Configuration Issues and “Multiple Python Sub Interpreters” in Application Issues. This constraint applies under the shared GIL irrespective of the two opt-in modes below.

Per-interpreter GIL: hard import failure

A sub interpreter with its own GIL refuses to import a C extension that has not declared PEP 489 multi-interpreter support. The error surfaces at import time, before traffic flows. Typical declaration in a C extension’s multi-phase init table:

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

An extension that declares Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED, or omits the slot entirely, fails to import inside any sub interpreter with its own GIL. The application group then fails to load and requests routed to it return 500.

Free-threading: runtime warning, then load anyway

A free-threaded interpreter is more permissive at import: an undeclared extension still loads, but CPython logs a warning per extension. The application runs, but its correctness under concurrent access has not been audited. Typical declaration:

static PyModuleDef_Slot module_slots[] = {
    {Py_mod_exec, exec_module},
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
    {0, NULL}
};

mod_wsgi sets PyConfig.enable_gil explicitly to disable the GIL when WSGIFreeThreading is on, so the import permissiveness comes from CPython’s _PyConfig_GIL_DISABLE policy rather than from mod_wsgi-side filtering.

Auditing checklist:

  • Consider every C extension transitively imported, not just top-level dependencies. Extensions pulled in by SDKs (database drivers, serialisers, monitoring agents) are easy to miss.

  • Pure Python modules are unaffected by either mode.

  • Native code linked into Python via ctypes or cffi is not subject to the declaration check; its thread-safety has to meet the mode’s requirements independently.

Using these directives under mod_wsgi-express

mod_wsgi-express has a first-class --free-threading flag for WSGIFreeThreading. For WSGIPerInterpreterGIL and WSGIInterpreterOptions the supported path is --include-file, which appends a file of Apache directives at the end of the generated configuration. --include-file is repeatable, so several fragments can be combined, and it remains the way to apply WSGIFreeThreading when it needs to be scoped to a specific process group rather than the whole instance.

Free-threading

The simplest form uses the dedicated flag:

mod_wsgi-express start-server wsgi.py \
    --processes 1 --threads 30 \
    --free-threading

mod_wsgi-express always creates one daemon process group, so --free-threading emits a top-level WSGIFreeThreading On that applies to both that daemon group and the embedded interpreter the Apache child uses for any auth or dispatch scripts.

If the running Python interpreter is not a free-threaded build (Py_GIL_DISABLED not set), mod_wsgi-express fails at configuration time with a message naming the requirement, rather than silently generating a directive that the resulting Apache process will warn about and ignore.

For finer-grained scoping (free-threading for one named daemon group only, or for the embedded interpreter only) use an include file with an explicit <WSGIInterpreterOptions> container; see Scoped configuration via WSGIInterpreterOptions below.

Per-interpreter GIL

Same shape:

# /tmp/per-interp-gil.conf
WSGIPerInterpreterGIL On

with:

mod_wsgi-express start-server wsgi.py \
    --processes 1 --threads 15 \
    --application-group %{ENV:APPLICATION_GROUP} \
    --include-file /tmp/per-interp-gil.conf

The --application-group %{ENV:APPLICATION_GROUP} option pairs with a WSGIDispatchScript to route each request to a chosen sub interpreter. Both pieces can live in the same include file:

# /tmp/dispatch.conf
WSGIPerInterpreterGIL On
WSGIDispatchScript /tmp/dispatch.py

then:

mod_wsgi-express start-server wsgi.py \
    --processes 1 --threads 15 \
    --application-group %{ENV:APPLICATION_GROUP} \
    --include-file /tmp/dispatch.conf

The WSGIPerInterpreterGIL page has the full dispatch-script example.

Scoped configuration via WSGIInterpreterOptions

When the configuration calls for a <WSGIInterpreterOptions> container (for example to opt only the daemon group into free-threading while the embedded interpreter stays on the shared GIL), write the container into the include file directly and target the daemon group by name. Use Express’s --process-group NAME option to give the daemon group a stable name to refer to:

# /tmp/daemon-freethreading.conf
<WSGIInterpreterOptions process-group=myapp>
    WSGIFreeThreading On
</WSGIInterpreterOptions>

invoked as:

mod_wsgi-express start-server wsgi.py \
    --process-group myapp \
    --processes 1 --threads 30 \
    --include-file /tmp/daemon-freethreading.conf

Without --process-group, Express names the daemon group after the listening host and port (for example localhost:8000). The generated name is still usable as a selector, but --process-group makes the configuration deterministic and easier to script around.

Choosing a mode

There is no universal answer; the right choice depends on the workload and the C extension surface. Rough guidance:

  • The shared-GIL configuration with multiple daemon processes is the right default for almost every deployment. Concurrency across requests comes from running multiple processes, which sidesteps the GIL and the audit cost of either opt-in mode. Reach for the alternatives only when there is a clear reason to.

  • Choose per-interpreter GIL when the application is CPU-bound, an audit of every loaded C extension for PEP 489 multi-interpreter support is feasible, and the deployment shape calls for parallel execution inside a single process (for example to share a large in-process resource across requests without inter-process synchronisation). Pair it with a dispatch script to route requests across the named sub interpreters.

  • Choose free-threading when every C extension in the loaded set has been audited for the no-GIL runtime and the application can take advantage of intra-process parallelism without the per-sub-interpreter isolation that PEP 684 offers. Free-threading is the most permissive at import time but the highest risk to in-process invariants.

  • In a hybrid environment, split the workload across daemon process groups by mode rather than trying to pick one mode for everything.

Cross-references