How to add feature flags and experiments to a FastAPI backend

Evaluate a report flag on the server, return a control or summary variant, and measure exports with the same user identity. Allow about 25 minutes.

  • FastAPI
  • Python 3.12+
  • Experiment SDK 1.13+
  • 25 min

Chris Tam · Last updated 21 October 2026

FastAPI can choose a report experience before returning its API response. I use feature flags to control that choice and experimentation to compare what users do afterward. The report's export action supplies the outcome event.

In this tutorial, I extend the product analytics setup, evaluate a flag for signed-in users, request exposure tracking at the decision, and record exports separately.

A POST /api/reports/preview response with either the existing report or an added summary, plus POST /api/reports/export that returns a CSV and tracks Report Exported. The remote evaluation request asks Amplitude to track exposure to report-summary; the export route never evaluates the flag again.

Prerequisites

  • The FastAPI product analytics tutorial, including its app/analytics.py and app/auth.py modules, the preview route, and the app's normal startup and shutdown handling. I keep those analytics and identity helpers unchanged.
  • Python 3.12 or later and a uv-managed project. The new modules below are complete; merge route and lifespan changes into the existing application.
  • An Amplitude project with access to feature experimentation and permission to create deployments, flags, and experiments. Sign up for an account and compare plans for your team's needs.
  • A verified application login. This example evaluates only signed-in users. Connect get_current_user to verified authentication; the base dependency returns None until that is done.

1. Create the flag and server deployment

In Amplitude, open Experiment > Deployments, select Create Deployment, choose the same project as the analytics API key, and create a Server deployment for this service. Copy its deployment key. Keep that key in the server's environment; it is a different credential from the analytics project API key.

Open Experiment > Feature Flags > Create Feature Flag. Name the flag Report summary, confirm its key is report-summary, choose Remote evaluation, and attach the server deployment. In Variants, add values control and treatment. The application compares these exact values.

For an initial rollout, target the account_type property your application sends. Use member and staff as eligible values. For a split test, use user bucketing and a 50/50 distribution within that audience. Keep a limited rollout while testing, then select Activate flag when the configuration is ready.

Process environment additions

Process environment additions
# Keep AMPLITUDE_API_KEY from the product analytics tutorial.
export AMPLITUDE_DEPLOYMENT_KEY="your-server-deployment-key"
export AMPLITUDE_SERVER_ZONE="US"

Use EU for an EU project, for both analytics and evaluation. Never put the server deployment key in browser code or a public environment variable. The deployment guide covers the project and deployment relationship.

2. Add a shared evaluation client

The Experiment Python SDK provides remote evaluation. Install it in the existing project:

Terminal

Terminal
uv add "amplitude-experiment>=1.13,<2"

Create app/experiments.py. Each worker creates its client on first use; user identity is supplied with each fetch. The connection and request limits below bound waiting for this cosmetic report choice. Tune them for the service's latency budget.

app/experiments.py

app/experiments.py
# Keep analytics and authentication from the product analytics tutorial.
import logging
import os
from threading import Lock
from amplitude_experiment import Experiment, RemoteEvaluationConfig, User
from amplitude_experiment.remote.fetch_options import FetchOptions
from amplitude_experiment.server_zone import ServerZone

FLAG_KEY = "report-summary"
_client = None
_lock = Lock()
log = logging.getLogger(__name__)


def get_experiment():
    global _client
    with _lock:
        if _client is None:
            zone = ServerZone.EU if os.environ.get("AMPLITUDE_SERVER_ZONE", "US") == "EU" else ServerZone.US
            _client = Experiment.initialize_remote(
                os.environ["AMPLITUDE_DEPLOYMENT_KEY"],
                RemoteEvaluationConfig(
                    server_zone=zone,
                    fetch_timeout_millis=500,
                    fetch_retries=0,
                    connection_pool_max_size=8,
                    fetch_pool_acquire_timeout_millis=100,
                ),
            )
        return _client


