← Back to Blog
feature flagsgogolangtutorial

Feature Flags in Go: A Practical Guide with Examples

Rollgate Team··14 min read
Feature Flags in Go: A Practical Guide with Examples

Why Feature Flags in Go?

Go is the language of choice for microservices, API backends, CLI tools, and infrastructure software. Teams shipping Go code in production face the same challenge: how do you release features safely without redeploying your entire service?

Feature flags in Go solve this by decoupling deployment from release. You deploy code to production behind a flag, then control who sees it — without touching your CI/CD pipeline or restarting your binary.

Here is why feature flags are particularly useful in Go applications:

  • Microservices: Roll out a new endpoint or algorithm to 5% of traffic, monitor error rates, then gradually increase. If something breaks, disable the flag instantly — no redeployment across your fleet.
  • API backends: Ship a v2 of your API handler behind a flag. Route beta users to v2 while everyone else stays on v1. Migrate at your own pace.
  • CLI tools: Enable experimental commands for internal testers before exposing them to all users.
  • Infrastructure: Toggle between database backends, caching strategies, or queue implementations without code changes.

If you are not familiar with the concept yet, check out our complete guide to feature flags for the fundamentals.

Quick Start: Feature Flags in Go

Let's get a feature flag running in Go in under two minutes. Install the Rollgate Go SDK:

go get github.com/rollgate/sdk-go

Then use it in your application:

package main

import (
	"context"
	"fmt"
	"log"

	rollgate "github.com/rollgate/sdk-go"
)

func main() {
	client, err := rollgate.NewClient(rollgate.Config{
		APIKey: "your-api-key",
	})
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	if err := client.Init(context.Background()); err != nil {
		log.Fatal(err)
	}

	if client.IsEnabled("new-pricing-page", false) {
		fmt.Println("Showing new pricing page")
	} else {
		fmt.Println("Showing old pricing page")
	}
}

That is the entire setup. The SDK fetches flags from Rollgate's API, caches them locally, and keeps them updated via background polling. The second argument to IsEnabled is the default value — what to return if the flag does not exist or the client is not ready.

The DIY Approach

Before reaching for a feature flag service, you might try building your own. Here is the simplest version using a config file:

package featureflags

import (
	"encoding/json"
	"os"
	"sync"
)

type Flags struct {
	mu    sync.RWMutex
	flags map[string]bool
}

func New(path string) (*Flags, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var flags map[string]bool
	if err := json.Unmarshal(data, &flags); err != nil {
		return nil, err
	}

	return &Flags{flags: flags}, nil
}

func (f *Flags) IsEnabled(key string) bool {
	f.mu.RLock()
	defer f.mu.RUnlock()
	return f.flags[key]
}

With a flags.json file:

{
  "new-search": true,
  "dark-mode": false,
  "v2-api": true
}

This works for prototypes. But it hits a wall quickly:

  • No real-time updates: Changing a flag requires editing the file and restarting the process. In a fleet of 20 microservices, that means 20 restarts.
  • No targeting: You cannot enable a flag for specific users, segments, or percentages. It is all-or-nothing.
  • No audit trail: Who changed what, when? Config files do not track that.
  • No gradual rollouts: You cannot roll out to 5% of users, then 25%, then 100%.
  • No kill switch: When production is on fire, you want to disable a feature in seconds, not minutes.

For anything beyond a side project, you need a proper feature flag system. Let's look at how the Rollgate Go SDK handles all of these cases.

Using the Rollgate Go SDK

The Rollgate Go SDK is designed for Go idioms: context.Context for cancellation, interfaces for testability, functional options for flexibility, and proper error handling.

Full Setup

package main

import (
	"context"
	"log"
	"os"
	"time"

	rollgate "github.com/rollgate/sdk-go"
)

