Feature toggles and gradual rollouts for Go SaaS applications. Flags are
evaluated against an immutable in-memory snapshot, so a request never waits on
the database: kill switches, user and tenant targeting, attribute rules,
sticky percentage rollouts and weighted experiments resolve in roughly 100 ns
without allocating. Snapshots refresh by polling and, with PostgreSQL, by push
through LISTEN/NOTIFY. Writes go through a validating management API with
optimistic concurrency and audit hooks.
go get github.com/byteminus/go-featureflagsRequires Go 1.26 or later.
package main
import (
"context"
"log"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
featureflags "github.com/byteminus/go-featureflags"
"github.com/byteminus/go-featureflags/pgxstore"
)
func main() {
ctx := context.Background()
url := os.Getenv("DATABASE_URL")
pool, err := pgxpool.New(ctx, url)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
store, err := pgxstore.New(pool)
if err != nil {
log.Fatal(err)
}
if err := store.CreateSchema(ctx); err != nil {
log.Fatal(err)
}
// Management: validated writes with optimistic concurrency.
manager, err := featureflags.NewManager(store, featureflags.ManagerOptions{})
if err != nil {
log.Fatal(err)
}
flag := featureflags.NewBoolFlag("new-checkout", "Redesigned checkout flow")
flag.Targets = []featureflags.Target{{Variant: featureflags.VariantOn, Tenants: []string{"acme"}}}
flag.Rollout = featureflags.PercentageRollout(1000) // 10% of everyone else
opsCtx := featureflags.WithActor(ctx, featureflags.Ref{Type: "user", ID: "ops@example.com"})
if _, err := manager.Create(opsCtx, flag); err != nil {
log.Print(err) // already exists on the second run
}
// Evaluation: an in-memory snapshot, refreshed by push and polling.
listener, err := store.Listener(func(ctx context.Context) (*pgx.Conn, error) {
return pgx.Connect(ctx, url)
})
if err != nil {
log.Fatal(err)
}
client, err := featureflags.NewClient(store, featureflags.ClientOptions{Notifier: listener})
if err != nil {
log.Fatal(err)
}
if err := client.Refresh(ctx); err != nil {
log.Fatal(err)
}
runCtx, stop := context.WithCancel(ctx)
defer stop()
go func() { _ = client.Run(runCtx) }()
// Per request, usually in middleware after authentication:
reqCtx := featureflags.WithEvalContext(ctx, featureflags.EvalContext{
UserID: "user-1",
TenantID: "acme",
Attributes: map[string]any{"plan": "pro"},
})
r := client.Evaluate(reqCtx, "new-checkout")
// The last argument is what a missing flag means to this caller.
enabled := client.Enabled(reqCtx, "new-checkout", false)
log.Printf("enabled=%v variant=%s reason=%s", enabled, r.Variant.Name, r.Reason)
}Flag. A key (a-z, 0-9, ., _, -; up to 128 characters), a
description, an Enabled kill switch, named Variants with optional JSON
payloads, and a DefaultVariant. Boolean flags use the variants on and
off; Client.Enabled is true exactly when the result is on.
Evaluation order. For an enabled flag, targets and rules are checked in list order and the first match wins, so evaluation is deterministic:
| Step | Matches when | Reason |
|---|---|---|
Targets[i] |
the user ID or tenant ID is listed | target_match, Index = i |
Rules[i] |
every condition holds (and, for a distribution, the context has the bucketing key) | rule_match, Index = i |
Rollout |
the context has the bucketing key | rollout |
| default | always | default |
A disabled flag serves its default variant with reason disabled. An unknown
key gives flag_not_found and the zero Variant; Client.Enabled and
Client.Variant take the value to return in that case, so each caller decides
what a missing flag means.
Conditions. eq, neq, in, not_in, contains (substring, or slice
element) and the numeric lt, lte, gt, gte. Values are strings,
booleans or numbers:
- All Go numeric types compare as numbers (as
float64). Kinds never mix: the string"12"is not the number12, soeqandinfail andneqandnot_inhold. Ordering operators hold only for numeric attributes. - String comparisons,
containsincluded, are exact and case-sensitive. - A condition on an attribute the context does not carry, or carries as nil
or as an unusable value (a map, NaN), never holds, including
neqandnot_in, so missing data never opts someone in. - There is no regular expression operator.
Bucketing. A Distribution assigns weights in basis points (summing to
10000) to variants and buckets by user ID (default) or tenant ID. The bucket
of a context is part of the compatibility promise, because changing it would
move users between variants of running rollouts:
input = flagKey + 0x00 + salt + 0x00 + bucketingKey (raw UTF-8 bytes, no normalization)
digest = SHA-256(input)
bucket = uint64(big-endian digest[0:8]) mod 10000
The variant listed at position i serves buckets from the sum of the weights
before it (inclusive) to the sum including it (exclusive). So 0% serves
nobody, 100% serves everybody, and growing the first variant's share only adds
users: whoever was in at 10% is still in at 20%. The flag key is part of the
hash, so each flag samples its own users; change the Salt to reshuffle one
flag. Test vectors, pinned by TestBucketPinned and computed independently:
| flag key | salt | bucketing key | bucket |
|---|---|---|---|
new-checkout |
user-1 |
1479 | |
new-checkout |
user-2 |
8982 | |
new-checkout |
v2 |
user-1 |
9307 |
pricing.page |
tenant-42 |
8132 |
Validation. Validate reports every problem at once: malformed keys or
variant names, duplicate variants, invalid payload JSON, references to
unknown variants, malformed conditions, and weights that do not sum to 10000.
It also bounds the size of a definition, since definitions may come from an
admin API and every request evaluates them: a 4096-byte description, 100
variants with 64 KiB payloads, 100 targets holding 10000 IDs in total, 100
rules of up to 20 conditions, 10000 in/not_in values in total, and
1024-byte condition strings. A definition that passes Validate never makes
evaluation panic.
Safe defaults. Evaluation never touches the source, never blocks and never
panics. A failing refresh keeps the last good snapshot and reports through
ClientOptions.OnError. A stored definition that fails validation keeps the
previous version of that flag (or leaves a new flag out) while every other
flag still refreshes.
Hooks. ClientOptions.OnEvaluation receives every evaluation (for
exposure analytics); ManagerOptions.OnChange receives every create, update
and delete with the actor (for audit).
Context values. The evaluation context and the actor travel in the
context through WithEvalContext and WithActor. An application that
already keeps its user in the context supplies the lookups instead, once, at
startup; the hooks have the same signatures as EvalContextFrom and
ActorFrom:
featureflags.ClientOptions{
EvalContextFrom: func(ctx context.Context) (featureflags.EvalContext, bool) {
u, ok := myapp.UserFrom(ctx)
return featureflags.EvalContext{UserID: u.ID, TenantID: u.OrgID}, ok
},
}
featureflags.ManagerOptions{
ActorFrom: func(ctx context.Context) (featureflags.Ref, bool) {
u, ok := myapp.UserFrom(ctx)
return featureflags.Ref{Type: "user", ID: u.ID}, ok
},
}An actor is a Ref, a type and an ID, so a user and an API token with the
same ID never collide. A Ref with only one field set is rejected.
Errors. Every error wraps one of ErrNotFound, ErrInvalid or
ErrConflict (through ErrFlagNotFound, ErrInvalidFlag, ErrFlagExists
and ErrVersionConflict), so map them to HTTP statuses with errors.Is.
Config as code. ParseJSON and FileSource read {"flags": [...]}
documents of up to 32 MiB strictly (unknown fields are errors). A
FileSource can feed a Client directly, with polling picking up edits.
pgxstore stores one row per flag. Store.Schema() returns idempotent DDL;
run it with CreateSchema or copy it into a migration:
CREATE TABLE IF NOT EXISTS "feature_flags" (
key text PRIMARY KEY CHECK (key ~ '^[a-z0-9][a-z0-9._-]{0,127}$'),
definition jsonb NOT NULL CHECK (jsonb_typeof(definition) = 'object' AND definition->>'key' = key),
version bigint NOT NULL CHECK (version >= 1),
updated_at timestamptz NOT NULL
);Options: WithTable("schema.table"), WithChannel(name) (the NOTIFY channel,
default feature_flags), WithNow(clock) and WithConn.
Every write is a single conditional statement that also calls pg_notify. A
second writer holding the same version waits for the first one's row lock and
then fails with ErrVersionConflict, so concurrent updates never lose one
another, and a write inside a transaction notifies only on commit. To make
writes join a transaction you carry in the context, resolve the connection
per call:
type txKey struct{}
store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
return tx
}
return nil // use the pool
}))A ManagerOptions.OnChange hook can then write its audit row with
store.Querier(ctx) in the same transaction, and return an error to make the
caller roll back.
A row edited by hand so that its definition no longer decodes does not stop
the other flags: Load returns it without variants, so a running Client
keeps that flag's previous definition and reports it, and Get returns the
decode error.
store.Listener(connect) returns a featureflags.Notifier that holds one
dedicated connection running LISTEN. It bounds connecting by
WithConnectTimeout (10 seconds), pings an idle connection every
WithPingInterval (30 seconds), reconnects with jittered exponential backoff
(WithBackoff), reports through WithErrorHandler, and signals a refresh after
every reconnect so nothing missed while disconnected is lost. Keep polling
enabled as the safety net.
| Package | Contents |
|---|---|
featureflags |
Flag, Variant, Target, Rule, Condition, Distribution; Validate, Bucket; Snapshot; Client (Enabled, Variant, Evaluate, Refresh, Run); Manager; Store, Source, Notifier; ParseJSON, FileSource; Ref, WithEvalContext, WithActor |
memstore |
In-memory Store and Notifier (not transactional) |
pgxstore |
PostgreSQL Store and LISTEN/NOTIFY Listener |
storetest |
Run(t, newStore) conformance suite for Store implementations |
featureflagstest |
New(t, opts) returns overridable flags behind a real Client |
In application tests, use featureflagstest instead of a store:
flags := featureflagstest.New(t, featureflags.ClientOptions{})
flags.Enable("new-checkout")
flags.SetVariant("pricing-page", "annual-first", map[string]string{"highlight": "annual"})
handler := NewHandler(flags.Client())Changes apply synchronously. To verify your own Store, run
storetest.Run(t, newStore) from a test.
To test this module, see CONTRIBUTING.md; the PostgreSQL
tests need TEST_DATABASE_URL.
- Targeted refresh: reload only the flag named in a notification instead of the full set.
- Segments: reusable, named groups of conditions shared across flags.
- Semantic version and date comparison operators.
- Prerequisite flags (a flag that only applies when another resolves to a given variant).
- Syncing a config-as-code file into a
Storethrough theManager. - Flag history (previous versions) in
pgxstore.
This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.