def choose_report_layout(user_id, account_type):
    # This experiment is for signed-in users. Auth determines eligibility.
    if user_id is None:
        return "control"
    client = get_experiment()
    try:
        variants = client.fetch_v2(
            User(user_id=user_id, user_properties={"account_type": account_type}),
            FetchOptions(tracksExposure=True, flagKeys=[FLAG_KEY]),
        ) or {}
        variant = variants.get(FLAG_KEY)
        if variant is not None and variant.value in {"control", "treatment"}:
            return variant.value
    except Exception:
        # Availability wins for this cosmetic layout. Keep authorization separate.
        log.warning("Experiment evaluation failed; serving the baseline report")
    return "control"


def close_experiment():
    # Call only at worker exit, after requests have drained.
    if _client is not None:
        _client.close()

Why. A shared client can serve many users, but a shared mutable user object can mix their assignments. I build a new User inside each call. Missing identity skips evaluation. An absent flag, an unexpected value, or a fetch failure serves the baseline report. That fallback is not a real control assignment, and the application does not fabricate an exposure for it.

tracksExposure=True requests server-side exposure tracking when this flag is evaluated. flagKeys limits the request to the experience about to be used. Call this helper at the preview decision, rather than during generic request middleware or background prefetching. A server decision does not confirm that a browser rendered the response.

This flag controls presentation. Existing authentication and authorization still decide whether a user can access a real report.

3. Serve the variant and record exports

The export event uses the same analytics client and canonical user ID as Report Previewed. Create a separate module so the base analytics helper stays unchanged:

app/report_events.py

app/report_events.py
from amplitude import BaseEvent
from .analytics import get_client


def record_report_export(user_id, device_id, framework):
    if not isinstance(device_id, str) or not 5 <= len(device_id) <= 1024:
        device_id = None
    if user_id is None and device_id is None:
        return False
    get_client().track(BaseEvent(
        event_type="Report Exported",
        user_id=user_id,
        device_id=device_id,
        event_properties={"report_type": "usage", "days": 7, "framework": framework},
    ))
    return True

Merge the two routes and lifespan additions below into the existing app. Preserve middleware, authentication dependencies, other endpoints, and existing startup/shutdown work. In the base app, get_current_user runs anonymously until it is connected to real authentication.

app/main.py, report routes and lifespan

app/main.py, report routes and lifespan
# Keep routes, middleware, and lifespan work from /build/fastapi-product-analytics.
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from fastapi.responses import Response
from starlette.concurrency import run_in_threadpool
from .analytics import get_client, record_report_preview, shutdown_client
from .auth import AuthUser, get_current_user
from .experiments import choose_report_layout, close_experiment, get_experiment
from .report_events import record_report_export


@asynccontextmanager
async def lifespan(app: FastAPI):
    get_client()
    get_experiment()
    try:
        yield
    finally:
        await run_in_threadpool(close_experiment)
        await run_in_threadpool(shutdown_client)


app = FastAPI(lifespan=lifespan)


@app.post("/api/reports/preview")
async def report_preview(
    request: Request,
    user: Annotated[AuthUser | None, Depends(get_current_user)],
):
    user_id = f"user-{user.id}" if user is not None else None
    account_type = user.account_type if user is not None else None
    layout = await run_in_threadpool(choose_report_layout, user_id, account_type)
    preview = {"report_type": "usage", "days": 7, "layout": layout}
    if layout == "treatment":
        preview["summary"] = "Your last seven days of usage are ready to export."
    await run_in_threadpool(record_report_preview, user_id, request.headers.get("X-Amplitude-Device-Id"), "fastapi", account_type)
    return preview


