Python SDK
Evaluate flags inside your Python services and jobs, with the standard library only and an OpenFeature provider and OpenTelemetry hook behind extras.
The Python SDK keeps a copy of your environment’s flags in memory and evaluates them in your process, so a flag check never waits for the network and your users’ attributes never leave your service. It needs Python 3.10 or newer and nothing outside the standard library.
Install
pip install featflags # or: uv add featflags
pip install 'featflags[openfeature]' # with the OpenFeature provider
pip install 'featflags[otel]' # with the OpenTelemetry hook
The package is featflags on PyPI.
Create a client
Create one client per environment key and share it: it’s safe to use from many threads. On a server, use the environment’s server key (sk_…), which you keep secret.
import featflags
flags = featflags.Client(
"sk_…",
"https://flags.featflags.io",
events_url="https://events.featflags.io",
)
The constructor fetches the first copy of your flags, bounded by timeout. If that fetch fails, the client starts anyway: it serves the bootstrap document, or your fallbacks with reason not_found, until a poll succeeds. version tells you which version of your flags is in use. Polling and sending run on daemon threads; warnings go to the featflags logger.
| Argument | Default | What |
|---|---|---|
key |
required | The environment’s key. |
cdn_url |
required | https://flags.featflags.io. |
events_url |
off | https://events.featflags.io. Without it the SDK sends nothing: no evaluation counts, no experiment data. |
poll_interval |
5 s for sk_… keys, 60 s otherwise |
Seconds between checks for changes, ±10% jitter. Zero or less turns polling off. |
flush_interval |
60 s | Seconds between sends of evaluation counts and experiment data. The first send waits up to one extra interval at random. |
on_exposure |
none | Called with an Exposure the first time each (context key, flag, variant) is evaluated, to forward to your own analytics. |
bootstrap |
none | A Document, or its JSON as text or decoded, to evaluate against until the first fetch answers. |
timeout |
10 s | Keyword-only. HTTP timeout. |
clock |
system clock | Keyword-only. Epoch milliseconds, for scheduled rollout steps; tests pin it. |
membership_cache |
1024 | Keyword-only. List shards kept in memory (about 2 MB) when lists are too big to download whole. |
membership_timeout |
0.5 s | Keyword-only. How long one evaluation waits for list shards it doesn’t have yet. |
hooks |
none | Keyword-only. Run around every evaluation: see Hooks. |
experiment_key |
none | Keyword-only. The environment’s experiment key, xk_…: see Experiments. |
Evaluate flags
user = {"key": "u-alice", "plan": "pro", "groups": ["qa"]}
if flags.bool_variation("new-checkout", user, False):
...
d = flags.string_variation_detail("pricing-page", user, "v1")
log.info("pricing-page: %s (%s, rule %s)", d.value, d.reason, d.rule_id)
bool_variation, string_variation, number_variation and json_variation return the flag’s value, or your fallback when the flag doesn’t exist, the context is invalid, or the value has another type (a bool isn’t a number here, even though Python’s bool is an int). The …_detail variants return a Detail with value, variant and the reason: rule_id with rule, prerequisite with prerequisite_failed. number_variation returns an int or a float as the JSON had it; json_variation returns a fresh copy of lists and dicts every call.
Contexts
A context is who or what you’re evaluating for, in LaunchDarkly’s shape, as a dict:
{"key": "u-alice", "email": "alice@acme.com"} # a user, the default kind
{"kind": "org", "key": "acme", "plan": "team"} # another kind
{"kind": "multi", "user": {"key": "u-alice"}, "org": {"key": "acme", "plan": "team"}} # several at once
To evaluate many flags for the same context, parse it once with featflags.Context(dict); valid, kinds and key(kind) inspect it. Attribute values are strings, numbers, booleans, or lists and tuples of those; None, dicts and nested lists count as missing. An invalid context (no key, a bad kind) gets your fallback with reason error. Concepts has more on kinds.
Lists and private segments
Targets and segment lists of 64 keys or fewer travel inside your flags. Longer ones, up to millions of keys, are split into small shards the SDK fetches separately:
- When all of an environment’s lists total under 50 MB, the client downloads every shard on background threads as soon as your flags arrive.
- Otherwise it fetches a shard the first time an evaluation needs it and keeps the most recently used ones (
membership_cache).
Evaluation is synchronous, so an evaluation that needs a shard the client doesn’t have yet blocks its thread until the shard arrives or membership_timeout passes (0.5 s, shared by the whole evaluation). Past that, the list doesn’t match, the shard keeps loading for later evaluations, and the reason is membership_pending: the value is what the flag serves without that list. A failed shard fetch reports the same and is retried by the next evaluation that needs it.
Private segments can’t be read in a browser without a token from your backend, so nobody can list who’s on them. Compute the tokens at sign-in and hand them to the browser:
tokens = flags.membership_token({"key": user_id}) # {"user": "8ecc…"}
The TypeScript SDK takes them as identify(ctx, { membershipTokens: tokens }). membership_token is empty when the environment has no private lists, or for an invalid context.
Staying up to date
The client checks for changes every 5 seconds with a server key, so your servers have a change within about 5 seconds. Nothing is downloaded unless something changed. It never goes back to an older version of your flags, and when a check fails it keeps serving the last good copy, so a featflags outage leaves your flags as they were. refresh() checks now and raises on failure.
Regex conditions run on the SDK’s own matcher, never on re, so a pattern can’t make an evaluation slow: time grows linearly with the attribute’s length.
Experiments
With events_url set and the environment’s experiment key, the client reports what experiments need: which variant each unit saw, and the events you track. Keep the key with your other secrets; featflags never holds it.
flags = featflags.Client(
"sk_…", "https://flags.featflags.io", events_url="https://events.featflags.io",
experiment_key=os.environ["FEATFLAGS_EXPERIMENT_KEY"],
)
on = flags.bool_variation("new-checkout", user, False) # counts as an exposure if an experiment's audience served it
flags.track(user, "checkout.completed", order.total) # an event key and an optional number; nothing else
flags.track(user, "page.error")
track sends an event to every running experiment whose metrics count that event key, and nothing when none does, so tracking freely costs nothing. It doesn’t check that the user was exposed here: a conversion often happens on your server while the exposure happened in the browser. Event keys match [a-z0-9_.:-]{1,64}; a bad key or value (a bool, a string, NaN, 1e15 or more) is logged once and dropped, never raised. Experiment data goes out with the evaluation counts, and only while the key matches the environment’s: after you rotate it, a deploy with the old key sends nothing and logs why.
Browsers and mobile apps get seeds instead of the key, which would be public in an app. Hand them over at sign-in, with the membership tokens:
seeds = flags.experiment_seeds(user) # None without an experiment key
return {"membershipTokens": flags.membership_token(user), "experimentSeeds": seeds}
The client SDKs take them as experimentSeeds when they identify the user. Seeds carry the key’s fingerprint, so after a rotation an app sends nothing until it gets new ones. How units are computed.
Hooks
A hook runs around every evaluation, for logging, metrics or tracing: before_evaluation in the order you registered hooks, after_evaluation in reverse, with the detail the caller gets. Subclass featflags.Hook and override the stages you need. A stage that raises is logged and skipped.
class Timer(featflags.Hook):
name = "timer"
def before_evaluation(self, series, data):
return {**data, "start": time.monotonic()}
def after_evaluation(self, series, data, detail):
log.info("%s %s: %s in %.6fs", series.method, series.flag_key, detail.reason, time.monotonic() - data["start"])
return data
flags = featflags.Client("sk_…", "https://flags.featflags.io", hooks=[Timer()])
The hooks contract has the details every SDK shares.
OpenTelemetry
featflags.otel.OtelHook, installed with the otel extra (which adds opentelemetry-api), adds a feature_flag.evaluation event to the current span, with OpenTelemetry’s semantic convention attributes:
from featflags.otel import OtelHook
flags = featflags.Client("sk_…", "https://flags.featflags.io", hooks=[OtelHook()])
OpenFeature
featflags.openfeature.FeatflagsProvider, installed with the openfeature extra, is an OpenFeature provider for openfeature-sdk. The provider doesn’t own the client, so close it yourself.
from openfeature import api
from openfeature.evaluation_context import EvaluationContext
from featflags.openfeature import FeatflagsProvider
api.set_provider(FeatflagsProvider(flags))
client = api.get_client("checkout")
on = client.get_boolean_value("new-checkout", False, EvaluationContext("u-alice", {"plan": "pro"}))
The targeting key is the context key. Add a kind attribute for another kind, or kind: "multi" with each member as a dict with its own targetingKey. datetime attributes count as missing: pass instants as RFC 3339 strings or epoch milliseconds. OpenFeature and OpenTelemetry has how reasons map.
Shutting down
The client sends evaluation counts and experiment data once a minute. Call close() before a short-lived job or a server exits, so the last minute isn’t lost; it stops polling and sends what’s waiting. Or use the client as a context manager:
with featflags.Client("sk_…", "https://flags.featflags.io", events_url="https://events.featflags.io") as flags:
...
flush() sends now without closing, and raises on failure. Evaluation counts carry the flag, variant, reason, rule and version, and nothing about the context.