Use Feature Flags for Safer Django Releases
Use feature flags in Django to separate deploys from releases: flag types, a simple flag model, django-waffle, gradual rollouts, kill switches, and cleanup.
Problem statement
Many Django teams treat "deploy" and "release" as the same event: new code reaches production and every user sees it at once. That coupling creates pressure:
- large features wait in long-lived branches until they are completely finished, then merge with painful conflicts
- a problem found after deploy requires a full rollback, which also removes unrelated fixes from the same release
- there is no way to let internal staff or a small percentage of users try a change first
- risky integrations (a new payment provider, a new search backend) cannot be switched off quickly without a deploy
Feature flags decouple the two. Code is deployed disabled, then enabled for specific users, groups, or percentages, and disabled again in seconds if needed. Flags also add their own risks: forgotten flags accumulate, code paths multiply, and a flag lookup on every request can cost performance if implemented carelessly.
Quick answer
- Decide the flag type: release toggle (temporary), kill switch or operational toggle (long-lived), permission or experiment flag.
- Start simple: a small database-backed flag model cached in Redis, or an established package such as django-waffle.
- Evaluate flags on the server; for frontends, pass evaluated values to templates or API responses.
- Roll out gradually: staff first, then a percentage, then everyone.
- Keep kill switches for risky dependencies and default them to the safe behavior when flag data is unavailable.
- Test both states of every flag in CI.
- Remove release flags after full rollout; track owners and expiry dates.
Step-by-step solution
1. Know which kind of flag you need
| Type | Lifetime | Example |
|---|---|---|
| Release toggle | days to weeks | new checkout flow hidden until ready |
| Kill switch / ops toggle | long-lived | disable expensive recommendations under load |
| Permission flag | long-lived | beta features for selected customers |
| Experiment flag | weeks | A/B test with measured outcomes |
Release toggles should disappear after rollout. Kill switches stay but should be few and documented.
2. A minimal database-backed flag
For small projects, a simple model is often enough:
# flags/models.py
from django.db import models
class Flag(models.Model):
name = models.SlugField(unique=True)
enabled_for_everyone = models.BooleanField(default=False)
enabled_for_staff = models.BooleanField(default=False)
rollout_percent = models.PositiveSmallIntegerField(default=0)
note = models.CharField(max_length=200, blank=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
# flags/service.py
import hashlib
from django.core.cache import cache
from .models import Flag
CACHE_KEY = "flags:all:v1"
CACHE_SECONDS = 30
def _load_flags():
flags = cache.get(CACHE_KEY)
if flags is None:
flags = {
f.name: (f.enabled_for_everyone, f.enabled_for_staff, f.rollout_percent)
for f in Flag.objects.all()
}
cache.set(CACHE_KEY, flags, CACHE_SECONDS)
return flags
def _bucket(flag_name, user_id):
digest = hashlib.sha256(f"{flag_name}:{user_id}".encode()).hexdigest()
return int(digest[:8], 16) % 100
def is_enabled(flag_name, user=None, default=False):
try:
flags = _load_flags()
except Exception:
return default
if flag_name not in flags:
return default
everyone, staff, percent = flags[flag_name]
if everyone:
return True
if user is not None and user.is_authenticated:
if staff and user.is_staff:
return True
if percent and _bucket(flag_name, user.pk) < percent:
return True
return False
Design choices:
- Cached as a single dictionary with a short timeout, so checking many flags per request costs at most one cache read and, every 30 seconds, one query.
- Deterministic percentage rollout: hashing flag name and user ID puts each user consistently in or out of the rollout, and different flags use different buckets.
- Safe default: if the database or cache fails, the function returns
default, which should be the old, known-good behavior.
Invalidate the cache when flags change so toggles take effect immediately:
# flags/signals.py
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .models import Flag
from .service import CACHE_KEY
@receiver([post_save, post_delete], sender=Flag)
def clear_flag_cache(sender, **kwargs):
cache.delete(CACHE_KEY)
Register the model in the admin so staff can toggle flags, and restrict who can change them with permissions.
3. Using an established package
django-waffle provides flags (per user, group, percentage, testing overrides), switches (simple on/off), and samples (random percentages), with admin integration, template tags, and view decorators:
# views.py
from waffle import flag_is_active
def checkout(request):
if flag_is_active(request, "new_checkout"):
return new_checkout(request)
return legacy_checkout(request)
{% load waffle_tags %}
{% flag "new_checkout" %}
<a href="{% url 'checkout' %}">Try the new checkout</a>
{% endflag %}
Hosted flag services offer targeting rules, audit logs, and SDKs across languages; they add an external dependency, so check how their SDK behaves when the service is unreachable.
4. Structure code around flags
Keep the branch small and at the edge:
def checkout(request):
if is_enabled("new_checkout", request.user):
return new_checkout_view(request)
return legacy_checkout_view(request)
Avoid scattering the same flag through many templates, models, and services. One or two decision points per flag make removal straightforward later.
For single-page apps and mobile clients, evaluate flags on the server and include results in an API response:
def client_config(request):
return JsonResponse({
"flags": {
"new_checkout": is_enabled("new_checkout", request.user),
}
})
Never rely on client-side flags for security; a hidden button does not protect the endpoint behind it.
5. Flags and database changes
Flags do not remove the need for backward-compatible migrations. Deploy schema changes that both code paths can use (for example add nullable columns first), enable the flag, and remove old columns only after the flag is removed. If a flag protects new tables, create them before enabling the flag.
6. Gradual rollout
A typical sequence for a risky feature:
- Deploy with the flag off everywhere.
- Enable for staff; test in production with real data.
- Enable for 5 percent of users; watch error rates, latency, and business metrics.
- Increase to 25, 50, then 100 percent over hours or days.
- Remove the flag and the old code path in a later release.
Record each change (who, when, what) either through the admin's history or a flag service's audit log, so incidents can be correlated with flag changes.
7. Kill switches
For dependencies that can fail or overload the system, build explicit switches that default to safety:
def recommendations_for(user):
if not is_enabled("recommendations", user, default=False):
return []
try:
return recommendation_client.fetch(user.pk, timeout=0.5)
except RecommendationError:
return []
During an incident, turning the flag off removes the load and the failure mode without a deploy.
8. Test both paths
Every release toggle doubles the number of paths through its code. Test both in CI:
from unittest import mock
from django.test import TestCase
class CheckoutFlagTests(TestCase):
def test_legacy_checkout_when_flag_off(self):
with mock.patch("shop.views.is_enabled", return_value=False):
response = self.client.get("/checkout/")
self.assertTemplateUsed(response, "shop/legacy_checkout.html")
def test_new_checkout_when_flag_on(self):
with mock.patch("shop.views.is_enabled", return_value=True):
response = self.client.get("/checkout/")
self.assertTemplateUsed(response, "shop/new_checkout.html")
Patch the function where it is imported in the module under test.
9. Clean up
Flags left in the codebase become permanent complexity. Keep a list with owner, purpose, and expiry:
| Flag | Type | Owner | Created | Remove after |
|---|---|---|---|---|
new_checkout | release | payments team | 2026-09-01 | 2026-10-15 |
recommendations | kill switch | platform team | 2026-03-10 | permanent |
Search for flag names regularly and remove release toggles once they have been at 100 percent for a full release cycle.
Verification check:
- toggling a flag in the admin changes behavior within the cache timeout (or immediately with invalidation)
- flag checks add negligible latency (one cache read per request at most)
- disabling the flag storage (for example stopping Redis in staging) falls back to safe defaults
- CI tests cover both states of every active release toggle
Explanation
Feature flags move the decision about who sees new code from deploy time to runtime. That makes deploys smaller and safer, because unfinished or risky work ships disabled, and it gives operators a fast, targeted control during incidents. The engineering discipline around flags (safe defaults, deterministic rollouts, tests for both paths, and removal) determines whether they reduce risk or add long-term complexity.
Edge cases / notes
Caching and flags
Pages cached by a CDN or cache_page may include content rendered with one flag state. Do not cache pages whose output depends on per-user flags, or include the flag state in cache keys.
Anonymous users
Percentage rollouts for anonymous visitors need a stable identifier, such as a first-party cookie set on the first visit. Without one, users switch between variants on each request.
Background tasks
Tasks run later than the request that queued them. Evaluate flags when the task runs, or pass the evaluated value as an argument, and decide consciously which behavior you want.
Common mistakes
- Using random percentages per request, so users flip between old and new behavior.
- Defaulting to "enabled" when flag lookups fail.
- Querying the flag table on every check without caching.
- Never removing release toggles.
Next steps
- Combine flags with environment switching in Blue-Green Deployments for Django.
- Keep rollback options open with How to Roll Back a Django Release Safely.
- Test flags before production with Set Up a Staging Environment for Django.
- Plan compatible schema changes with Django Migration Strategy for Safe Deployments.
FAQ
What are feature flags in Django?
Runtime switches that decide whether code paths are active for a request, user, or percentage of users, so new features can be deployed disabled and enabled later without a new deploy.
Should I build my own feature flags or use django-waffle?
A small database model with caching is enough for a few flags. django-waffle adds per-user and group targeting, testing helpers, and admin tooling, which helps as flag usage grows.
How do I roll out a Django feature to a percentage of users?
Hash a stable user identifier together with the flag name into a bucket from 0 to 99, and enable the feature when the bucket is below the rollout percentage. Users stay consistently in or out.
Do feature flags replace backward-compatible migrations?
No. Both code paths run against the same database, so schema changes must still be compatible with the old and new code until the flag and old path are removed.