@app.post("/api/reports/export")
async def report_export(
    request: Request,
    user: Annotated[AuthUser | None, Depends(get_current_user)],
):
    user_id = f"user-{user.id}" if user is not None else None
    response = Response(
        "report_type,days\nusage,7\n",
        media_type="text/csv",
        headers={"Content-Disposition": 'attachment; filename="usage.csv"'},
    )
    await run_in_threadpool(record_report_export, user_id, request.headers.get("X-Amplitude-Device-Id"), "fastapi")
    return response

Why. fetch_v2() is synchronous. run_in_threadpool keeps network waiting off the ASGI event loop. The shared connection pool limits concurrent evaluation calls; a worker should not initialize its SDKs in a pre-fork master. At graceful exit, requests drain before lifespan cleanup closes evaluation connections and flushes analytics.

The fixed report data makes the example runnable. Merge the variant selection with the application's real computation and authorization. Report Exported means the backend prepared the CSV response; it does not prove a client finished downloading the file.

4. Exercise the two report actions

Use the application's normal login to prepare a valid session in cookies.txt, or adapt the request to its existing verified token mechanism. Do not add a user-ID header as a substitute for authentication. The device header forwards an existing browser analytics device ID; it is not an authentication credential.

Terminal, with an authenticated application session

Terminal, with an authenticated application session
# Keep your existing authenticated cookies in cookies.txt.
ORIGIN="http://127.0.0.1:8000"
curl --fail-with-body -b cookies.txt -X POST "$ORIGIN/api/reports/preview"   -H "X-Amplitude-Device-Id: existing-browser-device-id"

The response contains layout: "control" or layout: "treatment". Only treatment includes summary. Keep the same user identity for the later export. Preserve any existing browser CORS and cookie-CSRF policy when integrating these endpoints; curl does not exercise browser policies.

5. Turn the flag into an experiment

Convert the existing flag to an experiment through the documented experiment setup workflow. In Metrics, add a conversion metric based on Report Exported and set the goal to increase exports. Keep the same server deployment and audience.

Use Amplitude's [Experiment] Exposure event for exposure attribution. Report Previewed is a product event, and [Experiment] Assignment is a distinct assignment event. This implementation explicitly requests exposure tracking; it does not rename either event or send a second manual exposure.

Test the variants and metric configuration before selecting Start Experiment. After previewing the report, invoke the export action:

Terminal, same authenticated session

Terminal, same authenticated session
curl --fail-with-body -b cookies.txt -X POST "$ORIGIN/api/reports/export" -H "X-Amplitude-Device-Id: existing-browser-device-id" -o usage.csv

Verify it worked

  1. Check both response variants using your development flag's test targeting. A control response has no summary; a treatment response includes it. Test rollout and property targeting separately, then remove test targeting before collecting production results.
  2. Confirm the evaluation request succeeds. The SDK requests only report-summary and asks the evaluation service to track exposure. A successful HTTP request alone is not proof that Amplitude processed its exposure event.
  3. Export once after a preview. The response is a CSV attachment; the export route makes no flag fetch. In the analytics delivery callback, confirm HTTP 200. The base SDK buffers events, so allow its normal interval or graceful shutdown to upload them.
  4. In Amplitude's Ingestion Debugger, check request and event counts. Then open Users > User Profiles, find the same canonical user ID, and inspect Report Previewed and Report Exported in the event stream. Check [Experiment] Exposure for the flag and actual variant, then confirm the experiment's exposure and conversion analysis uses those events.
  5. Repeat as another signed-in user and an anonymous request. The anonymous request gets the baseline without evaluation. Force an evaluation failure in development and confirm the report remains usable; do not count that fallback as a control assignment.

MCP. With existing project access, use the Amplitude MCP server to inspect the event taxonomy and confirm Report Exported matches the event used by the metric. Do not create a near-duplicate conversion name.

Troubleshooting

Every response uses the baseline

Confirm verified authentication returns a user, the deployment key belongs to the analytics project, the flag is attached and active, and the configured values are exactly control and treatment. Check evaluation errors. Anonymous requests, absent flags, unknown values, and failures deliberately choose the baseline.

