Skip to content

Python SDK Reference

TL;DR: Install featureflip, call client.variation("flag-key", {"user_id": "u-123"}, default=False), and you’re evaluating flags — synchronous, no asyncio.

The featureflip Python package provides a synchronous client for evaluating feature flags in any Python 3.10+ application — Django, Flask, FastAPI, Celery workers, scripts, or notebooks. The SDK is intentionally synchronous: variation calls do not return coroutines, so you can drop them into existing sync codebases without rewriting call sites or introducing asyncio to a non-async project.

Internally the client uses httpx for HTTP and SSE streaming, and stores flag configurations in a thread-safe in-memory cache. Evaluations read from this cache under a lock and return immediately, so they are safe to call from gunicorn workers, threaded WSGI servers, and Celery prefork pools alike. There is no GIL contention because the hot path is a dictionary lookup, not Python bytecode crunching JSON.

Evaluation context is a plain dict[str, Any] rather than a class — {"user_id": "u-123", "plan": "pro"} is the entire API. The user_id key is reserved: it anchors percentage rollouts so a given user lands in a stable bucket across processes and pods. Custom keys feed into targeting rules. String enum values from the API (rule operators, evaluation reasons) are normalized to lowercase on the client side, so you can compare them against Python literals without worrying about casing.

View source on GitHub

Terminal window
pip install featureflip

The package depends on httpx, httpx-sse, and structlog.

The Featureflip Python SDK is designed to be used as a singleton across your application. Construct the client once and share it:

# In your application startup
from featureflip import FeatureflipClient
client = FeatureflipClient(sdk_key="your-sdk-key")

Calling FeatureflipClient(sdk_key="x") multiple times with the same SDK key returns distinct handle objects that share a single underlying client — the SDK maintains a process-wide cache keyed by SDK key. You cannot accidentally open duplicate streaming connections by constructing multiple clients.

# Both of these return distinct handle objects that share one underlying client
c1 = FeatureflipClient(sdk_key="your-sdk-key")
c2 = FeatureflipClient(sdk_key="your-sdk-key")
assert c1 is not c2 # different handles
# ...but only one HTTP connection, one polling thread, one event processor
c1.close() # decrements refcount; c2 is still alive
c2.close() # last handle closed; real shutdown runs

Different SDK keys get independent clients:

client_a = FeatureflipClient(sdk_key="sdk-key-a")
client_b = FeatureflipClient(sdk_key="sdk-key-b")
# Two independent clients

Register the client as a module-level singleton at application startup. Any framework-specific startup / shutdown hook can own the construction and closing:

# django: in AppConfig.ready() or a module-level variable
# flask: in create_app() or an extension init
# fastapi: in a lifespan context manager
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

Even if your framework ends up constructing multiple clients (e.g., under autoreload or gunicorn’s pre-fork model), the singleton-by-construction cache ensures duplicate constructions are harmless.

close() is refcounted. When multiple handles share one cached client, closing one handle does not shut down the shared background threads — the real shutdown runs only when the last handle is closed. In a typical application the client lives for the entire process lifetime and is cleaned up automatically at process exit or via a framework’s shutdown lifecycle.

As a context manager:

with FeatureflipClient(sdk_key="your-sdk-key") as client:
value = client.variation("flag", {"user_id": "123"}, default=False)
# client.close() is called automatically; decrements refcount

The Config dataclass accepts the following options:

OptionTypeDefaultDescription
base_urlstr"https://eval.featureflip.io"Base URL of the Evaluation API
connect_timeoutfloat5.0Connection timeout in seconds
read_timeoutfloat10.0Read timeout in seconds
streamingboolTrueEnable SSE for real-time flag updates
poll_intervalfloat30.0Polling interval in seconds (used when streaming is disabled)
send_eventsboolTrueEnable analytics event tracking
flush_intervalfloat30.0Interval in seconds between event flush batches
flush_batch_sizeint100Maximum number of events per flush batch
init_timeoutfloat10.0Maximum time in seconds to wait for initialization
from featureflip import Config
config = Config(
base_url="https://eval.featureflip.io",
streaming=True,
poll_interval=60.0,
flush_interval=15.0,
init_timeout=30.0,
)

All timeout and interval values must be positive. The constructor validates these constraints and raises ValueError for invalid values.

Create a client by providing an SDK key and optional configuration:

from featureflip import FeatureflipClient, Config
client = FeatureflipClient(
sdk_key="sdk-dev-abc123",
config=Config(base_url="https://eval.featureflip.io"),
)

