Go was designed to be simple. Its creators deliberately excluded features that introduce hidden complexity -- no inheritance hierarchies, no operator overloading, no implicit type conversions. The language rewards explicitness and punishes cleverness. Yet feature flags, by their very nature, inject conditional complexity into every function they touch. And in Go codebases, this contradiction creates a specific kind of technical debt that is both easier to detect and harder to ignore than in more permissive languages.
In our experience working with Go-heavy engineering teams, codebases over 100,000 lines routinely contain dozens of stale feature flags. Each stale flag adds unreachable code paths that the compiler cannot detect, because the branching is driven by runtime configuration rather than compile-time constants. In a language that prides itself on minimal dead code, that is a significant problem.
Why Go makes flag debt more visible -- and more painful
Go's design philosophy works both for and against you when it comes to feature flags.
The visibility advantage
Go code tends to be explicit. There are no decorators hiding behavior, no metaclass magic, no annotation processors running at compile time. When a Go function checks a feature flag, it does so with a plain if statement that anyone can read:
func HandleCheckout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if featureflags.IsEnabled(ctx, "new-checkout-flow") {
handleNewCheckout(ctx, w, r)
return
}
handleLegacyCheckout(ctx, w, r)
}
This is visible. It is obvious. Every engineer reading this function understands that two code paths exist and that a runtime flag determines which one executes. Compare this to a Python decorator or a Java annotation -- Go's flags hide nowhere.
The pain of accumulation
But this visibility becomes a liability when flags accumulate. Go's gofmt enforcer means every if/else block occupies vertical space. Go's error handling patterns mean each flag branch often has its own error paths. Go's explicit return statements mean stale flags create entire function bodies that will never execute.
Consider what a service handler looks like with three accumulated stale flags:
func ProcessOrder(ctx context.Context, order *Order) (*Receipt, error) {
client := getPaymentClient(ctx)
if featureflags.IsEnabled(ctx, "stripe-v2-api") {
// 40 lines of Stripe v2 handling
if featureflags.IsEnabled(ctx, "tax-calculation-service") {
// 25 lines of tax service integration
tax, err := taxService.Calculate(ctx, order)
if err != nil {
return nil, fmt.Errorf("tax calculation failed: %w", err)
}
order.Tax = tax
} else {
// 15 lines of legacy tax calculation
order.Tax = calculateLegacyTax(order)
}
if featureflags.IsEnabled(ctx, "async-receipt-generation") {
// 20 lines of async receipt logic
go generateReceiptAsync(ctx, order)
return &Receipt{Pending: true}, nil
}
return generateReceipt(ctx, order)
}
// 35 lines of legacy Stripe v1 handling that nobody should ever execute
return legacyProcessOrder(ctx, client, order)
}
This function has eight possible execution paths from three flags. If all three flags are permanently enabled, only one path is reachable -- but the function contains 135+ lines of dead code that Go's compiler will silently accept, test, and deploy.
Three stale flags turned a 30-line function into a 135-line labyrinth.
Common Go flag patterns and their cleanup challenges
Pattern 1: Direct SDK calls with if/else
The most common pattern in Go services:
func GetUserProfile(ctx context.Context, userID string) (*Profile, error) {
if featureflags.BoolVariation(ctx, "enhanced-profiles", false) {
return getEnhancedProfile(ctx, userID)
}
return getBasicProfile(ctx, userID)
}
Cleanup challenge: Straightforward -- remove the if/else, keep the enabled branch, delete the dead function. But you must verify that getBasicProfile is not called from anywhere else before deleting it.
After cleanup:
func GetUserProfile(ctx context.Context, userID string) (*Profile, error) {
return getEnhancedProfile(ctx, userID)
}
Pattern 2: Struct field toggles
Flags injected via configuration structs:
type ServiceConfig struct {
DatabaseURL string
CacheEnabled bool
UseNewSerializer bool // Feature flag
EnableBatchWrites bool // Feature flag
MaxRetries int
}
func NewOrderService(cfg ServiceConfig) *OrderService {
s := &OrderService{
db: connectDB(cfg.DatabaseURL),
cache: newCache(cfg.CacheEnabled),
}
if cfg.UseNewSerializer {
s.serializer = NewProtobufSerializer()
} else {
s.serializer = NewJSONSerializer()
}
if cfg.EnableBatchWrites {
s.writer = NewBatchWriter(cfg.MaxRetries)
} else {
s.writer = NewSingleWriter()
}
return s
}
Cleanup challenge: Struct fields are referenced in configuration loading, tests, and potentially in configuration files or environment variable parsing. Removing a struct field requires tracing every place the struct is constructed.
After cleanup (both flags permanently enabled):
type ServiceConfig struct {
DatabaseURL string
CacheEnabled bool
MaxRetries int
}
func NewOrderService(cfg ServiceConfig) *OrderService {
return &OrderService{
db: connectDB(cfg.DatabaseURL),
cache: newCache(cfg.CacheEnabled),
serializer: NewProtobufSerializer(),
writer: NewBatchWriter(cfg.MaxRetries),
}
}
Pattern 3: Middleware chains
Flags controlling HTTP middleware:
func SetupRouter(flags *featureflags.Client) *mux.Router {
r := mux.NewRouter()
// Always-on middleware
r.Use(loggingMiddleware)
r.Use(authMiddleware)
// Flag-controlled middleware
if flags.IsEnabled("rate-limiting-v2") {
r.Use(rateLimitV2Middleware)
} else {
r.Use(rateLimitV1Middleware)
}
if flags.IsEnabled("request-tracing") {
r.Use(tracingMiddleware)
}
if flags.IsEnabled("cors-relaxed") {
r.Use(relaxedCORSMiddleware)
} else {
r.Use(strictCORSMiddleware)
}
return r
}
Cleanup challenge: Middleware ordering matters. When removing a flag that toggles between two middleware implementations, you must ensure the remaining middleware is in the correct position in the chain. Integration tests are essential here.
Pattern 4: Interface-based flag abstraction
The most idiomatic Go pattern for feature flags, using interfaces to abstract the branching:
type PaymentProcessor interface {
ProcessPayment(ctx context.Context, amount Money) (*Transaction, error)
Refund(ctx context.Context, txID string) error
}
type StripeV1Processor struct { /* ... */ }
type StripeV2Processor struct { /* ... */ }
func NewPaymentProcessor(flags *featureflags.Client) PaymentProcessor {
if flags.IsEnabled("stripe-v2") {
return &StripeV2Processor{}
}
return &StripeV1Processor{}
}
Cleanup challenge: This is actually the cleanest pattern to clean up. Remove the factory function's conditional, delete the unused implementation, and the interface ensures all callers continue to work. If you have good test coverage against the interface, the transition is safe.
After cleanup:
func NewPaymentProcessor() PaymentProcessor {
return &StripeV2Processor{}
}
You can then evaluate whether the interface itself is still needed or whether the code can be simplified further to call StripeV2Processor directly.
Detection strategies for Go codebases
Static analysis with go vet and custom analyzers
Go's analysis package provides a framework for building custom static analyzers that integrate with go vet. A flag detector can walk the AST to find flag SDK calls:
package staleflagcheck
import (
"go/ast"
"go/token"
"golang.org/x/tools/go/analysis"
)
var Analyzer = &analysis.Analyzer{
Name: "staleflagcheck",
Doc: "reports feature flag references that may be stale",
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Check for featureflags.IsEnabled or BoolVariation calls
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
if ident.Name == "featureflags" &&
(sel.Sel.Name == "IsEnabled" || sel.Sel.Name == "BoolVariation") {
if len(call.Args) > 0 {
if lit, ok := call.Args[0].(*ast.BasicLit); ok &&
lit.Kind == token.STRING {
flagKey := lit.Value
// Check against known stale flags
if isStale(flagKey) {
pass.Reportf(call.Pos(),
"flag %s is stale and should be removed", flagKey)
}
}
}
}
return true
})
}
return nil, nil
}
This analyzer can be integrated into CI pipelines to fail builds when stale flags are detected.
Tree-sitter for Go grammar analysis
Tree-sitter provides an alternative to Go's native AST tooling that works across languages. For organizations managing flags in Go alongside TypeScript, Python, or other languages, tree-sitter offers a unified detection approach.
Tree-sitter's Go grammar can identify:
- Function calls matching flag SDK patterns
- If/else blocks conditioned on flag results
- Variable assignments from flag evaluation
- Struct field initialization with flag-derived values
// Tree-sitter query for Go flag detection (S-expression syntax)
(call_expression
function: (selector_expression
operand: (identifier) @package
field: (field_identifier) @method)
arguments: (argument_list
(interpreted_string_literal) @flag_key)
(#eq? @package "featureflags")
(#any-of? @method "IsEnabled" "BoolVariation" "StringVariation"))
This is the detection approach that FlagShark uses to scan Go codebases. The tree-sitter grammar handles Go's syntax precisely -- including method calls on imported packages, string literal extraction, and nested call expressions -- without requiring the code to compile. This matters for large monorepos where a developer might be working on a branch that does not yet compile cleanly.
Finding flag propagation with go/callgraph
For deep analysis, Go's golang.org/x/tools/go/callgraph package can trace how flag decisions propagate through call chains:
// Trace all functions reachable from a flag-gated branch
func traceFlagImpact(program *ssa.Program, flagFunc *ssa.Function) []string {
cg := static.CallGraph(program)
var impacted []string
visited := make(map[*ssa.Function]bool)
var walk func(fn *ssa.Function)
walk = func(fn *ssa.Function) {
if visited[fn] {
return
}
visited[fn] = true
impacted = append(impacted, fn.String())
node := cg.Nodes[fn]
if node == nil {
return
}
for _, edge := range node.Out {
walk(edge.Callee.Func)
}
}
walk(flagFunc)
return impacted
}
This reveals the full blast radius of a flag -- every function that is only reachable through a specific flag branch becomes a candidate for removal when that flag is cleaned up.
Safe removal techniques
The golden rule: keep the winning branch
When removing a stale flag from Go code, the process is deterministic:
- Determine which branch represents the permanently-enabled state
- Replace the entire
if/elseblock with the contents of that branch - Delete the dead branch's functions, types, and test code
- Verify with
go build,go vet, and your test suite
Before:
func SerializeResponse(ctx context.Context, data interface{}) ([]byte, error) {
if featureflags.IsEnabled(ctx, "protobuf-responses") {
return proto.Marshal(toProtoMessage(data))
}
return json.Marshal(data)
}
After (flag was permanently enabled):
func SerializeResponse(ctx context.Context, data interface{}) ([]byte, error) {
return proto.Marshal(toProtoMessage(data))
}
Note that ctx is still needed for other purposes in most real functions, but if ctx was only present to pass to the flag check, it should be removed from the function signature -- and all callers updated accordingly. Go's compiler will tell you if a variable is unused.
Handling error path differences
A common pitfall in Go flag cleanup is that different flag branches often have different error handling:
func FetchUserData(ctx context.Context, id string) (*User, error) {
if featureflags.IsEnabled(ctx, "graphql-backend") {
user, err := graphqlClient.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("graphql fetch failed for user %s: %w", id, err)
}
return mapGraphQLUser(user), nil
}
// Legacy REST path
resp, err := httpClient.Get(fmt.Sprintf("/api/users/%s", id))
if err != nil {
return nil, fmt.Errorf("REST fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var user restUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode failed: %w", err)
}
return mapRESTUser(&user), nil
}
When cleaning this up, do not accidentally preserve error messages that reference the wrong backend. The "graphql fetch failed" error string is correct for the winning path. The "REST fetch failed" and "decode failed" error strings should be removed entirely.
Removing flag dependencies from tests
Go test files are where flag cleanup often gets messy. Tests tend to set up flag state in table-driven test patterns:
Before:
func TestProcessOrder(t *testing.T) {
tests := []struct {
name string
flags map[string]bool
order Order
wantErr bool
wantReceipt Receipt
}{
{
name: "new flow enabled",
flags: map[string]bool{"new-order-flow": true},
order: Order{Amount: 100},
wantReceipt: Receipt{Total: 100, Method: "stripe-v2"},
},
{
name: "legacy flow",
flags: map[string]bool{"new-order-flow": false},
order: Order{Amount: 100},
wantReceipt: Receipt{Total: 100, Method: "stripe-v1"},
},
{
name: "new flow with invalid order",
flags: map[string]bool{"new-order-flow": true},
order: Order{Amount: -1},
wantErr: true,
},
{
name: "legacy flow with invalid order",
flags: map[string]bool{"new-order-flow": false},
order: Order{Amount: -1},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := featureflags.WithFlags(context.Background(), tt.flags)
receipt, err := ProcessOrder(ctx, &tt.order)
// assertions...
})
}
}
After:
func TestProcessOrder(t *testing.T) {
tests := []struct {
name string
order Order
wantErr bool
wantReceipt Receipt
}{
{
name: "valid order",
order: Order{Amount: 100},
wantReceipt: Receipt{Total: 100, Method: "stripe-v2"},
},
{
name: "invalid order",
order: Order{Amount: -1},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
receipt, err := ProcessOrder(context.Background(), &tt.order)
// assertions...
})
}
}
The test count dropped from four to two. The flag configuration was removed from the test struct. The context setup no longer needs the flag injection helper. This is a real productivity gain -- fewer tests to maintain, faster test execution, and clearer test intent.
Measuring flag debt in Go
Track these metrics to understand the scope of flag debt in your Go codebase:
| Metric | How to Measure | Healthy Threshold |
|---|---|---|
| Total flag references | grep -r "featureflags\." --include="*.go" | wc -l | < 50 per 100k LOC |
| Stale flag percentage | Cross-reference with flag service | < 20% |
| Average flag age | Flag service metadata | < 45 days |
| Lines behind stale flags | Custom AST analysis | < 5% of total |
| Flag-conditional test cases | Test struct analysis | < 15% of total tests |
| Functions with 2+ flag checks | AST analysis | < 5% of functions |
The cost of flag debt in Go services
Go services tend to be smaller and more focused than monolithic applications, but flag debt still accumulates. The impact grows with team size -- more engineers means more people navigating dead code paths, more time spent in code reviews reasoning about stale conditionals, and more test maintenance for branches that will never execute. Even a modest number of stale flags can meaningfully slow down a Go team when every developer encounters them daily.
Building a Go flag cleanup pipeline
Step 1: Inventory
Run a codebase scan to find every flag reference:
# Find all flag SDK calls
rg "featureflags\.(IsEnabled|BoolVariation|StringVariation|IntVariation)" \
--type go -c
# List unique flag keys
rg 'featureflags\.\w+\(\w+,\s*"([^"]+)"' --type go -o -r '$1' | sort -u
# Count references per flag
rg 'featureflags\.\w+\(\w+,\s*"([^"]+)"' --type go -o -r '$1' | sort | uniq -c | sort -rn
Step 2: Cross-reference with flag service
Compare the in-code flags against your flag management service to identify which flags are:
- 100% enabled for 30+ days -- candidates for immediate cleanup
- 100% disabled for 30+ days -- candidates for dead code removal
- Partially rolled out -- leave these alone
- Not found in the service -- orphaned references, highest priority for cleanup
Step 3: Prioritize by impact
Rank cleanup candidates by the number of code references and the amount of dead code they control:
type FlagCleanupCandidate struct {
Key string
References int
DeadCodeLines int
AffectedFiles int
DaysSinceStale int
Priority string // "critical", "high", "medium", "low"
}
Step 4: Automate with CI
Add a flag hygiene check to your CI pipeline:
# .github/workflows/flag-check.yml
name: Flag Hygiene
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Run stale flag check
run: go vet -vettool=$(which staleflagcheck) ./...
- name: Check flag count
run: |
count=$(rg "featureflags\." --type go -c | awk -F: '{sum+=$2} END{print sum}')
if [ "$count" -gt 100 ]; then
echo "::warning::Flag reference count ($count) exceeds threshold (100)"
fi
For teams that want this detection handled automatically across every PR, FlagShark monitors Go codebases for flag additions and removals, tracking each flag from the PR that introduces it through to the cleanup PR that removes it. It uses tree-sitter's Go grammar to parse flag patterns without requiring the code to compile, and generates cleanup PRs when flags become stale.
Go-specific pitfalls to avoid
1. Removing a flag but keeping its error wrapping. If the winning branch wraps errors with fmt.Errorf("new-flow: %w", err), make sure the "new-flow" prefix still makes sense as permanent error context. Consider simplifying error messages when the "new" path becomes the only path.
2. Forgetting to update go generate outputs. If your flag service generates Go code (mock clients, flag key constants), removing a flag from the service may require re-running go generate and committing the updated output.
3. Leaving flag SDK imports in files with no remaining flag calls. Go's compiler catches unused imports, but if you only remove some flag calls from a file, the import stays. Run goimports after cleanup.
4. Breaking wire/dependency injection graphs. If you use wire, fx, or manual dependency injection and a flag controls which implementation is provided, removing the flag means updating the DI configuration. Test your wire_gen.go or fx options after cleanup.
5. Ignoring _test.go files. Test helpers that set up flag state (featureflags.WithFlags(ctx, ...)) will compile and run even after the production flag is removed, silently wasting CI time. Always include test files in your cleanup scope.
Go's explicitness makes feature flag debt more visible than in most languages, but visibility alone does not produce action. The teams that maintain clean Go codebases treat flag cleanup as a regular engineering activity -- not a quarterly event, but a continuous part of the development cycle. Build detection into your CI pipeline, track metrics on flag age and dead code, and remove stale flags while the context is still fresh. In a language designed for simplicity, there is no excuse for carrying code that will never execute.