Feature flags in Go services: what developers should know
Deploying new features directly to production carries risk. Feature flags in Go services give you the ability to decouple deployment from release, letting you control who sees what and when. We'll walk through the practical patterns and tools that production teams use to ship safely, from simple configuration-based approaches to more sophisticated providers like OpenFeature.
Start with config-based flags
The simplest feature flag implementation uses environment variables or configuration files. You define a flag, check it at runtime, and route traffic accordingly. This approach works well for small teams and straightforward use cases.
Consider a Go service that calculates shipping costs. You want to test a new algorithm before rolling it out to all users. A config-based flag lets you enable it for a subset of requests:
package shipping
import "os"
type ShippingService struct {
useNewAlgorithm bool
}
func NewShippingService() *ShippingService {
return &ShippingService{
useNewAlgorithm: os.Getenv("USE_NEW_SHIPPING_ALGO") == "true",
}
}
func (s *ShippingService) CalculateCost(origin, destination string, weight float64) float64 {
if s.useNewAlgorithm {
return s.calculateCostV2(origin, destination, weight)
}
return s.calculateCostV1(origin, destination, weight)
}
func (s *ShippingService) calculateCostV1(origin, destination string, weight float64) float64 {
// Legacy calculation
return weight * 2.5
}
func (s *ShippingService) calculateCostV2(origin, destination string, weight float64) float64 {
// New calculation with distance optimization
return weight * 2.0
}
This pattern is predictable and requires no external dependencies. The downside is that changes require redeployment. For a canary release where you need to gradually expose the flag to 5% of users, then 25%, then 100%, you'd need multiple deployments.
Use OpenFeature for providers
OpenFeature is a vendor-neutral specification for feature flag management. It abstracts flag evaluation behind a common interface, letting you swap providers—Unleash, LaunchDarkly, Split.io—without changing your application code.
Install the Go SDK and a provider. We'll use the file-based provider for simplicity, though production teams typically use a service provider:
package main
import (
"context"
"fmt"
"log"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk-contrib/providers/file-provider/pkg"
)
func main() {
provider, err := fileprovider.NewFileProvider("/etc/flags.json")
if err != nil {
log.Fatalf("Failed to create provider: %v", err)
}
openfeature.SetProvider(provider)
client := openfeature.NewClient("shipping")
ctx := context.Background()
// Evaluate a boolean flag
enabled, err := client.BooleanValue(ctx, "new_shipping_algo", false)
if err != nil {
log.Fatalf("Failed to evaluate flag: %v", err)
}
if enabled {
fmt.Println("Using new algorithm")
}
}
Your flags configuration file looks like:
{
"flags": {
"new_shipping_algo": {
"state": "ENABLED",
"variants": {
"on": true,
"off": false
},
"defaultVariant": "on"
}
}
}
OpenFeature separates flag definition from evaluation logic. You can change flags without touching code, and providers handle distribution across your infrastructure. Many providers also support real-time updates—your services get flag changes without restarting.
Evaluate flags with context
Context-aware evaluation is where feature flags become powerful. Instead of a simple on/off, you check properties like user ID, region, plan tier, or request metadata to decide whether a flag applies.
Imagine you're rolling out a premium shipping feature. You want it enabled for users on the Enterprise plan first, then Standard, then everyone:
package main
import (
"context"
"github.com/open-feature/go-sdk/openfeature"
)
type EvaluationContext struct {
UserID string
PlanTier string
Region string
BetaTester bool
}
func evaluateShippingFlag(ctx context.Context, client *openfeature.Client, user EvaluationContext) bool {
evalCtx := openfeature.NewEvaluationContext(user.UserID, map[string]interface{}{
"plan_tier": user.PlanTier,
"region": user.Region,
"beta_tester": user.BetaTester,
})
enabled, err := client.BooleanValue(ctx, "premium_shipping", false, openfeature.WithEvalContext(evalCtx))
if err != nil {
// Handle error, fall back to false
return false
}
return enabled
}
func main() {
// In your handler
user := EvaluationContext{
UserID: "user_456",
PlanTier: "Enterprise",
Region: "US-East",
}
ctx := context.Background()
client := openfeature.NewClient("shipping")
if evaluateShippingFlag(ctx, client, user) {
// Show premium shipping options
}
}
Your provider stores rules that interpret this context. A rule might say: "Enable premium_shipping for Enterprise tier users in US-East." Another might say: "Enable for 10% of Standard tier users." Context makes your rollout strategy dynamic and testable.
This approach also enables targeting: you can enable a flag for specific users (beta testers), gradual rollouts (percentage-based), or geographic phased releases without code changes.
Remove flags after rollout
Feature flags are technical debt. After a flag completes its lifecycle—you've shipped the feature, validated it, and rolled it out to 100% of users—delete it. Leaving dead flags clutters your codebase and creates mental overhead during debugging.
Set a removal date when you create a flag. Document which code paths are controlled by the flag. After the flag is fully rolled out and stable, delete the flag check and the legacy code path:
// Before: with flag
func (s *ShippingService) CalculateCost(origin, destination string, weight float64) float64 {
if s.useNewAlgorithm {
return s.calculateCostV2(origin, destination, weight)
}
return s.calculateCostV1(origin, destination, weight)
}
// After: flag removed, new logic is default
func (s *ShippingService) CalculateCost(origin, destination string, weight float64) float64 {
return s.calculateCostV2(origin, destination, weight)
}
// Delete calculateCostV1 entirely
Track flags in your team's backlog. Create a cleanup ticket when a flag reaches 100% adoption, ideally within 1–2 weeks. This discipline prevents flags from becoming permanent and keeps your production code clean.
Common mistakes
- Forgetting to remove old code paths. Dead code accumulates and makes branches harder to reason about. Flag removal should be as routine as flag creation.
- Using flags for permissions or security decisions. Flags control feature visibility, not access control. Use proper authentication and authorization for sensitive operations.
- Not testing flag combinations. If you have three flags, you have eight possible states. Test the combinations that matter: all off, all on, and critical mixed states.
- Ignoring performance in evaluation. Every request evaluates flags. If your provider is slow or unreachable, your API slows or fails. Use caching and fallback mechanisms.
- Conflating flags with configuration. Flags control features; configuration controls behavior (timeout values, database URLs). Keep them separate.
Key takeaways
- Start simple with config-based flags for small teams; graduate to OpenFeature when you need flexibility and multi-provider support.
- Use evaluation context to enable sophisticated targeting: gradual rollouts, user segments, regional phasing.
- Treat flag removal as a first-class task. Set expiration dates, document dependencies, and clean up after a flag completes its lifecycle.
- Cache flag evaluations and implement fallback behavior to avoid performance degradation when your flag provider is slow or down.
- Test your flag combinations, especially mixed states, to catch logic errors early.






Comments
Loading comments…