🚩 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¶
3. Access Admin Interface¶
Visit /admin/waffle/ to manage flags, switches, and samples.
Creating Flags¶
Via Admin Interface¶
- Go to
/admin/waffle/flag/ - Click "Add Flag"
- Configure:
- Name:
new_dashboard - Everyone: Off (default)
- Percent: 0 (or 10 for 10% rollout)
- Superusers: On (enable for staff testing)
- Groups: Select specific groups
- 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):
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¶
- Use descriptive names -
new_dashboard_redesignnotflag_123 - Document flags - Add notes in admin about what each flag does
- Clean up old flags - Remove flags after full rollout
- Monitor metrics - Track conversion rates for each variant
- Test both paths - Write tests for flag on AND off
- Use switches for kill switches - Faster than flags
- Avoid flag sprawl - Consolidate related flags
- 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 contextcreate_default_flags()/create_default_switches()/create_default_samples()- used by theinit_feature_flagsmanagement command
Further Reading¶
Related Documentation¶
- Feature Gating - Subscription-based access control
- Stripe Integration - Payment and subscription management
- Teams & Organizations - Multi-tenant team management