Feature Flags in Python: A Fork-Safe Setup for Gunicorn and uWSGI

Add feature flags to a Python service: local in-memory evaluation, the pre-fork trap that silently freezes flags under Gunicorn and uWSGI, and how to initialize per worker.

  • python
  • tutorials

Most feature flag tutorials stop at “read a flag, branch on it.” In Python that part is three lines. The part that actually decides whether flags work in production is where you build the client, because nearly every Python web app runs behind a pre-forking server: Gunicorn, uWSGI, or Uvicorn with several workers. Those servers fork worker processes, and a background thread does not survive a fork. A flag client created in the wrong place starts a streaming thread that exists in the parent and is gone in every worker, so the workers serve whatever flag values were frozen at fork time, forever, with no error to tell you. The rest of this guide is about putting the client where that does not happen.

This guide adds feature flags to a Python service with the Python server SDK, which evaluates flags locally in memory so a check is a synchronous in-process read with no network round trip. For a copy-paste first flag, the Python quickstart is the five-minute version. This post covers the operational decision that quickstart leaves open: how a long-lived, pre-forked Python service has to be wired so flags keep updating in every worker.

Key Takeaways

  • A Python web app almost always runs behind a pre-forking server (Gunicorn, uWSGI, Uvicorn workers). Worker processes are created with os.fork(), and only the forking thread is copied into the child.
  • The SDK keeps flags current on a background thread. If the client is built before the fork (for example under Gunicorn --preload), that thread is absent in every worker, so flags freeze at their fork-time values and never update again. No exception is raised.
  • The fix is to build the client after the fork: a Gunicorn post_fork hook, a uWSGI @postfork function, a FastAPI lifespan, or lazy first-use construction. The SDK’s per-process cache makes lazy construction safe.
  • client.variation("key", {"user_id": id}, default) is a synchronous in-memory read guarded by a lock, so it is safe from threaded WSGI workers and fine to call from an async view without await.
  • Across workers and replicas the same user_id buckets the same way, so a percentage rollout is consistent without sticky sessions, and a dashboard flip reaches every worker over its own stream.

1. What makes feature flags in Python different

A flag lives in one of three very different homes, and the Python web case sits at the awkward end.

In a one-off script or a notebook, flags barely register: build the client, read a value, do the work, exit. In a browser tab the constraint is the render, which is why a React app cares about avoiding extra re-renders and a Next.js app cares about the hydration flash. A Python web service is the third home, and its defining property is not the language, it is the deployment: the process you wrote is not the process that serves requests. A WSGI or ASGI server boots, then forks a pool of worker processes, and your request handlers run in those children.

That fork is the thing a feature flag library has to respect. Local evaluation wants a long-lived process that fetches the ruleset once, keeps it current over a streaming connection, and answers every check from memory. That model is the same one the Node.js server guide and the Go service guide describe. The Python twist is that “the long-lived process” is the worker, not the master that imported your code, and the streaming connection is a thread that the fork does not carry across. Get the client into the worker and everything works. Build it in the master and the workers inherit a snapshot with no way to refresh it. The next two sections are that distinction in full.


2. Install the SDK and evaluate locally

Install the package:

Terminal window
pip install featureflip

The SDK targets Python 3.10+ and is synchronous on purpose: variation() returns a value, not a coroutine, so it drops into existing Django, Flask, FastAPI, or Celery code without introducing asyncio. Building a client fetches your full ruleset over HTTPS and blocks until that fetch finishes:

from featureflip import FeatureflipClient
client = FeatureflipClient(sdk_key="your-sdk-key")

Because the constructor blocks on the initial load and raises InitializationError on timeout, a client that hands back successfully already has flags in memory, so the first request it serves evaluates against real configuration. Hold the key in FEATUREFLIP_SDK_KEY and you can drop the argument; the SDK reads the environment variable.

Evaluation is a single method whose default argument decides the type:

show_banner = client.variation("new-banner", {"user_id": "u-8a31"}, default=False)
limit = client.variation("rate-limit", {"user_id": "u-8a31"}, default=100)
color = client.variation("banner-color", {"user_id": "u-8a31"}, default="blue")

