Skip to content

🚩 Feature Flags

Enable A/B testing, gradual rollouts, and feature toggles with django-waffle.

Overview

Feature flags allow you to:

  • A/B test new features with a subset of users
  • Gradually roll out features to minimize risk
  • Toggle features on/off without deploying code
  • Test in production safely with specific users/groups
  • Emergency rollback by disabling a flag

Django Keel integrates django-waffle for production-ready feature flagging.

Types of Flags

1. Flags (User-based)

Enable features for specific users:

from waffle import flag_is_active

if flag_is_active(request, "new_dashboard"):
    # Show new dashboard
    return render(request, "dashboard_v2.html")
else:
    # Show old dashboard
    return render(request, "dashboard_v1.html")

2. Switches (Global)

Enable features globally for everyone:

from waffle import switch_is_active

if switch_is_active("maintenance_mode"):
    return render(request, "maintenance.html")

3. Samples (Percentage-based)

Enable features for a percentage of users:

from waffle import sample_is_active

if sample_is_active("beta_features"):
    # 10% of users see beta features
    show_beta_features()

Setup

1. Enable Feature Flags

When generating your project, feature flags are included by default in Django Keel projects.

Check config/settings/base.py:

INSTALLED_APPS = [
    # ...
    "waffle",
    # ...
]

MIDDLEWARE = [
    # ...
    "waffle.middleware.WaffleMiddleware",
    # ...
]

2. Run Migrations

python manage.py migrate waffle

3. Access Admin Interface

Visit /admin/waffle/ to manage flags, switches, and samples.

Creating Flags

Via Admin Interface

  1. Go to /admin/waffle/flag/
  2. Click "Add Flag"
  3. Configure:
  4. Name: new_dashboard
  5. Everyone: Off (default)
  6. Percent: 0 (or 10 for 10% rollout)
  7. Superusers: On (enable for staff testing)
  8. Groups: Select specific groups
  9. Users: Add specific users

Via Django Shell

from waffle.models import Flag

# Create a flag
flag = Flag.objects.create(
    name="new_dashboard",
    everyone=False,
    percent=10.0,  # 10% of users
    superusers=True,
)

# Enable for specific users
from django.contrib.auth import get_user_model

User = get_user_model()
user = User.objects.get(email="beta@example.com")
flag.users.add(user)

Via Management Command

Django Keel ships an init_feature_flags command that creates a default set of flags (e.g. beta_features, api_v2), switches (e.g. maintenance_mode, read_only_mode), and samples (e.g. new_ui_rollout):

python manage.py init_feature_flags

django-waffle's own commands also work:

# Create a flag
python manage.py waffle_flag new_dashboard --create

# Enable for everyone
python manage.py waffle_flag new_dashboard --everyone

# Enable for 25% of users
python manage.py waffle_flag new_dashboard --percent 25

# Disable
python manage.py waffle_flag new_dashboard --deactivate

Usage in Views

Function-Based Views

from waffle.decorators import waffle_flag


@waffle_flag("new_dashboard")
def dashboard_view(request):
    """Only accessible when flag is active."""
    return render(request, "dashboard_v2.html")

Class-Based Views

from waffle.mixins import WaffleFlagMixin


class DashboardView(WaffleFlagMixin, TemplateView):
    waffle_flag = "new_dashboard"
    template_name = "dashboard_v2.html"

Conditional Logic

from waffle import flag_is_active


def dashboard_view(request):
    if flag_is_active(request, "new_dashboard"):
        context = get_new_dashboard_context()
        template = "dashboard_v2.html"
    else:
        context = get_old_dashboard_context()
        template = "dashboard_v1.html"

    return render(request, template, context)

Usage in Templates

Check Flag Status

{% load waffle_tags %}

{% flag "new_dashboard" %}
  <div class="new-dashboard">
    <!-- New dashboard content -->
  </div>
{% else %}
  <div class="old-dashboard">
    <!-- Old dashboard content -->
  </div>
{% endflag %}

