Use Optimizely Feature Experimentation Feature Flags in Python

David Sertillange, Independent experimentation specialistDavid SertillangeIndependent experimentation specialist
·5 min read

Installing the Optimizely Feature Experimentation Python SDK takes about four lines. Running it correctly in a Python web application takes rather more thought, because the two things the SDK relies on — a background polling thread and a background event dispatch queue — both live inside a process that Gunicorn, uWSGI or Celery is going to fork, restart and kill without asking.

This guide covers initializing the Python SDK, reading a flag, tracking conversions, and the process-model questions that decide whether your decisions are fresh and your events arrive at all. If the flag is carrying an experiment, size it on the sample size calculator first, and check how long to run an A/B test before you commit a release window to it.

How the Python SDK Decides a Flag

The SDK downloads a datafile — the JSON description of every flag, rule and audience in the environment — and evaluates decisions locally. No decision makes a network call. The two network activities are polling for a new datafile and dispatching events, and both run on their own threads.

flowchart TD
    A[Worker process starts] --> B[Optimizely client created with SDK key]
    B --> C[PollingConfigManager thread fetches the datafile]
    C --> D[Client ready]
    D --> E[create_user_context with user id and attributes]
    E --> F[decide flag_key -> local evaluation]
    F --> G[Impression queued on BatchEventProcessor]
    G --> H[Dispatch thread posts a batch to Optimizely]

Everything below is a consequence of that picture: the client is stateful, it owns threads, and a forked child does not inherit a running thread.

Prerequisites

  • An Optimizely Feature Experimentation project and the SDK key for the environment you are wiring up.

  • A flag created in the Optimizely UI.decide('checkout_redesign') on a key that does not exist returns a decision with enabled false — indistinguishable from a real off answer, so check the key twice.

  • A stable user identifier, persisted per visitor. Bucketing is a hash of that id.

  • Conversion events created in Optimizely for every metric you intend to send.

  • Python 3.8 or later and the optimizely-sdk package added to your requirements file.

Step 1: Create One Client Per Worker Process

Create the client once per process and hold it at module scope. Creating it per request means a datafile fetch per request and an event queue that is discarded before it flushes.

from optimizely import optimizely
from optimizely.config_manager import PollingConfigManager

_client = optimizely.Optimizely(
    config_manager=PollingConfigManager(
        sdk_key=os.environ["OPTIMIZELY_SDK_KEY"],
        update_interval=300,
    ),
)

def get_client():
    return _client

Under a forking server this module must be imported after the fork, not before. Gunicorn's default is to fork before loading the application, which is what you want here; if you have enabled --preload, the polling thread is started in the master and the children inherit a config manager whose thread does not exist. The symptom is a datafile that never updates in production while working perfectly in development.

If you cannot control that, the safe pattern is a post_fork hook that builds the client in the child:

# gunicorn.conf.py
def post_fork(server, worker):
    import app.flags
    app.flags.init_client()

Step 2: Read a Flag

user = get_client().create_user_context(
    user_id=request.user_id,
    attributes={"plan": request.user_plan, "country": request.country},
)

decision = user.decide("checkout_redesign")

if decision.enabled:
    render_new_checkout(label=decision.variables["label"])
else:
    render_legacy_checkout()

decide returns enabled, variation_key, variables, rule_key and reasons. reasons is populated when you pass OptimizelyDecideOption.INCLUDE_REASONS, and it is the fastest way to answer "why did this user not get the treatment" — it names the audience that excluded them.

Calling decide records an impression. Call it where the user actually meets the change, not in middleware that runs on every request: enrolling users who never reached the tested path dilutes the measured effect toward zero.

For a read that must not enrol anybody — a health check, an admin dashboard, a log line — pass OptimizelyDecideOption.DISABLE_DECISION_EVENT.

Step 3: Track Conversions

user.track_event(
    "purchase_completed",
    event_tags={"revenue": int(round(order.total_usd * 100)), "value": order.margin},
)

revenue is an integer in cents; value is a float for numeric metrics. Anything else in event_tags is an event property and must exist in Optimizely first — undeclared properties are dropped at ingestion, silently.

The tracking call has to be made against a user context carrying the same user id that received the decision. In a background job that is the part people get wrong: the Celery task has the order, not the request, so pass the user id into the task payload rather than reconstructing an identity from whatever the worker happens to know.

Step 4: Flush Events Before the Process Exits

BatchEventProcessor holds events in memory and dispatches them on an interval or when the batch fills. A worker that is recycled — by --max-requests, by a deploy, by a container scaling in — drops whatever is still queued.

import atexit

atexit.register(_client.close)

close() flushes the queue and stops the threads. Register it in web workers, in Celery workers, and in any script that decides or tracks. A short-lived script without it will frequently record nothing at all, which looks exactly like an integration that was never wired up.

For scripts specifically, the simpler answer is to skip polling: pass a datafile string you fetched yourself, decide, track, and close().

Step 5: Handle Celery and Other Forking Workers

Celery prefork workers have the same constraint as Gunicorn, and one more: the client must not be created in the parent and used in the child.

from celery.signals import worker_process_init, worker_process_shutdown

@worker_process_init.connect
def init_optimizely(**kwargs):
    app.flags.init_client()

@worker_process_shutdown.connect
def close_optimizely(**kwargs):
    app.flags.get_client().close()

With --pool=gevent or --pool=eventlet the threading model changes again; the SDK's threads work, but the polling interval is only honoured when the loop yields. If a worker is CPU-bound for minutes at a time, its datafile is stale for minutes at a time.

Verifying the Integration

  1. Log every decision with a notification listener. Subscribe to DECISION and log rule_key, variation_key and the attributes the SDK received. Attributes that never arrive are the single most common cause of "the audience does not match".

  2. Confirm the datafile is moving. Log the datafile revision at each config update. If the revision is constant across a deploy, the polling thread is not running — go back to the fork question in step 1.

  3. Confirm events leave the process. Set the SDK log level to debug and look for the dispatch line, or point event_dispatcher at a wrapper that logs the payload. An empty log after ten minutes means the queue is filling and nothing is flushing.

  4. Decide for two user ids and confirm you can reach both variations.

Common Failure Modes

  • --preload with a polling config manager. The datafile silently freezes at the revision the master fetched.

  • No close() anywhere. Events accumulate and die with the process. Common in cron jobs, management commands and tests.

  • A client per request. A datafile fetch per request, an event queue that never flushes, and latency that scales with traffic.

  • Deciding in middleware. Every request becomes an impression, including the ones for endpoints the experiment does not touch.

  • A user id derived from the session object. Session ids rotate on login and on expiry, which re-buckets the user mid-experiment.

David Sertillange, Independent experimentation specialist
David Sertillange

Independent experimentation specialist

David Sertillange is an independent experimentation specialist with 10 years implementing Optimizely across enterprise programs. He specializes in Feature Experimentation, analytics integrations, and helping teams build a culture of data-driven decision making.

Subscribe

Practical Optimizely tips, monthly. No fluff.