There is no separate bool_variation or string_variation. Pass a bool default and you get a bool back; pass an int, a str, or a dict and the return type follows. The default is returned if the flag is missing or evaluation fails, so a flag check has no error path of its own. When you need to know why a value came back, client.variation_detail("new-banner", ctx, default=False) returns an EvaluationDetail carrying the reason and the matched rule_id. The Python quickstart has the minimal end-to-end version; the rest of this guide is about the part it does not cover.


3. The pre-fork trap: why a client built too early goes silently stale

Here is the setup that looks correct and is not. You construct the client at import time, or in create_app(), and you run under Gunicorn with --preload so the app loads once in the master before workers fork:

# app.py: built at import, then the master is forked under --preload
from featureflip import FeatureflipClient
client = FeatureflipClient(sdk_key="...") # threads start HERE, in the master

The constructor runs in the master. It fetches the ruleset and starts a background thread that holds the streaming connection (plus a second thread that flushes analytics events). Then Gunicorn calls os.fork() for each worker. A fork copies the calling thread and nothing else: every other thread in the process is absent in the child. So each worker inherits the flag dictionary exactly as it stood at fork time, but the thread that was keeping it current is gone. The worker evaluates flags happily against a snapshot that can never change. Flip a kill switch in the dashboard during an incident and the workers do not hear about it. The event-flush thread is gone too, so exposure and track events pile up in the worker’s queue and never ship. Nothing throws. The only symptom is flags that quietly stopped responding.

The SDK’s per-process cache does not save you here, and it is worth being precise about why. That cache makes duplicate construction harmless: build two clients with the same key in one process and you share one connection. What no cache can fix is a client whose threads were started in a different process. The fork already happened; the threads are already gone. The cache prevents a redundant client, not a dead one.

Where the flag client survives a fork, and where it does not Two scenarios. In the first, the client is built in the master process before the fork, so its background update thread is alive in the master but absent in each forked worker, leaving the workers with a frozen flag snapshot. In the second, the fork happens first and each worker builds its own client, so every worker has a live update thread and flags stay current. Where the flag client survives a fork, and where it does not Illustrative: process layout, not a benchmark Built before the fork (e.g. Gunicorn --preload) master client + update thread ✓ fork() worker 1 · threads gone ✗ worker 2 · threads gone ✗ flags frozen at fork time, no error Built after the fork (post_fork / @postfork / lifespan / lazy) master no client yet fork() worker 1 · builds client ✓ worker 2 · builds client ✓ own stream per worker, flags stay live
A background thread is not copied into a forked child. Build the client in the master and the workers get a frozen snapshot; build it after the fork and each worker owns a live streaming connection.

This is not a Gunicorn-only problem. uWSGI preforks by default: it loads your app in the master and forks workers unless you set lazy-apps = true. The same trap applies to any code that opens a connection or starts a thread at import under a preloading server, which is why database drivers and metrics libraries carry the same warning.


4. Initialize after the fork, not before

The rule is one line: construct the client inside a per-worker startup hook, never at import time under a preloading server. Each server gives you the hook.

Under Gunicorn, use the post_fork server hook in your config file. It runs once inside each worker right after the fork:

gunicorn.conf.py
def post_fork(server, worker):
import myapp.flags
myapp.flags.init_client() # builds the client in THIS worker

Under uWSGI, the @postfork decorator does the same job:

from uwsgidecorators import postfork
@postfork
def init_flags():
import myapp.flags
myapp.flags.init_client()

Under FastAPI or any ASGI app run with Uvicorn workers, a lifespan context manager runs once per worker process at startup, which is already after the fork:

from contextlib import asynccontextmanager
from fastapi import FastAPI
from featureflip import FeatureflipClient
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.flags = FeatureflipClient(sdk_key=os.environ["FEATUREFLIP_SDK_KEY"])
yield
app.state.flags.close()
app = FastAPI(lifespan=lifespan)

The most portable option, which works the same under every server because it defers construction until the first request a worker handles, is lazy first-use:

myapp/flags.py
import os
from featureflip import FeatureflipClient
_client: FeatureflipClient | None = None
def get_client() -> FeatureflipClient:
global _client
if _client is None:
_client = FeatureflipClient(sdk_key=os.environ["FEATUREFLIP_SDK_KEY"])
return _client