func main() {
	client, err := rollgate.NewClient(rollgate.Config{
		APIKey:          os.Getenv("ROLLGATE_API_KEY"),
		BaseURL:         "https://api.rollgate.io",
		Timeout:         5 * time.Second,
		RefreshInterval: 30 * time.Second,
		EnableStreaming:  true, // Real-time updates via SSE
		Cache: rollgate.CacheConfig{
			TTL:      5 * time.Minute,
			StaleTTL: 1 * time.Hour,
			Enabled:  true,
		},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	ctx := context.Background()
	if err := client.Init(ctx); err != nil {
		log.Fatal(err)
	}

	// Identify a user for targeted evaluations
	err = client.Identify(ctx, &rollgate.UserContext{
		ID:    "user-123",
		Email: "[email protected]",
		Attributes: map[string]any{
			"plan":    "pro",
			"country": "DE",
		},
	})
	if err != nil {
		log.Printf("identify failed: %v", err)
	}

	// Check a flag
	if client.IsEnabled("new-dashboard", false) {
		showNewDashboard()
	}
}

Per-Evaluation User Context

Sometimes you need to evaluate a flag for a different user without changing the client-level identity. The SDK supports functional options for this:

// Evaluate for a specific user without changing client state
enabled := client.IsEnabled("beta-feature", false,
	rollgate.WithUser("user-456"),
	rollgate.WithAttributes(map[string]any{
		"plan": "enterprise",
	}),
)

Evaluation Details

When you need to know why a flag returned a specific value (for debugging or logging), use IsEnabledDetail:

detail := client.IsEnabledDetail("checkout-v2", false)
log.Printf("flag=%s value=%v reason=%s",
	"checkout-v2", detail.Value, detail.Reason.Kind)

This returns the evaluation reason: whether the flag matched a targeting rule, a percentage rollout, or fell through to the default value.

Feature Flags in HTTP Handlers

Go's HTTP ecosystem (Chi, Gin, standard net/http) makes it easy to integrate feature flags at the handler level. Since Rollgate's own API is built with Chi, here are examples for all three.

Chi Router (Recommended)

package main

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	rollgate "github.com/rollgate/sdk-go"
)

func main() {
	r := chi.NewRouter()

	// Route based on feature flag
	r.Get("/api/search", func(w http.ResponseWriter, r *http.Request) {
		userID := r.Context().Value("userID").(string)

		if flagClient.IsEnabled("search-v2", false,
			rollgate.WithUser(userID),
		) {
			searchV2Handler(w, r)
		} else {
			searchV1Handler(w, r)
		}
	})

	http.ListenAndServe(":8080", r)
}

Standard net/http

func featureFlagHandler(w http.ResponseWriter, r *http.Request) {
	userID := getUserID(r)

	if flagClient.IsEnabled("new-endpoint", false,
		rollgate.WithUser(userID),
	) {
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(newResponse())
	} else {
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(legacyResponse())
	}
}

Feature Flags in gRPC Services

For gRPC services, feature flags work naturally as unary interceptors:

package main

import (
	"context"

	rollgate "github.com/rollgate/sdk-go"
	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
)

func featureFlagInterceptor(flagClient *rollgate.Client) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req interface{},
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (interface{}, error) {
		// Extract user ID from gRPC metadata
		var userID string
		if md, ok := metadata.FromIncomingContext(ctx); ok {
			if values := md.Get("x-user-id"); len(values) > 0 {
				userID = values[0]
			}
		}

		// Inject flag values into context
		flags := map[string]bool{
			"search-v2":    flagClient.IsEnabled("search-v2", false, rollgate.WithUser(userID)),
			"new-ranking":  flagClient.IsEnabled("new-ranking", false, rollgate.WithUser(userID)),
		}
		ctx = context.WithValue(ctx, flagsContextKey, flags)

		return handler(ctx, req)
	}
}

// In your gRPC service implementation
func (s *SearchService) Search(ctx context.Context, req *pb.SearchRequest) (*pb.SearchResponse, error) {
	flags := ctx.Value(flagsContextKey).(map[string]bool)

	if flags["search-v2"] {
		return s.searchV2(ctx, req)
	}
	return s.searchV1(ctx, req)
}

Gradual Rollouts in Go

Gradual rollouts let you release a feature to a percentage of users, increasing over time. This is the safest way to ship changes in production. For a deep dive on rollout strategies, see our gradual rollouts guide.

The Rollgate SDK handles rollout percentages server-side using consistent hashing. This means:

  • The same user always gets the same result for a given flag (no flickering).
  • Users in the 5% rollout are always included in the 25% rollout.
  • Distribution is statistically uniform.
// In the Rollgate dashboard, set "new-checkout" to 10% rollout.
// In your code, just check the flag:

func checkoutHandler(w http.ResponseWriter, r *http.Request) {
	userID := getUserID(r)

	// The SDK evaluates the rollout percentage using consistent
	// hashing of flagKey + userID. No extra code needed.
	if flagClient.IsEnabled("new-checkout", false,
		rollgate.WithUser(userID),
	) {
		newCheckoutFlow(w, r)
	} else {
		currentCheckoutFlow(w, r)
	}
}

You control the percentage from the Rollgate dashboard. Start at 1%, monitor your metrics, bump to 10%, then 50%, then 100%. If errors spike at any stage, set it back to 0% — instantly, without redeploying.

Feature Flags with User Targeting

