---
title: "How to add feature flags and experiments to a Django backend"
description: "Evaluate feature flags in Django, serve report variants, request exposure tracking, and measure exports with Amplitude."
canonical: "https://amplitude.com/en-us/build/django-feature-flags"
---

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

Evaluate feature flags in Django, serve report variants, request exposure tracking, and measure exports with Amplitude.

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

---

# How to add feature flags and experiments to a Django 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.

- Django
- Python 3.12+
- Experiment SDK 1.13+
- 25 min

Chris Tam · Last updated 21 October 2026

Django can choose a report experience before returning its API response. I use [feature flags](https://amplitude.com/feature-management) to control that choice and [experimentation](https://amplitude.com/amplitude-experiment) to compare what users do afterward. The report's export action supplies the outcome event.

In this tutorial, I extend the [product analytics setup](https://amplitude.com/build/django-product-analytics), 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 [Django product analytics tutorial](https://amplitude.com/build/django-product-analytics), including its `metrics/analytics.py` and `metrics/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 changes into the existing application.
- An Amplitude project with access to feature experimentation and permission to create deployments, flags, and experiments. [Sign up](https://app.amplitude.com/signup) for an account and [compare plans](https://amplitude.com/pricing) for your team's needs.
- A verified application login. This example evaluates only signed-in users. Django uses the existing `request.user` helper.

## 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

```sh
# 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](https://amplitude.com/docs/feature-experiment/workflow/configure) covers the project and deployment relationship.

## 2. Add a shared evaluation client

The [Experiment Python SDK](https://amplitude.com/docs/sdks/experiment-sdks/experiment-python) provides remote evaluation. Install it in the existing project:

**Terminal**

Terminal

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

Create `metrics/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.

**metrics/experiments.py**

metrics/experiments.py

```python
# 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:

**metrics/report\_events.py**

metrics/report\_events.py

```python
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 these two view functions and their imports into the existing module. Preserve other views, authorization decorators, and CSRF middleware. `GET` on the preview route still supplies a CSRF token; neither POST is exempt from CSRF checks.

**metrics/views.py**

metrics/views.py

```python
# Keep routes and decorators from /build/django-product-analytics.
from django.http import HttpResponse, JsonResponse
from django.middleware.csrf import get_token
from django.views.decorators.http import require_http_methods, require_POST
from .auth import get_current_user
from .analytics import record_report_preview
from .experiments import choose_report_layout
from .report_events import record_report_export

@require_http_methods(["GET", "POST"])
def report_preview(request):
    if request.method == "GET":
        return JsonResponse({"csrfToken": get_token(request)})
    user = get_current_user(request)
    user_id = f"user-{user.pk}" if user is not None else None
    account_type = "staff" if user is not None and user.is_staff else "member" if user is not None else None
    # Evaluate only where the selected experience is about to be used.
    layout = 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."
    record_report_preview(user_id, request.headers.get("X-Amplitude-Device-Id"), "django", account_type)
    return JsonResponse(preview)

@require_POST
def report_export(request):
    user = get_current_user(request)
    user_id = f"user-{user.pk}" if user is not None else None
    # No flag fetch here: exporting is the outcome, not another exposure.
    response = HttpResponse("report_type,days\nusage,7\n", content_type="text/csv")
    response["Content-Disposition"] = 'attachment; filename="usage.csv"'
    record_report_export(user_id, request.headers.get("X-Amplitude-Device-Id"), "django")
    return response
```

Add the export pattern beside the existing preview pattern. Keep the root `path("api/", include("metrics.urls"))` and any other URL patterns.

**metrics/urls.py, report patterns**

metrics/urls.py, report patterns

```python
# Add export beside the existing preview; keep all other URL patterns.
from django.urls import path
from .views import report_preview, report_export

urlpatterns = [
    path("reports/preview", report_preview),
    path("reports/export", report_export),
]
```

Keep the worker-exit flushing from the product analytics tutorial. Call `close_experiment()` from `metrics.experiments` in the worker's existing graceful-exit hook after requests drain, alongside analytics cleanup. Remote evaluation has no application-side exposure queue to flush; the analytics conversion events still need the base SDK's shutdown handling.

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

```sh
# Keep your existing authenticated cookies in cookies.txt.
ORIGIN="http://127.0.0.1:8000"
CSRF_TOKEN=$(curl -sS -b cookies.txt -c cookies.txt "$ORIGIN/api/reports/preview" |
  python3 -c 'import json,sys; print(json.load(sys.stdin)["csrfToken"])')
curl --fail-with-body -b cookies.txt -X POST "$ORIGIN/api/reports/preview"   -H "X-CSRFToken: $CSRF_TOKEN"   -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](https://amplitude.com/docs/feature-experiment/experiment-quick-start#design-the-experiment). 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

```sh
curl --fail-with-body -b cookies.txt -X POST "$ORIGIN/api/reports/export" -H "X-CSRFToken: $CSRF_TOKEN" -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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) 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.

### Django rejects the POST with HTTP 403

Keep CSRF middleware enabled and send the token from the GET response with the same cookie session. Preserve the application's authorization checks; do not exempt the analytics routes from them.

## 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 [Django product analytics](https://amplitude.com/build/django-product-analytics); keep your versions: `metrics/analytics.py`, `metrics/auth.py`, the root application configuration, and existing shutdown hooks.

Full code reference

```python
# -- metrics/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()

# -- metrics/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

# -- metrics/views.py --
# Keep routes and decorators from /build/django-product-analytics.
from django.http import HttpResponse, JsonResponse
from django.middleware.csrf import get_token
from django.views.decorators.http import require_http_methods, require_POST
from .auth import get_current_user
from .analytics import record_report_preview
from .experiments import choose_report_layout
from .report_events import record_report_export

@require_http_methods(["GET", "POST"])
def report_preview(request):
    if request.method == "GET":
        return JsonResponse({"csrfToken": get_token(request)})
    user = get_current_user(request)
    user_id = f"user-{user.pk}" if user is not None else None
    account_type = "staff" if user is not None and user.is_staff else "member" if user is not None else None
    # Evaluate only where the selected experience is about to be used.
    layout = 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."
    record_report_preview(user_id, request.headers.get("X-Amplitude-Device-Id"), "django", account_type)
    return JsonResponse(preview)

@require_POST
def report_export(request):
    user = get_current_user(request)
    user_id = f"user-{user.pk}" if user is not None else None
    # No flag fetch here: exporting is the outcome, not another exposure.
    response = HttpResponse("report_type,days\nusage,7\n", content_type="text/csv")
    response["Content-Disposition"] = 'attachment; filename="usage.csv"'
    record_report_export(user_id, request.headers.get("X-Amplitude-Device-Id"), "django")
    return response

# -- metrics/urls.py --
# Add export beside the existing preview; keep all other URL patterns.
from django.urls import path
from .views import report_preview, report_export

urlpatterns = [
    path("reports/preview", report_preview),
    path("reports/export", report_export),
]
```

## Hand it to your agent

**Coding-agent prompt**

Coding-agent prompt

```text
Extend the existing Django report example using https://amplitude.com/build/django-feature-flags.
Keep metrics/analytics.py and metrics/auth.py from /build/django-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.
Keep both POSTs protected by Django CSRF and merge URL patterns.
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](https://amplitude.com/feature-management): control the report summary for eligible users without changing route code.

[Experimentation](https://amplitude.com/amplitude-experiment): compare exports after exposure to the summary or baseline experience.

[Product analytics](https://amplitude.com/amplitude-analytics): inspect `Report Previewed` and `Report Exported` with the same canonical user identity.

## Next steps

[Web frameworksDjango product analyticsRevisit the shared identity and analytics setup.](https://amplitude.com/build/django-product-analytics)[Web frameworksDjango agent analyticsInstrument model calls separately if this backend serves an assistant.](https://amplitude.com/build/django-agent-analytics)[Use caseStitch client and server eventsKeep browser and backend identities consistent.](https://amplitude.com/build/stitch-client-server-events)

Reference docs

[Experiment Python SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-python)[Configure your experiment →](https://amplitude.com/docs/feature-experiment/workflow/configure)[Experiment quick start →](https://amplitude.com/docs/feature-experiment/experiment-quick-start)[Assignment and exposure events →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Debug Analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

For agents

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

## Compare the report experiences

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

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