The wrong users qualify

Use user_properties.account_type from trusted application data. Check the audience property name and rollout in Amplitude. A property sent through Identify is useful for analytics, but this request supplies its targeting property directly.

Exports appear but the experiment has no exposures

Check tracksExposure=True, the report-summary key, deployment membership, and the same canonical user ID in evaluation and analytics. Verify processed [Experiment] Exposure events separately from assignment events. The export action does not produce an exposure.

A request hangs during evaluation

Tune the fetch timeout and connection-pool wait limit for your latency budget. These examples disable retries and fall back to the baseline. Pool saturation is also a waiting condition, not only a slow remote response.

Other FastAPI routes stop responding during a fetch

Keep choose_report_layout inside run_in_threadpool. Calling the synchronous SDK directly inside an async route blocks its event loop. Apply the same boundary to the synchronous analytics calls and shutdown helper.

FAQ

Is the analytics project API key the deployment key?

Use a server deployment key for Experiment and the analytics project API key for conversion events. Both belong to the same Amplitude project. Keep the server deployment key on the server.

Does this experiment include anonymous users?

This example evaluates signed-in users only. Anonymous requests retain the baseline and can still send product events with a usable device ID. An anonymous experiment needs an explicit device bucketing and identity-continuity design.

Does an evaluation timeout mean control won?

A timeout selects the baseline for availability. It is not a successful control assignment and does not establish an experiment result. Wait for processed exposures, conversions, and the experiment's statistical analysis before choosing a winner.

Full code reference

Unchanged from FastAPI product analytics; keep your versions: app/analytics.py, app/auth.py, the root application configuration, and existing shutdown hooks.

Full code reference
# -- app/experiments.py --
# Keep analytics and authentication from the product analytics tutorial.
import logging
import os
from threading import Lock
from amplitude_experiment import Experiment, RemoteEvaluationConfig, User
from amplitude_experiment.remote.fetch_options import FetchOptions
from amplitude_experiment.server_zone import ServerZone

FLAG_KEY = "report-summary"
_client = None
_lock = Lock()
log = logging.getLogger(__name__)


def get_experiment():
    global _client
    with _lock:
        if _client is None:
            zone = ServerZone.EU if os.environ.get("AMPLITUDE_SERVER_ZONE", "US") == "EU" else ServerZone.US
            _client = Experiment.initialize_remote(
                os.environ["AMPLITUDE_DEPLOYMENT_KEY"],
                RemoteEvaluationConfig(
                    server_zone=zone,
                    fetch_timeout_millis=500,
                    fetch_retries=0,
                    connection_pool_max_size=8,
                    fetch_pool_acquire_timeout_millis=100,
                ),
            )
        return _client


def choose_report_layout(user_id, account_type):
    # This experiment is for signed-in users. Auth determines eligibility.
    if user_id is None:
        return "control"
    client = get_experiment()
    try:
        variants = client.fetch_v2(
            User(user_id=user_id, user_properties={"account_type": account_type}),
            FetchOptions(tracksExposure=True, flagKeys=[FLAG_KEY]),
        ) or {}
        variant = variants.get(FLAG_KEY)
        if variant is not None and variant.value in {"control", "treatment"}:
            return variant.value
    except Exception:
        # Availability wins for this cosmetic layout. Keep authorization separate.
        log.warning("Experiment evaluation failed; serving the baseline report")
    return "control"


def close_experiment():
    # Call only at worker exit, after requests have drained.
    if _client is not None:
        _client.close()

# -- app/report_events.py --
from amplitude import BaseEvent
from .analytics import get_client


def record_report_export(user_id, device_id, framework):
    if not isinstance(device_id, str) or not 5 <= len(device_id) <= 1024:
        device_id = None
    if user_id is None and device_id is None:
        return False
    get_client().track(BaseEvent(
        event_type="Report Exported",
        user_id=user_id,
        device_id=device_id,
        event_properties={"report_type": "usage", "days": 7, "framework": framework},
    ))
    return True