Beyond percentage rollouts, you can target specific users, segments, or attributes. This is useful for beta programs, enterprise features, or regional launches.

Attribute-Based Targeting

Set up targeting rules in the Rollgate dashboard. For example: enable advanced-analytics for users where plan equals pro or enterprise.

// Identify the user with attributes
err := client.Identify(ctx, &rollgate.UserContext{
	ID:    userID,
	Email: user.Email,
	Attributes: map[string]any{
		"plan":       user.Plan,        // "free", "starter", "pro", "enterprise"
		"country":    user.Country,     // "US", "DE", "JP"
		"created_at": user.CreatedAt,   // signup date
		"team_size":  user.TeamSize,    // number
	},
})
if err != nil {
	log.Printf("identify error: %v", err)
}

// Now flag evaluations consider these attributes
if client.IsEnabled("advanced-analytics", false) {
	// Only users matching the targeting rules see this
	showAdvancedAnalytics()
}

Segment Examples

Common targeting patterns you can configure in the dashboard:

  • Beta testers: email ends with @yourcompany.com
  • Enterprise features: plan equals enterprise
  • Regional rollout: country in US, CA, GB
  • Power users: team_size greater than 50
  • New users: created_at after a specific date

The SDK evaluates targeting rules locally after the initial fetch, so there is no per-evaluation API call.

Testing with Feature Flags

Testability is a first-class concern in Go. The Rollgate SDK is designed around interfaces, making it easy to mock in tests.

Define a Flag Client Interface

// featureflags.go
package featureflags

// Client defines the interface for feature flag evaluation.
type Client interface {
	IsEnabled(flagKey string, defaultValue bool) bool
	Close()
}

Mock Implementation for Tests

// featureflags_test.go
package featureflags

type MockClient struct {
	Flags map[string]bool
}

func (m *MockClient) IsEnabled(flagKey string, defaultValue bool) bool {
	if val, ok := m.Flags[flagKey]; ok {
		return val
	}
	return defaultValue
}

func (m *MockClient) Close() {}

Test Both Paths

Always test both the enabled and disabled paths. Feature flags that are never tested in both states are bugs waiting to happen.

func TestCheckoutHandler(t *testing.T) {
	tests := []struct {
		name       string
		flagValue  bool
		wantStatus int
		wantBody   string
	}{
		{
			name:       "new checkout enabled",
			flagValue:  true,
			wantStatus: http.StatusOK,
			wantBody:   `{"version":"v2"}`,
		},
		{
			name:       "new checkout disabled",
			flagValue:  false,
			wantStatus: http.StatusOK,
			wantBody:   `{"version":"v1"}`,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mock := &MockClient{
				Flags: map[string]bool{
					"new-checkout": tt.flagValue,
				},
			}

			handler := NewCheckoutHandler(mock)
			req := httptest.NewRequest("GET", "/checkout", nil)
			w := httptest.NewRecorder()

			handler.ServeHTTP(w, req)

			if w.Code != tt.wantStatus {
				t.Errorf("got status %d, want %d", w.Code, tt.wantStatus)
			}
		})
	}
}

This is a pattern we use heavily in Rollgate's own API. See feature flags vs feature branches for more on how flags improve your testing workflow.

Feature Flag Middleware Pattern

For applications that check multiple flags across many handlers, a middleware pattern keeps your code DRY. Evaluate flags once per request and inject the results into context.Context:

package middleware

import (
	"context"
	"net/http"

	rollgate "github.com/rollgate/sdk-go"
)

type contextKey string

const FlagsKey contextKey = "feature-flags"

// FeatureFlags is a map of flag keys to their boolean values.
type FeatureFlags map[string]bool

