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.
Summary
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.
Recipe
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:
- Dark-launching features during canary or blue-green deploys
- Gradual percentage rollouts to enterprise tenants
- Kill switches for risky integrations without emergency redeploy
- A/B experiments on API behavior or response shape
- Ops-controlled toggles (maintenance mode, read-only mode)
Working Example
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:
- Default
falsefor new flags - safe dark launch posture. - Flag evaluation errors fall back to default (fail-safe to legacy path).
- Admin endpoint guarded by token for controlled enablement (production uses vendor UI or GitOps).
- Same binary serves both code paths; toggling does not require redeploy.
Deep Dive
How It Works
- Provider fetches flag definitions from LaunchDarkly, Flagsmith, S3 JSON, or etcd.
- SDK caches evaluations and streams updates (SSE or polling).
- Application calls
BooleanValue/StringValueat decision points with an evaluation context (user ID, org, region). - Observability logs flag key and variant in structured fields for incident correlation.
Evaluation Context
| 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.
OpenFeature vs Vendor SDK
| 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 |
Go Notes
// 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))
})
}
}- Inject
*openfeature.Clientor your interface inmain- test with a fake provider. - For gRPC, evaluate in unary interceptors and attach to
context.Context.
Gotchas
- Default true for new flags - a provider outage enables risky code for everyone. Fix: default false; explicit opt-in per environment.
- Flag checks in hot loops - calling the provider on every DB row. Fix: evaluate once per request or cache with TTL respecting stream updates.
- Inconsistent flag state across pods - stale cache after kill switch. Fix: use provider streaming; set short max cache TTL for critical flags.
- No audit trail - nobody knows who enabled
payments-v2. Fix: use vendor audit logs or GitOps for flag config. - Permanent flags -
if flagbranches become spaghetti. Fix: flag retirement tickets; delete dead paths after full rollout. - Sensitive flags in logs - logging full evaluation context leaks PII. Fix: log flag key and result only.
Alternatives
| 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 |
FAQs
LaunchDarkly or OpenFeature for a new Go service?
Start with OpenFeature and the LaunchDarkly provider if you already pay for LaunchDarkly.
OpenFeature keeps migration paths open if you switch vendors.
How do flags interact with canary deploys?
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.
Can I use Kubernetes ConfigMaps as a flag store?
Yes for small teams - mount JSON and watch with fsnotify.
No percentage targeting or audit UI - fine for kill switches only.
How to test flag paths in Go?
Inject a fake provider in tests.
Table-test both enabled and disabled branches; assert metrics and response bodies.
Should workers evaluate flags?
Yes - use job metadata (tenant ID) as evaluation context.
Cache flag state per job batch to limit provider calls.
What about string or JSON flag payloads?
Use StringValue or structured JSON flags for dynamic config (e.g. rate limit numbers).
Keep schema versioned to avoid parse panics - validate before use.
Flag evaluation latency budget?
Target sub-millisecond with local cache.
On provider timeout, return safe default and increment a flag_eval_errors metric.
How many flags is too many?
Track flag age and ownership.
Retire flags within 90 days of full rollout; hundreds of stale flags confuse on-call.
GDPR and flag targeting?
Use opaque targeting keys, not email in context fields.
Document flag evaluation in privacy notices if used for experiments.
Open-source alternatives to LaunchDarkly?
Flagsmith, Unleash, and GO Feature Flag (github.com/thomaspoignant/go-feature-flag) are common.
Self-host when data residency rules block SaaS.
Related
- Enterprise Delivery Basics - env-backed flag example
- Shipping Go Services Safely at Enterprise Scale - flags in the delivery chain
- Release Strategy: Blue-Green & Canary for Go - traffic shifting
- Progressive Delivery & Automated Verification - verify before enabling flags
- Rollback Runbooks for Go Deployments - disable flags during incidents
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).