The first get_client() call in a worker builds the client in that worker, after the fork, so its streaming thread lives where the requests do. This is where the per-process cache earns its keep: if two threads in the same worker race the first call, they share one underlying client and one connection rather than opening two. The SDK reference covers the lifetime and refcount model in full. The single discipline to keep is that nothing builds the client at import time when the app is preloaded; defer it to the worker.


5. Evaluate from threaded workers and async views

Once a worker holds a client, evaluation is a synchronous in-memory read. The flag store sits behind a lock, so a variation call is safe to make from any thread:

def dashboard_view(request):
ctx = {"user_id": request.user.id, "plan": request.user.plan}
if get_client().variation("new-dashboard-nav", ctx, default=False):
return render_dashboard_v2(request)
return render_dashboard_v1(request)

That covers the two common worker models. Gunicorn and uWSGI can run several threads inside each worker process, and the lock makes concurrent variation() calls from those threads safe with no work on your side. A Celery prefork pool is the same picture: build the client per worker in the worker_process_init signal, then evaluate from tasks. Because the hot path is a dictionary lookup rather than JSON parsing, the GIL is not a contention point in practice.

Async views are fine too. variation() does no I/O, so calling it from an async def handler without await does not block the event loop in any meaningful way; it returns in microseconds from memory. The one call that blocks is the constructor, which is why it belongs in the lifespan startup and not inside a request.

The context dictionary carries what your rules run against: user_id drives percentage rollouts, and any other keys feed targeting rules.

ctx = {"user_id": "u-8a31", "plan": "pro", "country": "US"}
if get_client().variation("priority-queue", ctx, default=False):
route_to_fast_lane()

Production Python services run as many worker processes across several replicas, and a request for a given user can land on any of them. Local evaluation handles that without a shared cache to invalidate. Each worker holds its own in-memory ruleset and its own streaming connection, and bucketing is deterministic: the same user_id hashes to the same position in a percentage rollout in every worker and every replica, so a 10% rollout is the same 10% of users no matter which process answers. You do not need sticky sessions. When you flip a flag, the change pushes to each worker’s stream within milliseconds. If a stream drops, the SDK keeps serving the last known values while it reconnects and falls back to polling every 30 seconds, so a network blip degrades to slightly staler flags rather than to defaults.


6. Shut down each worker cleanly

The client runs background threads and batches analytics events, the exposure data behind a rollout plus any client.track(...) calls. On worker shutdown two things have to happen: the pending event batch flushes and the threads stop. Both are client.close(), and the cleanest way to guarantee it is the context manager, which closes on exit:

with FeatureflipClient(sdk_key="...") as client:
value = client.variation("flag", {"user_id": "u-1"}, default=False)
# close() runs here: flushes events, stops the background thread

For a long-lived server you usually want the client to live for the whole worker, so close it from the same per-worker lifecycle you built it in: the yield-and-after half of a FastAPI lifespan, a Gunicorn worker_exit hook, or a uWSGI shutdown handler. close() is refcounted and idempotent, so a defensive double-close is a no-op, and when several handles share one cached client the real shutdown runs only when the last one closes.


7. Test flag-gated code with for_testing

A flag introduces two branches, and both need tests that do not reach the network. FeatureflipClient.for_testing returns a client backed by fixed values, with no HTTP and no threads:

def test_checkout_new_flow():
client = FeatureflipClient.for_testing({
"new-checkout-flow": True,
"rate-limit": 500,
})
ctx = {"user_id": "test-user"}
assert client.variation("new-checkout-flow", ctx, default=False) is True

The test client ignores the context and returns its configured values, so there is no init to await and nothing to mock. Inject it wherever your code expects a FeatureflipClient, and flip the map in a second test to cover the off path. client.set_test_value("new-checkout-flow", False) updates a value mid-test when you want both branches in one function.


8. Why a first-party SDK, and what it meters

You could skip the SDK and call a flag endpoint over HTTP on each request, or poll it and cache the result yourself. That holds up until the endpoint is slow or down, at which point every request that checks a flag is either waiting on a network call or handling its failure, and you now own the cache, the refresh loop, the SSE reconnection, and the fork-safe lifecycle by hand. Local evaluation is that machinery built once and kept in sync with the dashboard for you, the kind of undifferentiated plumbing a build-versus-buy decision usually settles in favor of buying.

