How to Manage Feature Flags in Golang
I’ve been shipping Go services for a while now, and one pattern that keeps saving me is feature flags. The idea is dead simple: you deploy code to production, but you don’t show it to users until you’re ready. You flip a switch — maybe for 5% of traffic, maybe for a specific user, maybe for everyone at once. If something breaks, you flip it back off. No rollback, no hotfix, no 3am panic deploy.
In this post I’ll walk through how I manage feature flags in Go using a library called Flaggo that I built for exactly this purpose.
Why Bother with Feature Flags?
Here’s the thing — without feature flags, deploying and releasing are the same event. Every git push to main that triggers your CI/CD pipeline is also the moment users see your changes. That’s fine for small fixes, but it gets scary fast when you’re shipping something big.
With feature flags in place, you can:
- Roll out gradually — start with 5% of users, watch your metrics, bump to 20%, then 100%
- Kill broken things instantly — no need to revert commits or push a hotfix, just toggle the flag off
- Run A/B tests — show different experiences to different user segments and measure what works
- Keep merging to main — incomplete features sit behind a flag, so trunk-based development actually works
IMPORTANTOne thing I want to be clear about: feature flags don’t replace testing. They’re a release mechanism. You still need to write tests, run them in CI, and catch bugs before they hit production. Flags just give you a safety net for the stuff that slips through.
Flaggo — The Library I Built for This
I wanted something that handles actor targeting, percentage rollouts, and has a built-in dashboard — but doesn’t require me to run a separate service or pay for a SaaS platform. So I built Flaggo.
Here’s what it gives you out of the box:
- Actor-based targeting — enable flags for specific users, regions, or whatever attribute you care about
- Percentage rollouts with deterministic hashing (same user always gets the same result)
- Pluggable storage — JSON file for simple setups, Redis when you need shared state across instances
- A web dashboard with light/dark theme and search
- Optional HTTP Basic Auth to lock down the dashboard
- Thread-safe, so you don’t have to worry about concurrent access
Installation
go get github.com/miqdadyyy/flaggoBasic Setup with File Provider
The simplest way to get started is with the file provider. It stores your flag configs in a JSON file and handles reads/writes atomically.
package main
import ( "context" "log" "net/http"
"github.com/miqdadyyy/flaggo" "github.com/miqdadyyy/flaggo/providers/fileprovider" "github.com/miqdadyyy/flaggo/web")
func main() { // Initialize file-based storage fp, err := fileprovider.New(fileprovider.Options{ Path: "storage/flags.json", }) if err != nil { log.Fatal(err) }
// Create flaggo instance ff := flaggo.New(fp)
// Pre-populate flags (creates disabled flags if they don't exist) ctx := context.Background() ff.Populate(ctx, []string{"new-checkout", "dark-mode", "beta-api"})
// Mount the dashboard http.Handle("/flags", web.Handler(ff)) http.HandleFunc("/checkout", checkoutHandler(ff))
log.Println("Server running on http://localhost:3000") log.Fatal(http.ListenAndServe(":3000", nil))}
func checkoutHandler(ff *flaggo.Flaggo) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context()
if ff.IsEnabled(ctx, "new-checkout") { renderNewCheckout(w, r) } else { renderOldCheckout(w, r) } }}Redis Provider
If you’re running multiple instances of your service (and you probably are), you’ll want flags to be consistent across all of them. That’s where the Redis provider comes in:
package main
import ( "log"
"github.com/miqdadyyy/flaggo" "github.com/miqdadyyy/flaggo/providers/redisprovider")
func main() { rp, err := redisprovider.New(redisprovider.Options{ Addr: "redis://localhost:6379/0", Prefix: "flaggo:", }) if err != nil { log.Fatal(err) }
ff := flaggo.New(rp) // Use ff across your application...}Actor-Based Targeting
This is where things get interesting. You can attach attributes to a request context — user ID, session ID, country, plan type, whatever — and then target flags at specific values of those attributes.
package main
import ( "context" "net/http"
"github.com/miqdadyyy/flaggo")
func handler(ff *flaggo.Flaggo) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Attach actor to context actor := flaggo.Actor{ Attributes: map[string]string{ "user_id": getUserID(r), "session_id": getSessionID(r), "country": getCountry(r), }, } ctx := flaggo.WithActor(r.Context(), actor)
if ff.IsEnabled(ctx, "new-dashboard") { renderNewDashboard(w, r) } else { renderOldDashboard(w, r) } }}How Flag Evaluation Works
When you call IsEnabled, Flaggo checks things in this order:
- If the flag doesn’t exist or
Enabledis false → false - If the actor matches any entry in
Actors→ true (targeted users always get the feature) - If
hash(flagKey + sessionID) % 100 < Rollout→ true (percentage-based) - Otherwise → false
You can configure flags programmatically or through the dashboard:
// Full configuration with targeting + rolloutff.SetConfig(ctx, "new-checkout", flaggo.FlagConfig{ Enabled: true, Rollout: 50, // 50% of sessions Actors: map[string][]string{ "user_id": {"alice", "bob"}, // Always enabled for these users },})
// Simple enable/disable/toggleff.Enable(ctx, "dark-mode")ff.Disable(ctx, "maintenance-mode")ff.Toggle(ctx, "beta-api")And here’s what the underlying JSON looks like:
{ "new-checkout": { "enabled": true, "actors": { "user_id": ["alice", "bob"] }, "rollout": 50 }, "dark-mode": { "enabled": true, "rollout": 100 }}The Web Dashboard
One of my favorite parts — Flaggo ships with a dashboard you can mount on any route. No separate frontend app to deploy, no React build step. Just one line:
import "github.com/miqdadyyy/flaggo/web"
// Public dashboardhttp.Handle("/flags", web.Handler(ff))From the dashboard you can toggle flags, adjust rollout percentages, manage actor targeting, and search through your flags. It supports light and dark themes too.
Locking Down the Dashboard
In production you probably don’t want the dashboard open to the world. Add basic auth:
ff := flaggo.New(provider, flaggo.Config{ Username: "admin", Password: "secret",})REST API
Here’s a nice bonus — the same /flags endpoint also serves JSON when you hit it with Accept: application/json. So you can script flag changes or integrate with your CI/CD:
| Method | Query | What it does |
|---|---|---|
| GET | List all flags | |
| GET | ?key=name | Get one specific flag |
| POST | Create or update a flag | |
| DELETE | Disable a flag | |
| PATCH | Toggle a flag |
# List all flagscurl -H "Accept: application/json" http://localhost:3000/flags
# Create/update a flagcurl -X POST -H "Accept: application/json" \ -d '{"key":"new-feature","enabled":true,"rollout":25}' \ http://localhost:3000/flags
# Toggle a flagcurl -X PATCH -H "Accept: application/json" \ -d '{"key":"new-feature"}' \ http://localhost:3000/flagsFiber Middleware Pattern
If you’re using GoFiber (and I use it a lot), here’s a middleware that attaches actor context from request headers so your handlers don’t have to deal with it:
package middleware
import ( "github.com/gofiber/fiber/v2" "github.com/miqdadyyy/flaggo")
// WithFeatureFlags attaches actor context from request headers/sessionfunc WithFeatureFlags(ff *flaggo.Flaggo) fiber.Handler { return func(c *fiber.Ctx) error { actor := flaggo.Actor{ Attributes: map[string]string{ "user_id": c.Get("X-User-ID"), "session_id": c.Get("X-Session-ID"), }, } ctx := flaggo.WithActor(c.UserContext(), actor) c.SetUserContext(ctx) return c.Next() }}Then in your routes:
func main() { app := fiber.New() app.Use(middleware.WithFeatureFlags(ff))
app.Get("/checkout", func(c *fiber.Ctx) error { if ff.IsEnabled(c.UserContext(), "new-checkout") { return renderNewCheckout(c) } return renderOldCheckout(c) })
app.Listen(":8080")}TIPPutting the actor setup in middleware means your handlers stay clean. They just call
IsEnabledand don’t need to know where the user context came from. It also guarantees consistent evaluation within a single request.
Testing with Feature Flags
One thing I’ve learned the hard way: always test both paths. If you only test the happy path with the flag on, you’ll find out the hard way that the flag-off path is broken when you need to kill the feature at 2am.
Flaggo’s Provider interface makes this straightforward — just use the file provider pointed at a temp directory:
package handler
import ( "context" "net/http" "net/http/httptest" "testing"
"github.com/miqdadyyy/flaggo" "github.com/miqdadyyy/flaggo/providers/fileprovider")
func setupTestFlags(t *testing.T, flags map[string]flaggo.FlagConfig) *flaggo.Flaggo { t.Helper()
// Use a temp file for test isolation fp, err := fileprovider.New(fileprovider.Options{ Path: t.TempDir() + "/flags.json", }) if err != nil { t.Fatal(err) }
ff := flaggo.New(fp) ctx := context.Background()
for key, cfg := range flags { if err := ff.SetConfig(ctx, key, cfg); err != nil { t.Fatal(err) } }
return ff}
func TestCheckoutHandler(t *testing.T) { tests := []struct { name string flagValue bool wantCode int }{ {"new checkout enabled", true, http.StatusOK}, {"new checkout disabled", false, http.StatusOK}, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ff := setupTestFlags(t, map[string]flaggo.FlagConfig{ "new-checkout": { Enabled: tt.flagValue, Rollout: 100, }, })
handler := checkoutHandler(ff) req := httptest.NewRequest("GET", "/checkout", nil) w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.wantCode { t.Errorf("got status %d, want %d", w.Code, tt.wantCode) } }) }}Flag Lifecycle — Don’t Let Them Rot
This is the part most people skip, and it always bites them later. Feature flags are meant to be temporary. If you leave them in your codebase after they’ve served their purpose, they become dead code that nobody wants to touch.
Naming Convention
I use structured names so it’s obvious what a flag is for just by reading it: {type}-{team}-{feature}
| Type | Example | How long it should live |
|---|---|---|
release | release-payments-new-checkout | 30 days |
experiment | experiment-growth-button-color | 60 days |
ops | ops-platform-circuit-breaker | 90 days |
permission | permission-enterprise-sso | No expiry |
Cleaning Up
WARNINGI’ve seen codebases with 200+ feature flags where nobody knows which ones are still needed. Don’t let that happen to you. If a flag has been at 100% rollout for more than 30 days, it’s time to remove it.
Every flag I create gets three things from day one:
- An owner — someone who’s on the hook for removing it
- An expiration date — when it should be gone
- A cleanup plan — which files to touch when removing the flag
Flaggo’s dashboard makes it easy to spot flags that have been sitting at 100% for weeks. Check it regularly and clean up after yourself.