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.

Deploy and release separatedDeploycode shipped, flag offEnablesmall audienceObserveerrors, metricsRoll outor switch offA flag turns a risky release into a change you can undo in seconds.

Quick answer

  1. Decide the flag type: release toggle (temporary), kill switch or operational toggle (long-lived), permission or experiment flag.
  2. Start simple: a small database-backed flag model cached in Redis, or an established package such as django-waffle.
  3. Evaluate flags on the server; for frontends, pass evaluated values to templates or API responses.
  4. Roll out gradually: staff first, then a percentage, then everyone.
  5. Keep kill switches for risky dependencies and default them to the safe behavior when flag data is unavailable.
  6. Test both states of every flag in CI.
  7. Remove release flags after full rollout; track owners and expiry dates.

Step-by-step solution

1. Know which kind of flag you need

TypeLifetimeExample
Release toggledays to weeksnew checkout flow hidden until ready
Kill switch / ops togglelong-liveddisable expensive recommendations under load
Permission flaglong-livedbeta features for selected customers
Experiment flagweeksA/B test with measured outcomes
four kinds of flagPurposeLifetimeRelease flaghide unfinished work!days to weeksKill switchturn off a risky feature fast✓long-livedExperimentcompare variants!until decidedPermission flagplans, beta groups✓long-livedShort-lived flags need an owner and a removal datedjango-deployment.com
Mixing kinds in one system is fine; forgetting which kind a flag is is not.

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.

flags switch code, not schemasFlag off, schema changed✕migration dropped the old column✕flag-off path still reads it✕turning the flag off breaks the siteExpand, flag, contract✓add new schema, keep the old✓both code paths work✓drop old schema after the flag is goneBoth sides of a flag must work on the current schemadjango-deployment.com
The flag gives a fast rollback only if the schema allows both paths.

6. Gradual rollout

A typical sequence for a risky feature:

  1. Deploy with the flag off everywhere.
  2. Enable for staff; test in production with real data.
  3. Enable for 5 percent of users; watch error rates, latency, and business metrics.
  4. Increase to 25, 50, then 100 percent over hours or days.
  5. Remove the flag and the old code path in a later release.
widen the audience in stepsDay 1: staff onlyabout 1%Day 210%Day 450%Day 7100%0%25%50%75%100%usersPause at each step until error rates and latency look normaldjango-deployment.com
Bucket users by a stable hash of their ID so each user keeps the same experience.

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:

FlagTypeOwnerCreatedRemove after
new_checkoutreleasepayments team2026-09-012026-10-15
recommendationskill switchplatform team2026-03-10permanent
finish the job: remove the flagFlag at 100% for a full cycleno incidentsOld code path deletedand its testsFlag checks removedviews, templates, tasksFlag row deletedin a later releaseOld schema contractedif one was keptEvery stale flag is a code path nobody tests any moredjango-deployment.com
Delete the flag row after the code, so older instances never see it missing.

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

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.

Keep going