Bandera
Feature flags for Elixir with multivariate variants, data-defined targeting, and time-window scheduling — configured entirely at runtime, with an async-safe test layer.
Beyond the usual boolean/actor/group/percentage gates, Bandera adds N-way variant flags, attribute-based targeting rules with reusable segments, flag prerequisites, and scheduled activation windows — all stored as data, backed by in-memory, Ecto, or Redis storage, with an ETS cache and cross-node cache-busting notifications.
Why Bandera?
Most Elixir flag libraries cover boolean, actor, group, and percentage rollouts. Bandera does all of those — but it exists for the harder cases, and three capabilities are first-class here:
1. Multivariate flags, not just on/off. Serve one of N named variants, weighted and sticky per actor (the same user always sees the same variant). Built for A/B/n experiments and gradual variant ramps, not only kill switches. See Multivariate flags.
2. Data-defined targeting that changes without a deploy. Target on arbitrary
attributes with a rich operator set (:eq, :in, :gt, :matches, …) through an
evaluation context, package them into reusable named segments, and gate flags on
other flags with prerequisites — all stored as data. Change who is targeted by
writing a row, not shipping code. See
Targeting rules and segments.
3. Scheduling — flags that flip themselves. Activate a flag inside an ISO-8601 time window (UTC) so launches and sunsets happen on schedule, instead of someone toggling a switch at midnight. See Scheduling.
And two foundations it's built to get right — the ones most flag systems get wrong:
Runtime config, no recompilation. Bandera reads every setting at runtime
through Application.get_env/3 and never touches Application.compile_env/3.
Compile-time config gets baked into artifacts: change a value and you recompile,
and mix release's :validate_compile_env check refuses to boot when
config/runtime.exs overrides a compile-time key. Here, cache, TTL, persistence
adapter, Ecto table name, and notifications all live in config/runtime.exs, and
Bandera.reload_config/0 applies changes live. Resolved config is cached in
:persistent_term, so hot-path reads stay fast.
Async-safe testing, no global bleed or deadlocks. Flag state is normally
global (shared ETS or a database row), so toggling a flag in one test leaks into
others. That forces flag tests to run async: false, and writing flags inside
the Ecto SQL sandbox can deadlock. Bandera ships a process-scoped test layer:
overrides are scoped to the test process (and its spawned tasks), so tests run
async: true without interfering, never touch the database, and clean up
automatically when the test process exits. See Testing.
Installation
Add bandera to your dependencies in mix.exs:
def deps do
[
{:bandera, "~> 0.3.0"}
]
end
Then fetch it:
mix deps.get
Out of the box Bandera uses an in-memory (ETS) store, so it works with no further setup for development and single-node use. The persistence and notification backends are optional dependencies; add only the ones you use:
{:ecto_sql, "~> 3.10"}, # Ecto persistence (plus a DB driver: :postgrex or :ecto_sqlite3)
{:redix, "~> 1.1"}, # Redis persistence and/or Redis PubSub notifications
{:phoenix_pubsub, "~> 2.1"}, # Phoenix.PubSub cross-node notifications
{:nimble_ownership, "~> 1.0", only: :test} # required for the test layer
Configuration
All configuration is read at runtime, so you can place it in
config/runtime.exs (or any config file). Everything has a default; this is
only needed to change a backend.
config :bandera,
cache: [enabled: true, ttl: 900],
persistence: [
adapter: Bandera.Store.Persistent.Ecto,
repo: MyApp.Repo,
ecto_table_name: "bandera_flags"
],
cache_bust_notifications: [
enabled: true,
adapter: Bandera.Notifications.PhoenixPubSub,
client: MyApp.PubSub
]
Defaults if you configure nothing: in-memory store, cache on (900s TTL),
notifications off. Call Bandera.reload_config/0 to re-read config at runtime.
Using the Ecto store
If you choose the Ecto adapter, create the flags table with a migration:
defmodule MyApp.Repo.Migrations.CreateBanderaFlags do
use Ecto.Migration
def up, do: Bandera.Ecto.Migrations.up()
def down, do: Bandera.Ecto.Migrations.down()
end
The table name is read from your runtime config (default "bandera_flags").
The Ecto adapter is backend-agnostic: it uses only standard column types and
binds the table at runtime, with no database-specific SQL. It works with any
Ecto SQL database — Postgres (via :postgrex) or SQLite (via :ecto_sqlite3).
SQLite is the backend Bandera's own test suite runs against.
Usage
Once installed, the whole API lives on the Bandera module.
# Simple on/off
Bandera.enable(:checkout) #=> {:ok, true}
Bandera.enabled?(:checkout) #=> true
Bandera.disable(:checkout) #=> {:ok, false}
# Per actor (pass `for:` to check)
Bandera.enable(:beta, for_actor: "user-1")
Bandera.enabled?(:beta, for: "user-1") #=> true
Bandera.enabled?(:beta, for: "user-2") #=> false
# Per group
Bandera.enable(:beta, for_group: :staff)
Bandera.enabled?(:beta, for: %{groups: [:staff]}) #=> true
# Percentage rollout (ratio between 0.0 and 1.0)
Bandera.enable(:gradual, for_percentage_of: {:actors, 0.25})
Bandera.enable(:gradual, for_percentage_of: {:time, 0.10})
# Remove gates / introspect
Bandera.clear(:checkout) #=> :ok (removes the whole flag)
Bandera.clear(:beta, for_actor: "user-1")
Bandera.all_flag_names() #=> {:ok, [:beta]}
Bandera.get_flag(:beta) #=> {:ok, %Bandera.Flag{...}}
enabled?/2 always returns a boolean. A missing flag (or a store error,
which is logged) resolves to false.
Actors and groups
An actor is anything with a stable string id; a group is a named bucket an actor
can belong to. Bandera ships implementations for binaries, integers, and maps
(:id / :groups keys). For your own structs, implement the protocols:
defimpl Bandera.Actor, for: MyApp.User do
def id(user), do: "user:#{user.id}"
end
defimpl Bandera.Group, for: MyApp.User do
def in?(user, group_name), do: group_name in user.roles
end
Multivariate flags
Instead of a binary on/off, a flag can return one of N named variants. Actors are assigned a variant by a stable SHA-256 hash (same actor + flag → same variant across nodes and restarts), weighted proportionally:
# Create a 50/50 A/B test
Bandera.put_variants(:hero_cta, %{"control" => 1, "treatment" => 1})
# Resolve which variant the current user sees
variant = Bandera.variant(:hero_cta, for: current_user)
# => "control" or "treatment", same value for the same user every time
# Fallback when the flag is missing or has no variant gate
Bandera.variant(:hero_cta, default: "control")
# Weighted split: 10% treatment, 90% control
Bandera.put_variants(:checkout, %{"control" => 9, "new_flow" => 1})
variant/2 returns nil (or options[:default]) when the flag does not exist,
has no variant gate, or no for: actor is given (including for: nil). The same
stable SHA-256 score used by percentage_of_actors gates determines an actor's
bucket — deterministic per actor+flag pair, different across flags.
Targeting rules and segments
Flags can target arbitrary attributes in an evaluation context map — without deploying code. Rules are stored as data in the flags table.
Context-based rules
Pass a context: map to enabled?/2 and use enable(flag, when: constraints)
to define which context values grant access:
# Enable only for premium US users
Bandera.enable(:new_billing,
when: [
{"plan", :eq, "premium"},
{"country", :eq, "US"}
]
)
# All constraints must match (AND semantics)
Bandera.enabled?(:new_billing, context: %{"plan" => "premium", "country" => "US"})
#=> true
Bandera.enabled?(:new_billing, context: %{"plan" => "free", "country" => "US"})
#=> false
Supported operators: :eq, :neq, :in, :not_in, :contains, :gt,
:gte, :lt, :lte, :matches (regex).
Reusable segments
Define a segment once and reference it from many flags:
# Store a named set of constraints
Bandera.put_segment(:premium_us, [
{"plan", :eq, "premium"},
{"country", :eq, "US"}
])
# Enable flags by referencing the segment
Bandera.enable(:new_billing, for_segment: :premium_us)
Bandera.enable(:advanced_reports, for_segment: :premium_us)
# Check is the same — pass context:
Bandera.enabled?(:new_billing, context: %{"plan" => "premium", "country" => "US"})
#=> true
Segment constraints are expanded at evaluation time; changing a segment's rules automatically affects every flag that references it.
Evaluation precedence
When :for and :context are both present, precedence is:
actor gates → group gates → rule/segment gates → boolean/schedule gates → percentage
gates.
Migrating an existing Ecto install (schema v2)
If you already have the Bandera flags table and want to add variant support, run this one-time helper from a new migration:
defmodule MyApp.Repo.Migrations.BanderaSchemaV2 do
use Ecto.Migration
def up, do: Bandera.Ecto.Migrations.upgrade_v2()
def down, do: :ok
end
upgrade_v2/0 adds the nullable value column to an existing table with a plain
add/2 (so it also works on adapters such as SQLite3 that reject conditional column
additions); run it once from a versioned migration. New installs calling
Bandera.Ecto.Migrations.up() get the column automatically.
Prerequisites
A flag can require another flag to be in a given state before it's allowed to turn on. Prerequisites only veto — the dependent flag still needs its own granting gate.
Bandera.enable(:parent_feature)
Bandera.enable(:child_feature, requires: :parent_feature)
Bandera.enable(:child_feature) # the child's own grant
Bandera.enabled?(:child_feature) # true only while :parent_feature is enabled
Require a parent to be off with requires: {:parent, false}. Dependency cycles and
missing parents resolve to false rather than looping.
Scheduling
A schedule gate enables a flag only inside an ISO-8601 time window, evaluated in UTC.
Either bound may be nil for an open-ended start or end.
# Active only during the launch window
Bandera.enable(:black_friday,
schedule: {"2026-11-27T00:00:00Z", "2026-11-30T23:59:59Z"})
Bandera.enabled?(:black_friday) # true only inside the window
# Open-ended: on from a start time, with no end
Bandera.enable(:new_nav, schedule: {"2026-06-01T00:00:00Z", nil})
A malformed stored window fails closed (the flag is simply not enabled by the schedule). Schedule gates take part in the boolean fallback, so they combine with the other gate types just as the boolean gate does.
Testing
Bandera's test layer scopes flag overrides to the test process (and its
spawned tasks), so tests can run async: true without interfering with each
other and without touching the database.
# config/test.exs
config :bandera, store: Bandera.Store.ProcessScoped
# test/test_helper.exs
Bandera.Test.start()
defmodule MyApp.CheckoutTest do
use ExUnit.Case, async: true
use Bandera.Test
@tag feature_flags: [checkout: true]
test "feature on via tag" do
assert Bandera.enabled?(:checkout)
end
test "toggle in the body" do
enable_flag(:beta)
assert Bandera.enabled?(:beta)
end
end
Overrides are cleaned up automatically when the test process exits.
Fail-open default
By default enabled?/2 returns false when the store is unreachable (the
error is logged). Pass default: true to fail open instead:
# Returns true if the store is down, false if the flag is simply off.
Bandera.enabled?(:checkout, default: true)
# Works with per-actor checks too.
Bandera.enabled?(:beta, for: current_user, default: true)
Audit log
Bandera includes an opt-in audit hook that turns the built-in write telemetry into structured change events. Attach it once at startup with a callback:
# In your application start/2 or a supervision child:
Bandera.Audit.attach(:my_audit, fn event ->
MyApp.AuditLog.insert!(%{
action: event.action,
flag: event.flag_name,
actor: event.actor,
at: event.at
})
end)
The callback receives a %Bandera.Audit.Event{} on every enable/2,
disable/2, or clear/2 call. Pass :by to the write functions to record
who made the change:
Bandera.enable(:checkout, by: current_user.email)
Bandera.disable(:beta, for_actor: %{id: 1}, by: "admin@example.com")
Call Bandera.Audit.detach/1 with the same handler id to stop receiving events:
Bandera.Audit.detach(:my_audit)
Stale flags
Bandera can tell you which flags haven't been evaluated recently, so you can prune flags that are no longer in use.
Start Bandera.Usage in your supervision tree and call attach/0 once at
boot:
# In your application start/2:
children = [
# ... your other children ...
Bandera.Usage
]
Supervisor.start_link(children, strategy: :one_for_one)
# After the supervisor starts:
Bandera.Usage.attach()
Then query stale flags from IEx or a scheduled job:
# Flags not evaluated in the past 30 days (default):
Bandera.stale_flags()
# Custom window:
Bandera.stale_flags(older_than: 60)
Or use the Mix task from the command line:
# List all flags:
mix bandera.flags
# List stale flags (not evaluated in 30 days):
mix bandera.flags --stale
# Custom window:
mix bandera.flags --stale --older-than 60
Bandera.Usage is opt-in: nothing breaks when it is not started —
stale_flags/1 simply treats every flag as stale.
Telemetry
Bandera emits :telemetry events for reads ([:bandera, :enabled?]), writes
([:bandera, :enable | :disable | :clear]), and the persistence layer. Attach
your own handlers to measure flag usage and store latency.
Dashboard (optional)
Bandera includes an optional LiveView dashboard to browse and manage flags —
grouped, searchable, with full per-gate control. It ships in the core library and
activates only when your app depends on phoenix_live_view. It ships no
JavaScript and needs no asset-pipeline changes — it inherits your layout and runs
on your existing LiveView socket. Mount it behind your own admin auth:
import Bandera.Dashboard.Router
scope "/admin" do
pipe_through [:browser, :require_admin]
bandera_dashboard "/flags"
end
See the Dashboard guide for details.
Documentation
In-depth guides:
- Feature Guide — multivariate flags, targeting rules, segments, prerequisites, scheduling, audit log, and stale-flag tooling
- Using Bandera with Phoenix LiveView
- Flag Dashboard (LiveView UI)
- Migration from fun_with_flags
Generate docs locally with ExDoc:
mix docs
Once published, the docs will be at https://hexdocs.pm/bandera.