---
title: "How to add feature flags and experiments to a Go backend"
description: "Evaluate a report feature flag in Go, request exposure at use, and track report exports as the conversion with Amplitude."
canonical: "https://amplitude.com/en-us/build/go-feature-flags"
---

# How to add feature flags and experiments to a Go backend

Evaluate a report feature flag in Go, request exposure at use, and track report exports as the conversion with Amplitude.

Source: https://amplitude.com/en-us/build/go-feature-flags

---

# How to add feature flags and experiments to a Go backend

Use the Experiment Go SDK to choose a report summary on the server and Amplitude analytics to track exports afterward. I'll keep evaluation and conversion as separate actions. Allow about 25 minutes.

- Go 1.27
- net/http
- Experiment Go SDK
- 25 min

Chris Tam · Last updated 22 October 2026

A Go backend can choose a report experience before returning its response. [Amplitude feature flags](https://amplitude.com/feature-management) control that choice, and [experimentation](https://amplitude.com/amplitude-experiment) lets you compare what users do after they encounter it.

In this tutorial, you'll extend the Go product analytics report app, choose a summary for signed-in users, request exposure tracking at that decision, and track an export as the conversion.

A preview endpoint that returns `layout: control` or `layout: treatment`, with a summary only for treatment. A separate export endpoint returns CSV and tracks `Report Exported` using the same identity. It never evaluates the flag again.

## Prerequisites

- The Go 1.27 report app from the Go product analytics tutorial, with `analytics.go`, `auth.go`, `reports.go`, and `server.go` in the module root. Keep its analytics client and drain-before-close lifecycle.
- Existing verified authentication that supplies a canonical user ID and account plan through `WithVerifiedUser`. The standalone example stays anonymous until your real authentication middleware supplies a user.
- An Amplitude project with permission to create a server deployment, flag, and experiment. Use the same project as the analytics API key.

[Sign up](https://app.amplitude.com/signup) for an Amplitude account and [compare plans](https://amplitude.com/pricing) for your team's needs.

The files below add to the earlier app. Keep unrelated application routes, authorization, and request protections when merging them.

## 1. Install the Experiment SDK

From the existing module root, install the package named in the [Experiment Go SDK reference](https://amplitude.com/docs/sdks/experiment-sdks/experiment-go).

**Terminal**

Terminal

```sh
go get github.com/amplitude/experiment-go-server@v1.13.1
mkdir -p cmd/flags
```

Keep `go.mod` and `go.sum` with the application. This page adds a separate `cmd/flags` entry point so you can retain the earlier executable while reviewing the integration.

## 2. Create the report flag

In Amplitude, go to Experiments > Deployments, select Create Deployment, choose the analytics project, and create a Server deployment for this service. Keep its key in the server environment.

Go to Experiment > Feature Flags > Create Feature Flag. Set the key to `report-summary`, choose Remote evaluation, and attach the server deployment. Configure the variant values as `control` and `treatment`; only the exact value `treatment` enables the summary in this example. Save the variant changes and use Activate flag when the configuration is ready.

The following is a configuration checklist for those UI choices, not an API request:

**Report flag settings**

Report flag settings

```json
{
  "flag_key": "report-summary",
  "evaluation_mode": "remote",
  "deployment_type": "Server",
  "variant_values": ["control", "treatment"],
  "targeting_property_available": "plan"
}
```

Pass `plan` from verified account data if you target on it. For manual testing, configure your test users to receive each variant in the flag's targeting settings. Keep rollout and eligibility appropriate for your project.

## 3. Evaluate at the report decision

I'll reuse one remote client, build a fresh evaluation user per request, and ask only for the flag this response uses. A half-second timeout and zero retries keep the request from waiting through multiple evaluation attempts.

**flags.go**

flags.go

```go
package buildhub

import (
	"context"
	"errors"
	"strings"
	"time"

	"github.com/amplitude/experiment-go-server/pkg/experiment"
	"github.com/amplitude/experiment-go-server/pkg/experiment/remote"
)

const ReportFlag = "report-summary"

type ReportFlags struct{ client *remote.Client }

func NewReportFlags(key, zone string) (*ReportFlags, error) {
	if strings.TrimSpace(key) == "" {
		return nil, errors.New("set server-only AMPLITUDE_DEPLOYMENT_KEY")
	}
	config, err := reportFlagConfig(zone)
	if err != nil {
		return nil, err
	}
	return &ReportFlags{client: remote.Initialize(key, config)}, nil
}

func reportFlagConfig(zone string) (*remote.Config, error) {
	config := &remote.Config{FetchTimeout: 500 * time.Millisecond,
		RetryBackoff: &remote.RetryBackoff{FetchRetries: 0}}
	switch strings.ToUpper(zone) {
	case "", "US":
		config.ServerUrl = "https://api.lab.amplitude.com"
	case "EU":
		config.ServerUrl = "https://api.lab.eu.amplitude.com"
	default:
		return nil, errors.New("AMPLITUDE_SERVER_ZONE must be US or EU")
	}
	return config, nil
}

func (f *ReportFlags) Layout(ctx context.Context, identity Identity) string {
	// This experiment targets signed-in users; anonymous requests keep baseline.
	if identity.UserID == "" {
		return "control"
	}
	user := &experiment.User{UserId: identity.UserID, DeviceId: identity.DeviceID,
		UserProperties: map[string]interface{}{"plan": identity.Plan}}
	variants, err := f.client.FetchV2WithContextAndOptions(user, ctx, &remote.FetchOptions{
		FlagKeys: []string{ReportFlag}, TracksAssignment: true, TracksExposure: true})
	if err != nil {
		return "control"
	}
	if variant, ok := variants[ReportFlag]; ok {
		// V2 also returns default and undeployed variants; keep our baseline for them.
		if variant.Metadata["default"] == true || variant.Metadata["deployed"] == false {
			return "control"
		}
		if variant.Value == "treatment" {
			return "treatment"
		}
	return "control"
}
```

The user and device IDs come from the earlier app's verified request context and analytics metadata header. Explicit `plan` context makes the current account value available to targeting without waiting for a profile update.

Anonymous requests keep the baseline. Evaluation errors, missing or unknown variants, and default or undeployed variants also keep the baseline. A fallback is an application choice; it is not proof that Experiment assigned a real control variant.

**Why.** The SDK adds library information to the user passed to fetch. A fresh object inside `Layout` keeps concurrent requests from sharing that mutable state. The context-aware call also lets a canceled request stop waiting for evaluation. Regional hosts match the project's data residency; the deployment key stays server-side.

`TracksExposure: true` requests exposure tracking for this flag at the preview decision. Use it where the experience is selected, rather than in generic middleware or prefetch code. The server's decision does not prove the browser displayed the response. Keep the exposure event out of the export handler and avoid sending a second manual exposure for the same decision.

## 4. Track the export separately

I'll replace only the report's preview handler and add an export handler. Treatment adds a summary to the existing report response. Export queues `Report Exported` only after the CSV write succeeds.

**flag\_reports.go**

flag\_reports.go

```go
package buildhub

import (
	"encoding/json"
	"net/http"
)

func FlagPreviewHandler(analytics *Analytics, flags *ReportFlags) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		identity := RequestIdentity(r)
		layout := flags.Layout(r.Context(), identity)
		report := map[string]interface{}{"report_type": "usage", "days": 7, "layout": layout}
		if layout == "treatment" {
			report["summary"] = "Your last seven days are ready to export."
		}
		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(report); err == nil {
			analytics.Report("Report Previewed", identity)
		}
	})
}

func ExportHandler(analytics *Analytics) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/csv")
		w.Header().Set("Content-Disposition", `attachment; filename="usage.csv"`)
		if _, err := w.Write([]byte("report_type,days\nusage,7\n")); err == nil {
			analytics.Report("Report Exported", RequestIdentity(r))
		}
	})
}
```

`analytics.Report` keeps the earlier event properties, identity handling, and plan Identify operation. Both endpoints use the same canonical user ID. Keep your application's report authorization and data access checks around these handlers.

**MCP.** Use [Amplitude's MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) to inspect existing flags, metrics, and event names before creating another report conversion metric.

## 5. Connect the routes

I'll pass the flagged preview into the existing router and add export to that same mux. Keep the app's verified authentication middleware around it, together with any agent or application routes you've already added.

**cmd/flags/main.go**

cmd/flags/main.go

```go
// Keep verified auth, agent routes and other Go page additions in the app.
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"

	hub "example.com/amplitude-build-go"
)

func main() {
	analytics, err := hub.NewAnalytics(os.Getenv("AMPLITUDE_API_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
	if err != nil {
		log.Fatal(err)
	}
	flags, err := hub.NewReportFlags(os.Getenv("AMPLITUDE_DEPLOYMENT_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
	if err != nil {
		analytics.Close()
		log.Fatal(err)
	}
	mux := hub.NewRouter(analytics, hub.FlagPreviewHandler(analytics, flags))
	mux.Handle("POST /api/reports/export", hub.ExportHandler(analytics))
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := hub.Serve(ctx, "127.0.0.1:8080", mux, analytics); err != nil {
		log.Print(err)
	}
```

This entry point uses the example module path from Go product analytics. Replace it with your module's path when integrating into an existing service. The shared server still drains active handlers before it closes analytics.

Stop the earlier local server before starting this entry point on the same port. Set both keys in the process environment:

**Terminal**

Terminal

```sh
go mod tidy
export AMPLITUDE_API_KEY='your-project-api-key'
export AMPLITUDE_DEPLOYMENT_KEY='your-server-deployment-key'
export AMPLITUDE_SERVER_ZONE=US
go run ./cmd/flags
```

Use `EU` for an EU project. The analytics API key and deployment key have different roles: one sends product events, and the other authorizes evaluation for the deployment. Keep both tied to the same project. This executable reads process variables and does not load a `.env` file.

## 6. Set up the experiment metric

To compare exports between variants, convert the flag to an experiment and configure a primary success metric for `Report Exported`. In the Metrics section, select or create that metric and set the expected direction to increase. Review targeting and allocation before selecting Start Experiment.

Use `[Experiment] Exposure` as the exposure event for the explicit exposure tracking requested by this example. `Report Previewed` describes the product action, and `[Experiment] Assignment` is a distinct assignment event. The [event-tracking reference](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking) explains the difference.

This is the expected journey to verify, not an event payload to send manually:

**Experiment journey**

Experiment journey

```text
Same user ID
  POST /api/reports/preview
    evaluate report-summary and request exposure
    return control or treatment report
    queue Report Previewed
  POST /api/reports/export
    return CSV
    queue Report Exported
```

A rollout changes who sees the summary. Experiment analysis uses the processed exposure and conversion events to measure the outcome; the application should not infer a winner from its own treatment response count.

## Verify it worked

Use your application's authenticated client for both variant cases. A raw request to the unmodified standalone server stays anonymous and deliberately receives the baseline.

**Terminal**

Terminal

```sh
curl -i -X POST http://127.0.0.1:8080/api/reports/preview \
  -H 'X-Amplitude-Device-Id: go-flag-anonymous-test'
curl -i -X POST http://127.0.0.1:8080/api/reports/export \
  -H 'X-Amplitude-Device-Id: go-flag-anonymous-test'
```

1. Confirm the anonymous preview returns `layout: control` without a summary, and export returns a CSV attachment. These anonymous requests do not evaluate the flag.
2. Through real verified authentication, send a preview for a test user configured to get treatment. Confirm `layout: treatment` and the summary, then repeat for a control user and confirm no summary. Keep the same user ID for evaluation and product events.
3. Verify each authenticated preview requests only `report-summary`, with assignment and exposure tracking enabled. Export must not trigger another evaluation request. A successful evaluation response proves the request succeeded, not that Amplitude finished processing exposure.
4. In Amplitude, inspect delivery counts in the Ingestion Debugger and search the user under Users > User Profiles. Check `Report Previewed`, `Report Exported`, and processed `[Experiment] Exposure` for the actual flag and variant. Then check the experiment's exposure and conversion analysis.
5. In development, test evaluation failure and a request timeout. The report should remain available with the baseline. Do not count these responses as successful control assignments. Stop the server gracefully and allow the analytics SDK to send its final batch.

## Troubleshooting

### Every response uses the baseline

Confirm that verified authentication supplied a user, the flag belongs to the configured deployment, and its exact values are `control` and `treatment`. Missing flags, unexpected values, default or undeployed variants, and evaluation failures all keep the baseline.

### The flag works in one environment only

Match the deployment key, analytics project, region, and deployment membership. A flag in another project or deployment is not included just because its key matches.

### A pro user misses a plan targeting rule

Pass the current plan from verified account data through the request identity. Keep the spelling and value consistent with the flag's targeting condition. User properties explicitly passed to evaluation take precedence over historical values.

### Exports appear but the experiment has no exposures

Confirm `TracksExposure: true`, the `report-summary` scope, deployment membership, and a matching user ID. Inspect processed `[Experiment] Exposure` separately from assignment. The export handler deliberately does not evaluate the flag.

### The report becomes slow when evaluation is unavailable

Keep the request timeout bounded and retry policy suitable for the HTTP path. This sample uses one attempt with a 500-millisecond timeout and falls back to the baseline. Keep reporting and authorization independent of evaluation availability.

## Frequently asked questions

### Does a flag replace authorization?

Keep authorization in your existing application controls. This flag changes the report summary after the request has passed your normal access checks.

### Should I reuse an evaluation user across requests?

Reuse the SDK client and construct a new `experiment.User` inside each call. The SDK writes library context to that object, so sharing it across concurrent handlers can mix mutable state.

### Should exporting fetch the flag again?

Export is the outcome being measured. It sends `Report Exported` with the same user identity and leaves evaluation at the preview decision, where the experience is selected.

### Does a timeout count as a control assignment?

A timeout chooses the application's baseline for availability. Confirm processed exposure and conversion events in Amplitude before interpreting experiment results.

## Full code reference

Unchanged from Go product analytics; keep your versions: `analytics.go`, `auth.go`, `reports.go`, and `server.go`. Add these three files, retaining all existing authentication, routes, and shutdown hooks.

Full code reference

```go
// flags.go
package buildhub

import (
	"context"
	"errors"
	"strings"
	"time"

	"github.com/amplitude/experiment-go-server/pkg/experiment"
	"github.com/amplitude/experiment-go-server/pkg/experiment/remote"
)

const ReportFlag = "report-summary"

type ReportFlags struct{ client *remote.Client }

func NewReportFlags(key, zone string) (*ReportFlags, error) {
	if strings.TrimSpace(key) == "" {
		return nil, errors.New("set server-only AMPLITUDE_DEPLOYMENT_KEY")
	}
	config, err := reportFlagConfig(zone)
	if err != nil {
		return nil, err
	}
	return &ReportFlags{client: remote.Initialize(key, config)}, nil
}

func reportFlagConfig(zone string) (*remote.Config, error) {
	config := &remote.Config{FetchTimeout: 500 * time.Millisecond,
		RetryBackoff: &remote.RetryBackoff{FetchRetries: 0}}
	switch strings.ToUpper(zone) {
	case "", "US":
		config.ServerUrl = "https://api.lab.amplitude.com"
	case "EU":
		config.ServerUrl = "https://api.lab.eu.amplitude.com"
	default:
		return nil, errors.New("AMPLITUDE_SERVER_ZONE must be US or EU")
	}
	return config, nil
}

func (f *ReportFlags) Layout(ctx context.Context, identity Identity) string {
	// This experiment targets signed-in users; anonymous requests keep baseline.
	if identity.UserID == "" {
		return "control"
	}
	user := &experiment.User{UserId: identity.UserID, DeviceId: identity.DeviceID,
		UserProperties: map[string]interface{}{"plan": identity.Plan}}
	variants, err := f.client.FetchV2WithContextAndOptions(user, ctx, &remote.FetchOptions{
		FlagKeys: []string{ReportFlag}, TracksAssignment: true, TracksExposure: true})
	if err != nil {
		return "control"
	}
	if variant, ok := variants[ReportFlag]; ok {
		// V2 also returns default and undeployed variants; keep our baseline for them.
		if variant.Metadata["default"] == true || variant.Metadata["deployed"] == false {
			return "control"
		}
		if variant.Value == "treatment" {
			return "treatment"
		}
	return "control"
}

// flag_reports.go
package buildhub

import (
	"encoding/json"
	"net/http"
)

func FlagPreviewHandler(analytics *Analytics, flags *ReportFlags) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		identity := RequestIdentity(r)
		layout := flags.Layout(r.Context(), identity)
		report := map[string]interface{}{"report_type": "usage", "days": 7, "layout": layout}
		if layout == "treatment" {
			report["summary"] = "Your last seven days are ready to export."
		}
		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(report); err == nil {
			analytics.Report("Report Previewed", identity)
		}
	})
}

func ExportHandler(analytics *Analytics) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/csv")
		w.Header().Set("Content-Disposition", `attachment; filename="usage.csv"`)
		if _, err := w.Write([]byte("report_type,days\nusage,7\n")); err == nil {
			analytics.Report("Report Exported", RequestIdentity(r))
		}
	})
}

// cmd/flags/main.go
// Keep verified auth, agent routes and other Go page additions in the app.
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"

	hub "example.com/amplitude-build-go"
)

func main() {
	analytics, err := hub.NewAnalytics(os.Getenv("AMPLITUDE_API_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
	if err != nil {
		log.Fatal(err)
	}
	flags, err := hub.NewReportFlags(os.Getenv("AMPLITUDE_DEPLOYMENT_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
	if err != nil {
		analytics.Close()
		log.Fatal(err)
	}
	mux := hub.NewRouter(analytics, hub.FlagPreviewHandler(analytics, flags))
	mux.Handle("POST /api/reports/export", hub.ExportHandler(analytics))
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := hub.Serve(ctx, "127.0.0.1:8080", mux, analytics); err != nil {
		log.Print(err)
	}
```

## Hand it to your agent

Using Cursor or Claude Code? Paste this prompt

```text
Use https://amplitude.com/build/go-feature-flags to extend this Go report service.
Preserve the existing analytics client, verified authentication and authorization,
per-request identity, application routes, and drain-before-close lifecycle.
Initialize one Experiment Go remote client with a server deployment key from the
analytics project. Configure US/EU correctly. Construct a fresh experiment.User
for each preview. Evaluate only report-summary with the request context and
request assignment plus exposure tracking where the preview chooses its layout.
Serve the baseline for anonymous users, errors, missing or unknown values, and
default or undeployed variants. Only treatment adds summary. Add an export route
that returns CSV and queues Report Exported after a successful write; never fetch
a flag or send another manual exposure on export. Keep keys in process environment.
Check both variants, scoped exposure requests, conversion identity, cancellation,
timeout, concurrency, and graceful final-event delivery. Verify remote exposure
processing and experiment analysis separately in Amplitude. Report changed files
and actual test results.
```

## What this unlocks

[Feature flags](https://amplitude.com/feature-management) control which eligible users receive the report summary.

[Experimentation](https://amplitude.com/amplitude-experiment) compares `Report Exported` after processed `[Experiment] Exposure` events.

[Product analytics](https://amplitude.com/amplitude-analytics) shows report previews and exports alongside the rest of the user's activity.

## Next steps

[GoGo product analyticsKeep the shared identity and shutdown foundation used by these report handlers.](https://amplitude.com/build/go-product-analytics)[GoGo agent analyticsConnect an assistant's exchanges to the same users and report outcomes.](https://amplitude.com/build/go-agent-analytics)[BackendFastAPI feature flagsFollow the same report decision and export conversion in a Python service.](https://amplitude.com/build/fastapi-feature-flags)

Reference docs

[Experiment Go SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-go)[Experiment quick start →](https://amplitude.com/docs/feature-experiment/experiment-quick-start)[Experiment data model →](https://amplitude.com/docs/feature-experiment/data-model)[Assignment and exposure events →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Evaluation API regions →](https://amplitude.com/docs/apis/experiment/experiment-evaluation-api)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/go-feature-flags.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Use the report preview and export events to compare what users do after each experience.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
