Feature Flags¶
Feature flags let you ship code behind a switch — roll a change out to one user, one team, or everyone, without a deploy for every flip. arvel's feature-flag module follows one simple model: define a flag once with a resolver, then ask whether it's active for a given scope (a user, a team, or nothing at all for a global flag).
A resolver runs at most once per scope — the first active()/value() call for a scope
resolves it and writes the result to the configured store; every call after that (for that same
scope) is served straight from the store, not re-computed.
Backends
The in-process array driver is the default and needs nothing — great for tests. database
persists resolved values in a features table (survives a restart); cache stores them in
the cache, tagged per flag so purging one flag never touches another's stored
values. Set features.driver.
Defining a flag¶
from arvel.features import Feature
Feature.define("new-dashboard", lambda scope: scope.is_beta_tester)
The resolver receives whatever scope you resolve the flag against and returns bool | str | Any
— a rich value, not just a boolean (see Rich values below). Register your flags in
a service provider's boot(), the same place you'd register routes or event listeners.
A class-based feature works too — it's instantiated once and dispatched through .resolve:
class NewDashboard:
def resolve(self, scope: Any) -> bool:
return scope.is_beta_tester
Feature.define("new-dashboard", NewDashboard)
Checking a flag¶
await Feature.active("new-dashboard", user) # bool
await Feature.inactive("new-dashboard", user) # not active
await Feature.for_(user).active("new-dashboard") # same thing, scope bound up front
scope=None (the default) resolves the flag globally — every caller shares the same stored
value, keyed by features.default_scope ("__global__" unless you change it).
Rich values¶
A resolver isn't limited to bool — return a variant string, a rollout percentage, anything
JSON-able:
Feature.define("checkout-variant", lambda scope: "purple" if scope.id % 2 else "orange")
await Feature.value("checkout-variant", user) # "purple" / "orange"
await Feature.active("checkout-variant", user) # True — active() is just truthiness
when — branch on the resolved value¶
await Feature.when("checkout-variant", user,
lambda variant: render(variant), # called with the resolved value when active/truthy
lambda variant: render_default(), # called when falsy
)
Overriding a resolver¶
activate/deactivate write directly to the store, bypassing the resolver entirely — useful for
an admin toggle or a test:
await Feature.activate("new-dashboard", user) # force it on
await Feature.deactivate("new-dashboard", user) # force it off
await Feature.activate("checkout-variant", user, value="purple") # force a rich value
forget clears one scope's stored value (the resolver runs again next time it's checked);
purge clears every stored scope for a flag — reach for it after changing a resolver's logic:
await Feature.forget("new-dashboard", user) # this user only
await Feature.purge("new-dashboard") # every scope, everywhere
await Feature.purge() # every defined flag — a clean slate
Resolve several flags for one scope in a single call with values:
await Feature.values(scope=user) # {name: value} for every defined flag
await Feature.values(["beta", "gamma"], user) # just these two
CLI¶
arvel feature:list # every flag registered via Feature.define()
arvel feature:purge <name> # clear every stored value for <name>
Configuration¶
# config/features.py
config = {
"driver": "database", # "array" | "database" | "cache"
"default_scope": "__global__",
}
The database driver stores resolved values in a features table; a new app ships the migration
for it, so arvel migrate creates it out of the box — no hand-written schema needed.
Common mistakes & gotchas¶
- Expecting a resolver to re-run. A resolver runs once per scope, then the result is stored.
Changing the resolver's logic doesn't retroactively re-evaluate stored scopes —
purgethe flag (orforgeta scope) to force a re-resolve. - Defining flags too late. Register
Feature.define(...)in a provider'sboot(). Define it after the code that checks it has already run, and that check resolves against an undefined flag. activate/deactivateare permanent overrides. They write straight to the store and bypass the resolver — a forced value sticks until youforget/purgeit, even if the resolver would now say otherwise.arraydriver in production. It's in-process and resets every boot — perfect for tests, wrong for a real rollout. Usedatabaseorcachewhen values must survive a restart.- A global check that should be scoped.
active("flag")with no scope resolves the single__global__value for everyone; pass the user/team when the flag is meant to vary per scope.
How it works¶
Feature is a manager over a pluggable store (array/database/cache). define registers a
resolver (a callable or a class with .resolve); the first active/value for a scope runs it and
writes the result to the store keyed by (flag, scope), and every later check reads the stored value.
activate/deactivate write the store directly; forget/purge evict entries so the resolver runs
again. Rich (non-boolean) values are stored JSON-encoded, and active() is simply the truthiness of
the resolved value.
See also¶
- Cache — the
cachedriver's backing store, and per-flag tag purging. - Migrations & Schema — the
featurestable thedatabasedriver uses. - Service Providers — where you register flags in
boot(). - Console —
feature:list/feature:purge.