Introduction: Why Your Go Service Needs Experiments
\n\nImagine: you rewrote the payment page on a new stack. Everything works, the Go code flies, tests are green. But conversion dropped by 15%. No matter how hard you try, it's impossible to predict user behavior in production. This is where A/B testing in Go comes into play — the only way to make decisions based on data, not intuition.
\n\nIn this article, we'll break down how to implement split-testing Golang in your application. You'll learn how to implement a bucket system, avoid the curse of multiple comparisons, and not break production. Let's go!
\n\n1. Basic Architecture of an A/B Test in Go
\n\nAny A/B test Golang consists of three pillars: traffic splitter, logging system, and statistical engine. Let's start with the first one.
\n\n1.1. Deterministic Distribution: Bucket Algorithm
\n\nKey rule: one user should always fall into the same group. We use consistent hashing based on the user ID:
\n\npackage abtest\n\nimport (\n "crypto/sha256"\n "encoding/binary"\n "fmt"\n)\n\n// Experiment defines the structure of an experiment\ntype Experiment struct {\n Name string\n Buckets []Bucket\n}\n\ntype Bucket struct {\n Name string\n Ratio float64 // 0.0 - 1.0\n}\n\n// AssignGroup returns the group name for userID\nfunc (e *Experiment) AssignGroup(userID string) string {\n hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", e.Name, userID)))\n value := binary.BigEndian.Uint64(hash[:8]) % 1000\n threshold := uint64(0)\n \n for _, b := range e.Buckets {\n threshold += uint64(b.Ratio * 1000)\n if value < threshold {\n return b.Name\n }\n }\n return "control" // fallback\n}\n\n\nWhy sha256? It provides a uniform distribution and is resistant to collisions. Bucket testing Go with this approach guarantees that a user won't jump between groups when the server restarts.
1.2. Middleware for HTTP Server
\n\nIntegrate A/B testing Go into your router. Example with net/http:
func ABMiddleware(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n userID := r.Header.Get("X-User-ID") // or from cookie\n if userID == "" {\n next.ServeHTTP(w, r)\n return\n }\n \n exp := &Experiment{\n Name: "checkout_v2",\n Buckets: []Bucket{\n {Name: "control", Ratio: 0.5},\n {Name: "treatment", Ratio: 0.5},\n },\n }\n \n group := exp.AssignGroup(userID)\n ctx := context.WithValue(r.Context(), "ab_group", group)\n next.ServeHTTP(w, r.WithContext(ctx))\n })\n}\n\n\nNow in handlers you can read the group via r.Context().Value("ab_group"). Simple and effective.
2. Advanced Techniques: Feature Flags and Multi-Armed Bandits
\n\nSimple A/B is good, but in real experiments in Golang flexibility is often needed.
\n\n2.1. Feature Flags with Dynamic Rollout
\n\nSometimes you need to enable a feature for 10% of users, then gradually increase to 100%. Implement via Redis:
\n\ntype DynamicExperiment struct {\n Client *redis.Client\n Name string\n}\n\nfunc (d *DynamicExperiment) IsEnabled(userID string) bool {\n // Get the current percentage from Redis (can be changed without redeploy)\n percent, err := d.Client.Get(context.Background(), d.Name).Float64()\n if err != nil {\n return false // disabled by default\n }\n \n hash := sha256.Sum256([]byte(userID))\n value := binary.BigEndian.Uint64(hash[:8]) % 1