Check Switch Status

{% load waffle_tags %}

{% switch "maintenance_mode" %}
  <div class="alert alert-warning">
    Site is under maintenance. Some features may be unavailable.
  </div>
{% endswitch %}

Usage in API (DRF)

Conditional Endpoints

from rest_framework.decorators import api_view
from waffle import flag_is_active


@api_view(["GET"])
def api_dashboard(request):
    if flag_is_active(request, "new_dashboard_api"):
        serializer = DashboardV2Serializer
    else:
        serializer = DashboardV1Serializer

    data = get_dashboard_data()
    return Response(serializer(data).data)

Feature Availability in Response

from waffle import flag_is_active


class FeatureListView(APIView):
    def get(self, request):
        return Response(
            {
                "features": {
                    "new_dashboard": flag_is_active(request, "new_dashboard"),
                    "dark_mode": flag_is_active(request, "dark_mode"),
                    "ai_assistant": flag_is_active(request, "ai_assistant"),
                }
            }
        )

Common Patterns

Gradual Rollout

Start with 1% and increase gradually:

# Day 1: 1% of users
flag.percent = 1.0
flag.save()

# Day 3: 10% of users (monitor metrics)
flag.percent = 10.0
flag.save()

# Day 7: 50% of users
flag.percent = 50.0
flag.save()

# Day 14: 100% of users
flag.everyone = True
flag.save()

Canary Releases

Enable for internal users first:

# Enable for superusers only
flag.superusers = True
flag.everyone = False

# Then enable for beta testers group
beta_group = Group.objects.get(name="Beta Testers")
flag.groups.add(beta_group)

# Finally, gradual rollout to all users
flag.percent = 10.0
flag.save()

A/B Testing

Split users into control and treatment groups:

# Create two flags for A/B test
flag_a = Flag.objects.create(name="checkout_v1", percent=50.0)
flag_b = Flag.objects.create(name="checkout_v2", percent=50.0)

# In view
if flag_is_active(request, "checkout_v2"):
    return checkout_v2(request)
else:
    return checkout_v1(request)

# Track conversions
if flag_is_active(request, "checkout_v2"):
    analytics.track("purchase", {"variant": "v2"})

Emergency Kill Switch

Quickly disable problematic features:

from waffle.models import Switch

# Create kill switch
Switch.objects.create(name="new_payment_processor", active=True)

# In code
from waffle import switch_is_active

if switch_is_active("new_payment_processor"):
    process_payment_v2()
else:
    process_payment_v1()  # Fallback to old processor

# Emergency disable (via admin or shell)
switch = Switch.objects.get(name="new_payment_processor")
switch.active = False
switch.save()

Testing with Feature Flags

Override Flags in Tests

from waffle.testutils import override_flag


class DashboardTestCase(TestCase):
    @override_flag("new_dashboard", active=True)
    def test_new_dashboard(self):
        response = self.client.get("/dashboard/")
        self.assertContains(response, "New Dashboard")

    @override_flag("new_dashboard", active=False)
    def test_old_dashboard(self):
        response = self.client.get("/dashboard/")
        self.assertContains(response, "Old Dashboard")

Test Both Variants

from waffle.testutils import override_flag


class CheckoutTestCase(TestCase):
    def test_checkout_variants(self):
        # Test new checkout
        with override_flag("checkout_v2", active=True):
            response = self.client.post("/checkout/", data)
            self.assertEqual(response.status_code, 200)

        # Test old checkout
        with override_flag("checkout_v2", active=False):
            response = self.client.post("/checkout/", data)
            self.assertEqual(response.status_code, 200)

Monitoring & Analytics

Track Flag Usage

import logging
from waffle import flag_is_active

logger = logging.getLogger(__name__)


def my_view(request):
    flag_active = flag_is_active(request, "new_feature")

    # Log flag usage
    logger.info(
        "feature_flag_check",
        extra={
            "flag_name": "new_feature",
            "user_id": request.user.id,
            "is_active": flag_active,
        },
    )

    if flag_active:
        return new_feature_view(request)
    return old_feature_view(request)

