Feature Flags in Go Services
Feature flags let Go teams deploy code to production while keeping new behavior off until validation completes - or flip a kill switch without rebuilding the binary.
Search across all documentation pages
Feature flags let Go teams deploy code to production while keeping new behavior off until validation completes - or flip a kill switch without rebuilding the binary.
A feature flag is a runtime decision: given a flag key and evaluation context (user, tenant, region), return enabled or disabled.
Go services evaluate flags in handlers, workers, and gRPC interceptors.
OpenFeature provides a vendor-neutral SDK; LaunchDarkly, Flagsmith, and in-house config services plug in as providers.
Flags complement canary deploys: canary shifts traffic between binaries; flags shift behavior within the same binary.
Quick-reference recipe card - copy-paste ready.
import (
"context"
"github.com/open-feature/go-sdk/openfeature"
)
func checkoutHandler(client *openfeature.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
enabled, _ := client.BooleanValue(r.Context(), "new-checkout", false, openfeature.EvaluationContext{})
if enabled {
newCheckout(w, r)
return
}
legacyCheckout(w, r)
}
}When to reach for this:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"sync"
"time"
)
// InMemoryProvider is a minimal flag backend for demos and tests.
type InMemoryProvider struct {
mu sync.RWMutex
flags map[string]bool
}
func NewInMemoryProvider() *InMemoryProvider {
return &InMemoryProvider{flags: map[string]bool{}}
}
func (p *InMemoryProvider) Set(key string, val bool) {
p.mu.Lock()
p.flags[key] = val
p.mu.Unlock()
}
func (p *InMemoryProvider) BooleanEvaluation(_ context.Context, flag string, defaultValue bool, _ map[string]interface{}) (bool, error) {
p.mu.RLock()
defer p.mu.RUnlock()
if v, ok := p.flags[flag]; ok {
return v, nil
}
return defaultValue, nil
}
type FlagClient struct {
provider *InMemoryProvider
}
func (c *FlagClient) Enabled(ctx context.Context, key string, def bool) bool {
val, err := c.provider.BooleanEvaluation(ctx, key, def, nil)
if err != nil {
slog.Warn("flag eval error", "key", key, "err", err)
return def
}
return val
}
func main() {
provider := NewInMemoryProvider()
// Default off - dark launch
provider.Set("new-pricing", false)
flags := &FlagClient{provider: provider}
mux := http.NewServeMux()
mux.HandleFunc("GET /price", func(w http.ResponseWriter, r *http.Request) {
if flags.Enabled(r.Context(), "new-pricing", false) {
w.Write([]byte(`{"amount":99,"engine":"v2"}`))
return
}
w.Write([]byte(`{"amount":100,"engine":"v1"}`))
})
mux.HandleFunc("POST /admin/flags/{key}", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Admin-Token") != os.Getenv("ADMIN_TOKEN") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
key := r.PathValue("key")
provider.Set(key, r.URL.Query().Get("value") == "true")
w.WriteHeader(http.StatusNoContent)
})
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
slog.Error("server stopped", "err", err)
os.Exit(1)
}
}What this demonstrates:
false for new flags - safe dark launch posture.BooleanValue / StringValue at decision points with an evaluation context (user ID, org, region).| Field | Purpose | Example |
|---|---|---|
targetingKey | Stable user/tenant ID | org_4821 |
region | Geo rollout | eu-west-1 |
tier | Plan-based features | enterprise |
Pass context from JWT claims or API keys - never evaluate flags without identity for user-facing behavior.
| Approach | Strength | Trade-off |
|---|---|---|
| OpenFeature | Swap providers without code churn | Extra abstraction layer |
| LaunchDarkly SDK | Rich targeting UI, experiments | Vendor lock-in |
| Env / ConfigMap | Zero dependency | No dynamic percentage rollouts |
// Evaluate once per request, pass decision down - avoid N+1 provider calls
func middleware(flags *FlagClient) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), pricingFlagKey,
flags.Enabled(r.Context(), "new-pricing", false))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}*openfeature.Client or your interface in main - test with a fake provider.context.Context.payments-v2. Fix: use vendor audit logs or GitOps for flag config.if flag branches become spaghetti. Fix: flag retirement tickets; delete dead paths after full rollout.| Alternative | Use When | Don't Use When |
|---|---|---|
| Feature flags | Toggle behavior without redeploy | Simple config that never changes at runtime |
| Canary deploy | Compare whole binary performance | Need per-tenant enablement |
| Config env vars | Rarely changed service settings | Percentage rollouts or kill switches |
API versioning (/v2) | Breaking contract changes | Small internal behavior tweaks |
Start with OpenFeature and the LaunchDarkly provider if you already pay for LaunchDarkly.
OpenFeature keeps migration paths open if you switch vendors.
Deploy canary with flag off for all users.
Enable flag for canary cohort only via targeting rules, then widen.
Separates binary health from feature logic risk.
Yes for small teams - mount JSON and watch with fsnotify.
No percentage targeting or audit UI - fine for kill switches only.
Inject a fake provider in tests.
Table-test both enabled and disabled branches; assert metrics and response bodies.
Yes - use job metadata (tenant ID) as evaluation context.
Cache flag state per job batch to limit provider calls.
Use StringValue or structured JSON flags for dynamic config (e.g. rate limit numbers).
Keep schema versioned to avoid parse panics - validate before use.
Target sub-millisecond with local cache.
On provider timeout, return safe default and increment a flag_eval_errors metric.
Track flag age and ownership.
Retire flags within 90 days of full rollout; hundreds of stale flags confuse on-call.
Use opaque targeting keys, not email in context fields.
Document flag evaluation in privacy notices if used for experiments.
Flagsmith, Unleash, and GO Feature Flag (github.com/thomaspoignant/go-feature-flag) are common.
Self-host when data residency rules block SaaS.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 18, 2026