Pricing lands on a Python backend in a specific way. A service evaluates flags on nearly every request, so on a platform that bills by evaluation or by monthly active user, the flag bill tracks traffic, and a launch that triples requests triples the meter. Featureflip uses flat pricing with unlimited evaluations on every plan, so request volume never moves the invoice. The pricing page has the numbers, and the comparison pages show how that lands against per-MAU and usage-billed vendors for a high-throughput service.


The shorter version

What makes feature flags in Python different from a script or a browser is the deployment: a pre-forking server runs your handlers in worker processes, and a background thread does not survive os.fork(). Install the SDK with pip install featureflip, but do not build the client at import time under a preloading server. Build it after the fork, in a Gunicorn post_fork hook, a uWSGI @postfork function, a FastAPI lifespan, or a lazy get_client() first-use helper, so each worker owns a live streaming connection instead of a frozen snapshot. Evaluate with client.variation("key", {"user_id": id}, default), a synchronous in-memory read guarded by a lock that is safe from threaded workers and fine to call from an async view without await. Across workers and replicas the same user_id buckets the same way, so rollouts are consistent without sticky sessions, and a dashboard flip reaches every worker over its stream. Close the client from the same per-worker lifecycle to flush analytics, and use FeatureflipClient.for_testing(...) to cover both branches without a network.


Frequently asked questions

How do I add feature flags to a Python application?

Run pip install featureflip, then build one client per worker process with FeatureflipClient(sdk_key=os.environ["FEATUREFLIP_SDK_KEY"]) and read flags with client.variation("your-flag", {"user_id": id}, default=False). Evaluation is a synchronous in-memory read, so a flag check adds no latency to a request. The one rule specific to Python is where you build the client: under a pre-forking server, construct it after the fork (see below), not at import time. The Python quickstart covers the minimal setup and the Python SDK reference documents every option.

Why do my feature flags stop updating under Gunicorn or uWSGI?

Because the client was built before the worker fork. The SDK keeps flags current on a background thread, and os.fork() copies only the calling thread, so that thread is absent in each worker and the flag store freezes at its fork-time values with no error. It happens when you construct the client at import time under Gunicorn --preload or uWSGI’s default prefork mode. Build the client after the fork instead: a Gunicorn post_fork hook, a uWSGI @postfork function, a FastAPI lifespan, or lazy first-use construction inside the worker.

Is the Featureflip Python SDK safe to call from multiple threads?

Yes. The flag store is guarded by a lock and variation() takes it for a read, so calls are safe from threaded WSGI workers, a Celery prefork pool, or any other threads in the process. The hot path is a dictionary lookup rather than JSON parsing, so the GIL is not a contention point. Build the client once per worker process and share that single instance across the worker’s threads.

Can I use the Python SDK in an async framework like FastAPI?

Yes. variation() is synchronous and does no I/O, so calling it from an async def handler without await returns from memory in microseconds and does not block the event loop. Build the client in a lifespan context manager, which runs once per worker at startup (after the fork), and close it in the same lifespan on shutdown. The only blocking call is the constructor, which is why it belongs in startup and not inside a request handler.

How do feature flags stay consistent across multiple Python workers and replicas?

Each worker process holds its own in-memory ruleset and its own streaming connection, and the same user_id hashes to the same bucket in every worker, so a percentage rollout serves the same users the same variation regardless of which process or replica answers. You do not need sticky sessions. A flag flipped in the dashboard pushes to every worker over its stream within milliseconds, so the whole fleet changes together without a redeploy or a shared cache to invalidate.


Featureflip is a focused, flat-priced feature flag platform with a synchronous Python SDK that evaluates flags locally in memory, so a Python service gets thread-safe flag checks on the request path, fleet-wide updates over streaming, and no bill that scales with traffic, as long as you build the client where its background thread can live: inside the worker, after the fork. The Python SDK reference has the full API, the companion guide on feature flags in Go covers the same local-evaluation pattern for a Go backend, and you can start on the free Solo plan without a credit card.