Integration with Analytics

from waffle import flag_is_active


def checkout_view(request):
    variant = "v2" if flag_is_active(request, "checkout_v2") else "v1"

    # Track with your analytics service
    analytics.track(
        request.user.id,
        "checkout_started",
        {
            "variant": variant,
        },
    )

    return render(request, f"checkout_{variant}.html")

Best Practices

  1. Use descriptive names - new_dashboard_redesign not flag_123
  2. Document flags - Add notes in admin about what each flag does
  3. Clean up old flags - Remove flags after full rollout
  4. Monitor metrics - Track conversion rates for each variant
  5. Test both paths - Write tests for flag on AND off
  6. Use switches for kill switches - Faster than flags
  7. Avoid flag sprawl - Consolidate related flags
  8. Set expiration dates - Remove temporary flags after rollout

Troubleshooting

Flag Not Working

Check middleware is installed:

# config/settings/base.py
MIDDLEWARE = [
    # ...
    "waffle.middleware.WaffleMiddleware",  # Must be here
    # ...
]

Check flag configuration:

from waffle.models import Flag

flag = Flag.objects.get(name="my_flag")
print(f"Everyone: {flag.everyone}")
print(f"Percent: {flag.percent}")
print(f"Active: {flag.is_active_for_user(user)}")

Flag Always Returns False

Check user authentication:

# Flags require authenticated users for user-based checks
if not request.user.is_authenticated:
    # Flag will be False unless everyone=True
    pass

Use switches for anonymous users:

from waffle import switch_is_active

# Works for all users (anonymous or authenticated)
if switch_is_active("new_feature"):
    show_feature()

Performance Issues

Cache flag lookups:

from django.core.cache import cache
from waffle import flag_is_active


def get_flag_status(request, flag_name):
    cache_key = f"flag_{flag_name}_{request.user.id}"
    status = cache.get(cache_key)

    if status is None:
        status = flag_is_active(request, flag_name)
        cache.set(cache_key, status, 60)  # Cache for 1 minute

    return status

API Reference

Django Keel provides helpers in apps/core/feature_flags.py.

Helper Functions

from apps.core.feature_flags import (
    is_feature_enabled,
    is_switch_enabled,
    is_in_sample,
)

# Check a flag - pass the request, or a user when no request is available
if is_feature_enabled("new_dashboard", request=request):
    show_new_dashboard()

if is_feature_enabled("new_dashboard", user=some_user):
    ...

# Check a switch (global on/off)
if is_switch_enabled("maintenance_mode"):
    ...

# Check a sample (percentage rollout)
if is_in_sample("new_ui_rollout", request):
    ...

Decorators

from apps.core.feature_flags import feature_flag, feature_switch, feature_sample


@feature_flag("new_dashboard", redirect_to="/pricing/")
def new_dashboard_view(request): ...


@feature_switch("maintenance_mode", redirect_to="/maintenance/")
def gated_view(request): ...


@feature_sample("new_ui_rollout")
def new_ui_view(request): ...

Each accepts redirect_to, message, and (for feature_flag/feature_switch) ajax_response to return JSON instead of redirecting.

Mixins

from apps.core.feature_flags import (
    FeatureFlagMixin,
    FeatureSwitchMixin,
    FeatureSampleMixin,
)


class MyView(FeatureFlagMixin, TemplateView):
    feature_flag_name = "new_feature"
    feature_redirect_url = "/pricing/"


class ApiView(FeatureSwitchMixin, View):
    feature_switch_name = "api_enabled"


class NewCheckoutView(FeatureSampleMixin, View):
    feature_sample_name = "new_checkout"
    feature_redirect_url = "/checkout/"

Other Helpers

  • get_feature_flags_for_template(request) - returns {"feature_flags": {...}} for template context
  • create_default_flags() / create_default_switches() / create_default_samples() - used by the init_feature_flags management command

Further Reading