Ship to production continuously without big-bang risk. Build a feature-flag layer in Django, roll features out to a percentage of users, run A/B experiments, and add instant kill switches for when something goes wrong.
Deploying code and releasing a feature should be two separate events. Feature flags let you merge to main, deploy on Tuesday, turn a feature on for 5% of users on Thursday, and roll it back in one click if metrics dip — all without a redeploy. This is progressive delivery, and it is how mature teams ship continuously without betting the site on every release. This tutorial builds a real flag system in Django and the delivery patterns it unlocks.
Without flags, every deploy is a gamble: all the new code goes live to everyone the instant it ships, so a bug is an outage and a rollback is another deploy under pressure. Feature flags break that coupling. The deploy becomes boring — the new code ships dormant, wrapped in a flag that is off — and the release becomes a controlled, reversible configuration change you make when you are ready and watching. That separation is the entire point: it turns scary, all-or-nothing launches into gradual, observable, instantly-reversible rollouts, and it lets code sit safely in production ahead of the business decision to turn it on.
It also unlocks workflows that are otherwise impossible: merging long-running work in small safe increments behind a flag instead of in a giant risky branch, testing in production with internal users before anyone else sees a feature, and giving product and sales a switch they can flip without engineering. The flag is the seam between "the code exists" and "customers experience it."
You do not need a SaaS vendor to start. A small model captures the essentials — an on/off switch, a percentage rollout, and an explicit allowlist for specific users:
class FeatureFlag(models.Model):
key = models.SlugField(unique=True)
enabled = models.BooleanField(default=False)
rollout_percent = models.PositiveSmallIntegerField(default=0) # 0-100
allowed_users = models.ManyToManyField(User, blank=True)
The model is deliberately simple, but the resolution logic is where the important decisions live — above all, that the same user must always get the same answer for a given flag.
A flag that flips on and off for the same user on every request is useless — the experience flickers and any experiment data is noise. Resolution must be deterministic: bucket each user by hashing the flag key together with their ID, so a user stays in the same bucket as you ramp the percentage up:
import hashlib
def flag_enabled(key, user=None):
flag = FeatureFlag.objects.filter(key=key).first()
if not flag or not flag.enabled:
return False
if user and flag.allowed_users.filter(pk=user.pk).exists():
return True
if flag.rollout_percent >= 100:
return True
if not user:
return False
bucket = int(hashlib.sha256(
f"{key}:{user.pk}".encode()).hexdigest(), 16) % 100
return bucket < flag.rollout_percent
Hashing key:user_id has two crucial properties: a given user's bucket is stable, so raising the rollout from 5% to 25% only ever adds users and never removes someone who already had the feature; and the bucketing differs per flag, so a user in the unlucky 95% for one feature is not systematically excluded from every feature. That stability is what makes both safe rollouts and honest experiments possible.
The hash function choice is not cosmetic. Python's built-in hash() on strings is randomized per process (controlled by PYTHONHASHSEED), so two Gunicorn workers would put the same user in different buckets. A digest such as SHA-256 is stable across processes, machines, and languages, which also matters when a JavaScript or Go service must reproduce the same decision.
Anonymous visitors are the second trap. Django's AnonymousUser is truthy and has pk = None, so passing request.user straight into flag_enabled hashes every logged-out visitor to the same string "key:None": they all land in one bucket, and a 5% rollout is effectively 0% or 100% for your whole anonymous audience. Pass None for unauthenticated users, or bucket them on a stable random ID stored in a long-lived cookie. In B2B products the right unit is often the organization rather than the user, so colleagues sharing a screen see the same interface.
Every request will check flags, often several, so hitting the database each time is wasteful. Cache flag definitions in Redis with a short TTL — a few seconds to a minute — so lookups are fast but changes still propagate quickly. The short TTL is a deliberate balance: long enough to remove the database load, short enough that flipping a kill switch takes effect almost immediately. For the highest-traffic paths, an in-process cache with an even shorter TTL layered on top removes the Redis round trip too. Whatever you choose, a flag check must be cheap enough that developers never hesitate to add one.
Cache one snapshot of all flags rather than a key per flag, so a single Redis round trip serves every check in the request, add a tiny in-process layer, and invalidate on write so the TTL is only a safety net:
# flags/cache.py -- import it from FlagsConfig.ready() so the receivers connect
import time
from django.core.cache import cache
from django.db import transaction
from django.db.models.signals import m2m_changed, post_delete, post_save
from django.dispatch import receiver
from .models import FeatureFlag
CACHE_KEY = "flags:snapshot:v1"
CACHE_TTL = 30 # seconds in Redis
LOCAL_TTL = 2 # seconds in each worker process
_local = {"data": None, "loaded_at": 0.0}
def _build_snapshot():
return {
f.key: {
"enabled": f.enabled,
"percent": f.rollout_percent,
"users": {u.pk for u in f.allowed_users.all()},
}
for f in FeatureFlag.objects.prefetch_related("allowed_users")
}
def get_snapshot():
now = time.monotonic()
if _local["data"] is not None and now - _local["loaded_at"] < LOCAL_TTL:
return _local["data"]
data = cache.get(CACHE_KEY)
if data is None:
data = _build_snapshot()
cache.set(CACHE_KEY, data, CACHE_TTL)
_local.update(data=data, loaded_at=now)
return data
def _invalidate(**kwargs):
transaction.on_commit(lambda: cache.delete(CACHE_KEY))
receiver([post_save, post_delete], sender=FeatureFlag)(_invalidate)
receiver(m2m_changed, sender=FeatureFlag.allowed_users.through)(_invalidate)
The transaction.on_commit wrapper prevents a subtle race: the admin saves inside a transaction, and if you deleted the key immediately, a concurrent request could rebuild the snapshot from the old committed row and cache it for another 30 seconds. Invalidation only clears Redis, though; each worker's local copy can stay stale for up to LOCAL_TTL, so the worst-case propagation time for a kill switch is about two seconds. Put that number in your runbook. Also memoize flag values per request (for example in a dict attached to request by a small middleware), so that a flag flipped mid-request cannot render half of each experience.
The canary pattern ramps a feature gradually while you watch the system's health: 1%, then 5%, 25%, 50%, 100%, pausing at each step to check error rates, latency, and the business metric the feature is meant to move. If the dashboards stay green, you continue; if anything spikes, you drop the rollout back to 0 instantly — no deploy, no rollback branch, just a configuration change. This turns a release from a single terrifying moment into a series of small, observable, reversible steps. The discipline that makes it work is watching the right metrics at each stage and being willing to halt; a canary you do not monitor is just a slow full release.
The most reliable canary halts itself. Compare the flagged cohort with the unflagged cohort over the same window, not today against yesterday; that cancels out traffic swings and unrelated incidents. Your error tracking must know each request's flag state (with Sentry, sentry_sdk.set_tag("flag.new_checkout", "on") in middleware does this). A management command run every minute then enforces a guardrail:
from django.core.management.base import BaseCommand
from flags.models import FeatureFlag
from myproject.metrics import error_rate # query your APM / Prometheus
class Command(BaseCommand):
help = "Halt canaries whose flagged cohort breaches the error guardrail"
def handle(self, *args, **options):
canaries = FeatureFlag.objects.filter(
enabled=True, rollout_percent__gt=0, rollout_percent__lt=100)
for flag in canaries:
treated = error_rate(flag.key, cohort="on", minutes=10)
control = error_rate(flag.key, cohort="off", minutes=10)
if treated > max(control * 1.5, control + 0.005):
flag.rollout_percent = 0
flag.save()
self.stderr.write(
f"halted {flag.key}: {treated:.2%} vs {control:.2%}")
The threshold mixes a relative and an absolute margin, so a near-zero baseline does not trip on a single exception. It sets the rollout to 0 rather than disabling the flag, which keeps allowlisted staff on the feature and lets engineers reproduce the bug. It calls save() rather than a queryset update() so that post_save fires and the cache invalidation above propagates the halt within seconds. At 1% of traffic, ten minutes may hold only a few dozen requests, so skip evaluation until the flagged cohort has enough volume to mean anything.
The same deterministic bucketing that powers rollouts powers experiments. Assign users to variant A or B by their stable bucket, render the appropriate experience, and emit an analytics event recording which variant each user saw. Then measure the metric you actually care about — conversion, retention, revenue per user — not vanity numbers like clicks. Run the experiment until you reach statistical significance rather than stopping the moment the numbers look good, which is how teams fool themselves with noise. When a winner is clear, ship it to everyone and delete the flag. Done well, this replaces opinion-driven debate with evidence; done carelessly, it produces confident conclusions from random variation, so respect the statistics.
Salt experiment hashes differently from rollout hashes (for example f"exp:{key}:{user.pk}"). Otherwise "treatment" ends up correlated with the users a rollout of the same key already enabled. Decide the sample size before launch. For 80% power at a 5% significance level, a rule of thumb is roughly 16 × p(1 − p) / δ² users per arm, where p is the baseline conversion rate and δ is the absolute lift to detect. A 5% baseline and a 0.5-point lift need about 30,400 users per arm.
Before reading results, check for sample ratio mismatch. A 50/50 test with 10,000 and 10,400 users per arm gives a chi-square of about 7.8 (p ≈ 0.005). That means assignment or exposure logging is broken, for example because one variant errors before its exposure event fires, and none of the conversion numbers can be trusted until you find the cause.
Percentage rollouts are the start, not the end. Real systems target by attributes: enable a feature for internal staff first, then beta-opt-in users, then a specific plan tier, then a region, then everyone. Extend the resolution logic to evaluate rules against user and request context — plan, signup date, country, account flags — so you can say "on for enterprise customers in the EU" declaratively. This is also how you run a feature for a single customer who requested it, or exclude a customer who must not see a change yet. Keep the rules data-driven so product can adjust targeting without a deploy.
Not every flag controls a new feature — some exist purely to turn things off in an emergency. Wrap risky dependencies and expensive operations in a kill switch: a new payment provider, a third-party integration prone to outages, a heavy report that can overload the database under load. When that dependency misbehaves, you flip the switch in seconds and degrade gracefully instead of failing hard, with no deploy in the critical moment. Kill switches are cheap insurance; add them proactively to anything whose failure would hurt, so that when the inevitable incident comes, your response is a click rather than a frantic hotfix.
A kill switch is only as good as the fallback behind it. Pair it with an error fallback, so the switch covers sustained outages and the try block covers transient ones:
def recommendations_for(user):
if not flag_enabled("recommendations_live", user):
return popular_items_cached() # switch flipped: skip the call
try:
return reco_client.fetch(user.pk, timeout=0.3)
except (TimeoutError, ConnectionError):
return popular_items_cached()
The resolver returns False for an unknown key. That means a switch phrased as "enabled means the dependency runs" fails closed: if someone deletes the row, the feature silently stops. For a payment provider that may be right, but for a core page it is an outage you caused yourself. Choose the default per switch, write it in the flag's description, and flip every switch in staging at least once. Until a switch has been tested, you cannot count on it.
Flags are not free forever. A flag that has been at 100% for three months is dead conditional code — two code paths where there should be one, extra branches to read and test, and a latent risk that someone flips the wrong one. This is real technical debt, and ignoring it leaves your codebase a maze of stale toggles. Build the discipline to remove flags once they have fully shipped: track each flag's age and state, schedule cleanup of the long-since-launched ones, and treat flag removal as part of finishing a feature, not an optional afterthought. A healthy flag system has a steady flow of flags created and retired, not an ever-growing pile.
The model above is enough to start and gives you full control with no external dependency. As needs grow — complex targeting, audit logs, a UI for non-engineers, experiment analysis, SDKs across services — managed platforms like LaunchDarkly, Flagsmith, or Unleash become attractive, and Unleash in particular has a strong open-source self-hosted option. The decision hinges on how central flags become to your workflow: a handful of rollout toggles do not justify a vendor, but an organization where product managers run dozens of experiments and targeting rules benefits from purpose-built tooling. Start simple, and adopt a platform when the operational and product needs clearly exceed what a model and an admin page provide.
Where a flag is evaluated matters for both security and performance. Server-side evaluation — deciding on the flag in Django and sending the client only the resulting experience — keeps your rollout logic and targeting rules private, which is essential when a flag gates an unreleased feature you do not want competitors or curious users discovering by reading the JavaScript bundle. Client-side evaluation, where the browser receives flag values and decides, is simpler for purely cosmetic toggles but exposes the existence of features and the shape of your targeting. The rule of thumb: evaluate anything sensitive or security-relevant on the server, and only push non-secret presentation flags to the client.
Once flags span more than one service, consistency becomes the challenge: the same user must get the same flag decision whether they hit the web app, the API, or a background worker, or the experience fractures. The deterministic hashing approach from earlier is what makes this possible — because the decision is a pure function of the flag key and user ID, every service computes the same answer without coordinating, as long as they share the flag configuration. Distribute the flag definitions to every service (via a shared store or a flag platform's SDK) and let each evaluate locally. This keeps decisions fast and consistent without a central evaluation service becoming a bottleneck or a single point of failure.
A kill switch is powerful, and power needs guardrails. Flipping a flag in production is a production change, so it deserves the same care as a deploy: an audit log of who changed what and when, permissions controlling who can toggle which flags, and ideally a confirmation step for high-impact switches. Without this, a feature flag system becomes an ungoverned back door where anyone can alter production behavior untracked. Record every change, restrict sensitive flags to the people who should control them, and surface the current state of all flags somewhere visible, so the team always knows what is on, for whom, and who decided.
It is worth separating three things that all look like flags but serve different masters. Release flags are temporary, controlling the rollout of new code and meant to be deleted once shipped. Experiment flags drive A/B tests and live until the experiment concludes. Entitlement flags are permanent, encoding what a customer's plan unlocks — they are part of your product, not technical debt. Treating them identically causes confusion: someone deletes an entitlement flag thinking it is stale release debt, and breaks a paying customer's access. Name and categorize flags by their purpose, apply hygiene only to the temporary kinds, and model entitlements deliberately as the long-lived configuration they are.
A flag creates two code paths, and both must work, which means your tests must exercise both states. Test the feature on and off, and test the transition, because bugs often hide in the assumption that everyone is in one state. This doubles some test cases, which is a real cost and another reason to delete flags promptly — every lingering flag permanently multiplies the states your suite must cover. For flags that interact, the combinations multiply further, so keep the number of simultaneously-live flags manageable. Parametrized tests that run the same scenario with the flag forced on and off are the clean way to cover both paths without duplicating test code.
With pytest-django, a fixture that patches the resolver where your code imports it lets each test force the flags it needs. Unknown keys default to off, as they do in production:
from unittest import mock
import pytest
@pytest.fixture
def flags():
forced = {}
with mock.patch("checkout.views.flag_enabled",
side_effect=lambda key, user=None: forced.get(key, False)):
yield forced
@pytest.mark.parametrize("on", [True, False])
def test_checkout_renders(client, flags, on):
flags["new_checkout"] = on
response = client.get("/checkout/")
assert response.status_code == 200
Add one test against the real resolver that raises a rollout from 5% to 25% over a few hundred users and asserts the first set is a subset of the second. Otherwise a well-meant refactor of the hash can silently reshuffle every user.
| Symptom | Likely cause | Fix |
|---|---|---|
| Feature appears and disappears for one user | Non-deterministic bucketing (hash(), random) | Hash a stable ID with SHA-256 |
| A 10% rollout reaches about 0% or 100% of visitors | All anonymous users share one bucket (pk=None) | Pass None or bucket on a cookie ID |
| Kill switch takes minutes to act | Long TTL, or invalidation that runs before commit | Invalidate with transaction.on_commit; keep the local TTL short |
| Logged-out users see a flagged variant | A full-page or CDN cache stored it | Include the variant in the cache key, or don't cache flagged pages |
| Database spike after deploy | Every worker rebuilds the snapshot at the same moment | Keep the process-local layer; warm the cache after deploy |
Feature flags turn scary releases into boring config changes by separating deploy from release. Build a small flag layer whose resolution is deterministic — hash key:user_id so a user's bucket is stable and ramping a rollout only ever adds users — and cache lookups with a short TTL so checks are cheap and kill switches still act fast. On that foundation, ramp features as monitored canaries, reuse the bucketing for statistically honest A/B tests, target by user and request attributes, and put kill switches on anything risky. The discipline that keeps the system healthy is hygiene: delete flags once they have fully shipped, or your codebase fills with dead toggles. Start with your own model and graduate to a managed platform only when targeting, experimentation, and team workflows genuinely demand it.