// WithFeatureFlags evaluates a set of flags for each request
// and stores the results in the request context.
func WithFeatureFlags(client *rollgate.Client, flags []string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			userID := getUserIDFromRequest(r)

			evaluated := make(FeatureFlags, len(flags))
			for _, flag := range flags {
				evaluated[flag] = client.IsEnabled(flag, false,
					rollgate.WithUser(userID),
				)
			}

			ctx := context.WithValue(r.Context(), FlagsKey, evaluated)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// GetFlags retrieves the evaluated flags from the request context.
func GetFlags(ctx context.Context) FeatureFlags {
	flags, ok := ctx.Value(FlagsKey).(FeatureFlags)
	if !ok {
		return FeatureFlags{}
	}
	return flags
}

// IsEnabled is a convenience function to check a single flag from context.
func IsEnabled(ctx context.Context, flagKey string) bool {
	return GetFlags(ctx)[flagKey]
}

Use it with Chi:

r := chi.NewRouter()

// Apply middleware — flags are evaluated once per request
r.Use(middleware.WithFeatureFlags(flagClient, []string{
	"search-v2",
	"new-pricing",
	"dark-mode",
}))

r.Get("/api/search", func(w http.ResponseWriter, r *http.Request) {
	if middleware.IsEnabled(r.Context(), "search-v2") {
		searchV2(w, r)
	} else {
		searchV1(w, r)
	}
})

This pattern evaluates all flags once at the middleware layer, avoiding repeated IsEnabled calls throughout your handlers. It also makes testing straightforward: just inject a context with the flags you want to test.

Performance: Local Evaluation vs API Calls

A common concern with feature flags in Go is latency. The Rollgate SDK is built for performance-critical services with multiple layers of optimization.

How the SDK Works

  1. Initial fetch: On Init(), the SDK fetches all flag values from the API in a single HTTP request.
  2. Local evaluation: After initialization, IsEnabled() reads from an in-memory map. There is no network call per evaluation. This means sub-microsecond flag checks.
  3. Background updates: The SDK keeps flags fresh via either polling (default: every 30 seconds) or SSE streaming (real-time, sub-second updates).
  4. Multi-layer caching: Flags are cached in memory with configurable TTL. If the API is unreachable, stale cache values are used as fallback.

SSE Streaming for Real-Time Updates

For applications where flag changes must propagate instantly (kill switches, incident response), enable SSE streaming:

client, _ := rollgate.NewClient(rollgate.Config{
	APIKey:         os.Getenv("ROLLGATE_API_KEY"),
	EnableStreaming: true, // Real-time updates via Server-Sent Events
})

With streaming enabled, flag changes propagate to your Go service in under one second. No polling delay.

Circuit Breaker

The SDK includes a built-in circuit breaker that protects your service when the Rollgate API is down:

  • After 5 consecutive failures, the circuit opens and the SDK stops making requests.
  • Cached flag values are used as fallback during the outage.
  • After 30 seconds, the circuit enters half-open state and tests with a single request.
  • After 3 successful requests, the circuit closes and normal operation resumes.
// Monitor circuit breaker state
client.OnCircuitOpen(func() {
	log.Warn("rollgate circuit breaker opened — using cached flags")
	metrics.Increment("rollgate.circuit_open")
})

client.OnCircuitClosed(func() {
	log.Info("rollgate circuit breaker closed — back to normal")
})

This means your Go service never fails because of a feature flag service outage. Flags degrade gracefully to cached values.

Performance Summary

OperationLatency
IsEnabled() call< 1 microsecond (in-memory lookup)
Initial flag fetch~50-100ms (single HTTP request)
Background refresh (polling)Every 30s, non-blocking
SSE update propagation< 1 second
Fallback to cacheAutomatic, zero-latency

FAQ

How do feature flags affect Go binary size?

The Rollgate Go SDK adds minimal overhead to your binary. It has zero external dependencies beyond the Go standard library. Typical binary size increase is around 2-3 MB.

Can I use feature flags in Go without a third-party service?

Yes. You can start with the DIY approach using config files or environment variables. However, you will quickly need targeting, gradual rollouts, and real-time updates — which require a service like Rollgate.

How do feature flags work with Go's concurrency model?

The Rollgate SDK is fully goroutine-safe. The internal flag store is protected by sync.RWMutex, so multiple goroutines can call IsEnabled() concurrently without contention. Background polling and SSE streaming run in separate goroutines.

What happens if the Rollgate API is unreachable?

The SDK uses a multi-layer fallback strategy: retry with exponential backoff, circuit breaker to prevent cascading failures, and stale cache as a last resort. Your service continues to operate with the last known flag values.

Should I use polling or SSE streaming?

Use polling (the default) for most services. It is simpler and works well when flag changes can take up to 30 seconds to propagate. Use SSE streaming when you need real-time updates, such as kill switches or incident response flags.

How do I clean up old feature flags?

Remove the flag from your code first, deploy, then archive it in the Rollgate dashboard. Never remove a flag from the dashboard before removing the code — you would get the default value instead of the intended behavior.

Next Steps

Ready to add feature flags to your Go application?

  1. Create a free Rollgate account — no credit card required
  2. Read the Go SDK docs — full API reference and advanced configuration
  3. Learn about gradual rollouts — ship features safely with percentage-based rollouts

Feature flags in Go let you ship faster, roll back instantly, and target specific users — all without redeploying your service. Start with a simple boolean flag and expand to gradual rollouts and user targeting as your needs grow.