The constructor performs initialization synchronously: it fetches the initial flag configuration, starts streaming or polling, and begins the event processor. If initialization fails or times out, an InitializationError is raised.

If sdk_key is not provided, the client falls back to the FEATUREFLIP_SDK_KEY environment variable. A ValueError is raised if neither is available.

import os
os.environ["FEATUREFLIP_SDK_KEY"] = "sdk-dev-abc123"
# SDK key read from environment
client = FeatureflipClient()

The client supports Python’s context manager protocol for automatic cleanup:

with FeatureflipClient(sdk_key="sdk-dev-abc123") as client:
enabled = client.variation("new-feature", {"user_id": "123"}, default=False)
# client.close() is called automatically
client.is_initialized: bool

A read-only property that returns True once the client has successfully loaded flag data.

The Python SDK uses a single variation() method for all flag types, rather than separate typed methods.

enabled = client.variation("dark-mode", {"user_id": "u-123"}, default=False)
plan = client.variation("pricing-tier", {"user_id": "u-123"}, default="free")
limit = client.variation("rate-limit", {"user_id": "u-123"}, default=100)

Signature:

def variation(
self,
key: str,
context: dict[str, Any],
default: T,
track: bool = True,
) -> T
ParameterTypeDescription
keystrThe flag key to evaluate
contextdict[str, Any]User attributes for targeting. The user_id key is used for percentage rollouts
defaultTValue returned if the flag is not found or evaluation fails
trackboolWhether to track this evaluation event (default True)

This method never raises exceptions. On any error, it logs a warning and returns the default value.

Returns an EvaluationDetail object with the evaluated value and metadata about the evaluation decision.

detail = client.variation_detail("dark-mode", {"user_id": "u-123"}, default=False)
print(detail.value) # True
print(detail.reason) # EvaluationReason.RULE_MATCH
print(detail.rule_id) # "rule-abc"

Signature:

def variation_detail(
self,
key: str,
context: dict[str, Any],
default: T,
) -> EvaluationDetail

A frozen dataclass containing the evaluation result:

PropertyTypeDescription
valueAnyThe evaluated flag value
reasonEvaluationReasonWhy this value was returned
rule_idstr | NoneThe ID of the matched targeting rule, if applicable
errorException | NoneThe exception that occurred, if reason is ERROR

An enum describing why a particular flag value was returned:

ValueDescription
FALLTHROUGHNo rules matched; the fallthrough variation was served
RULE_MATCHA targeting rule matched the context
FLAG_DISABLEDThe flag is disabled; the off variation was served
FLAG_NOT_FOUNDThe flag key does not exist
ERRORAn error occurred during evaluation

Records a custom analytics event.

client.track("purchase-completed", {"user_id": "u-123"}, metadata={
"plan": "pro",
"amount": 29.99,
})

Signature:

def track(
self,
event_name: str,
context: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> None

Sends user attributes for segment building and analytics.

client.identify({
"user_id": "u-123",
"email": "[email protected]",
"plan": "pro",
})

Signature:

def identify(self, context: dict[str, Any]) -> None

Shuts down the client, stops streaming or polling, flushes remaining events, and closes HTTP connections.

client.close()

Forces all buffered events to be sent to the server immediately.

client.flush()

Using the context manager (with statement) is the recommended approach, as it calls close() automatically when the block exits.

Creates a test client with fixed flag values. No network calls are made and no background threads are started.

client = FeatureflipClient.for_testing({
"dark-mode": True,
"pricing-tier": "enterprise",
"rate-limit": 500,
})
client.variation("dark-mode", {}, default=False) # True
client.variation("pricing-tier", {}, default="free") # "enterprise"

Signature:

@classmethod
def for_testing(cls, flags: dict[str, Any]) -> FeatureflipClient

Updates a flag value on a test client at runtime. Raises RuntimeError if called on a non-test client.

client = FeatureflipClient.for_testing({"dark-mode": True})
# Change the value during the test
client.set_test_value("dark-mode", False)
client.variation("dark-mode", {}, default=True) # False

Signature:

def set_test_value(self, key: str, value: Any) -> None

All public types are available from the top-level featureflip package:

from featureflip import (
FeatureflipClient,
Config,
EvaluationContext,
EvaluationDetail,
EvaluationReason,
InitializationError,
ConfigurationError,
FeatureflipError,
)
ExceptionDescription
FeatureflipErrorBase exception for all SDK errors
InitializationErrorRaised when initialization fails or times out
ConfigurationErrorRaised for invalid configuration values