# -- app/main.py --
# Keep routes, middleware, and lifespan work from /build/fastapi-product-analytics.
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from fastapi.responses import Response
from starlette.concurrency import run_in_threadpool
from .analytics import get_client, record_report_preview, shutdown_client
from .auth import AuthUser, get_current_user
from .experiments import choose_report_layout, close_experiment, get_experiment
from .report_events import record_report_export


@asynccontextmanager
async def lifespan(app: FastAPI):
    get_client()
    get_experiment()
    try:
        yield
    finally:
        await run_in_threadpool(close_experiment)
        await run_in_threadpool(shutdown_client)


app = FastAPI(lifespan=lifespan)


@app.post("/api/reports/preview")
async def report_preview(
    request: Request,
    user: Annotated[AuthUser | None, Depends(get_current_user)],
):
    user_id = f"user-{user.id}" if user is not None else None
    account_type = user.account_type if user is not None else None
    layout = await run_in_threadpool(choose_report_layout, user_id, account_type)
    preview = {"report_type": "usage", "days": 7, "layout": layout}
    if layout == "treatment":
        preview["summary"] = "Your last seven days of usage are ready to export."
    await run_in_threadpool(record_report_preview, user_id, request.headers.get("X-Amplitude-Device-Id"), "fastapi", account_type)
    return preview


@app.post("/api/reports/export")
async def report_export(
    request: Request,
    user: Annotated[AuthUser | None, Depends(get_current_user)],
):
    user_id = f"user-{user.id}" if user is not None else None
    response = Response(
        "report_type,days\nusage,7\n",
        media_type="text/csv",
        headers={"Content-Disposition": 'attachment; filename="usage.csv"'},
    )
    await run_in_threadpool(record_report_export, user_id, request.headers.get("X-Amplitude-Device-Id"), "fastapi")
    return response

Hand it to your agent

Coding-agent prompt

Coding-agent prompt
Extend the existing FastAPI report example using https://amplitude.com/build/fastapi-feature-flags.
Keep app/analytics.py and app/auth.py from /build/fastapi-product-analytics unchanged, including real verified authentication and worker shutdown flushing. Merge route and lifespan changes; preserve unrelated code and authorization/CSRF controls.
Use amplitude-experiment>=1.13,<2 with one remote client per worker. Read AMPLITUDE_DEPLOYMENT_KEY only on the server; it must belong to the analytics project. Honor AMPLITUDE_SERVER_ZONE for both SDKs.
Use the tested FetchOptions import from amplitude_experiment.remote.fetch_options, with tracksExposure=True and flagKeys=["report-summary"]. Supply a new User with the canonical authenticated ID and trusted account_type per request.
Evaluate only at POST /api/reports/preview. Serve control or treatment; only treatment adds summary. Anonymous identity, missing/unknown variants, and evaluation errors use the baseline without inventing exposures. Keep authentication separate from the flag.
Add POST /api/reports/export to return CSV and send Report Exported after preparing the response. Do not fetch a flag or manually send exposure on export. Keep the same user ID as evaluation and product analytics.
Run synchronous evaluation and analytics in run_in_threadpool; merge client initialization/cleanup into the existing lifespan.
Verify both variants, property targeting context, scoped exposure requests, conversion identity, timeout/failure fallback, concurrency, and shutdown delivery. Confirm remote exposures and experiment analysis separately in Amplitude. Report every file changed.

What this unlocks

Feature flags: control the report summary for eligible users without changing route code.

Experimentation: compare exports after exposure to the summary or baseline experience.

Product analytics: inspect Report Previewed and Report Exported with the same canonical user identity.

Next steps

Compare the report experiences

Inspect the exposure and export events, then use the experiment results to decide which report experience to keep.