Python's flexibility is both its greatest strength and, when it comes to feature flag debt, its most dangerous quality. In statically typed languages, removing a flag triggers compiler errors that guide you through the cleanup. In Python, removing a flag triggers nothing. No warnings, no errors, no red underlines in your IDE. The dead code from a stale flag sits silently alongside active code, passing imports, surviving linting, and occasionally confusing every developer who encounters it.
In our experience, Python applications tend to accumulate stale feature flags faster than teams realize. Each stale flag leaves behind dead code paths, conditional imports, and test fixtures that inflate the codebase and slow down development. In Python, where "there should be one obvious way to do it," stale flags create two ways to do everything -- and neither is obvious.
The Python flag landscape
Python web applications use feature flags differently depending on the framework, but the underlying patterns share common traits. Understanding these framework-specific patterns is the first step toward effective cleanup.
Django: The full-stack flag challenge
Django's batteries-included philosophy means flags can live in many places: template tags, middleware, model managers, management commands, admin panels, and celery tasks. This surface area makes Django flag debt particularly widespread.
django-waffle: The most common Django flag library
# views.py
import waffle
def dashboard_view(request):
if waffle.flag_is_active(request, 'new-dashboard'):
return render(request, 'dashboard/new.html', get_new_dashboard_context(request))
return render(request, 'dashboard/legacy.html', get_legacy_dashboard_context(request))
# In Django templates
{% load waffle_tags %}
{% flag "new-dashboard" %}
<div class="new-metrics-panel">
{{ new_metrics_html }}
</div>
{% else %}
<div class="legacy-metrics-panel">
{{ legacy_metrics_html }}
</div>
{% endflag %}
Flag patterns in Django middleware:
# middleware.py
class FeatureMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Flag controlling rate limiting strategy
if waffle.flag_is_active(request, 'rate-limit-v2'):
request.rate_limiter = TokenBucketLimiter()
else:
request.rate_limiter = SlidingWindowLimiter()
# Flag controlling response compression
if waffle.flag_is_active(request, 'brotli-compression'):
request.compressor = BrotliCompressor()
else:
request.compressor = GzipCompressor()
response = self.get_response(request)
return response
Flag patterns in Django model managers:
# models.py
class ProductManager(models.Manager):
def get_featured(self, request):
if waffle.flag_is_active(request, 'ml-recommendations'):
return self._get_ml_featured(request.user)
return self.filter(is_featured=True).order_by('-created_at')[:10]
def _get_ml_featured(self, user):
scores = recommendation_service.score_products(user)
product_ids = [s.product_id for s in sorted(scores, reverse=True)[:10]]
return self.filter(id__in=product_ids)
The challenge with Django is that flags appear in at least four distinct layers: views, templates, middleware, and models. A single flag cleanup may require changes across all four layers, plus migrations if the flag was stored in the database via django-waffle.
FastAPI: Flags in the dependency injection layer
FastAPI's dependency injection system creates a natural place for feature flags, but also a natural place for them to hide:
# dependencies.py
from fastapi import Depends, Request
async def get_search_engine(request: Request) -> SearchEngine:
if await is_flag_enabled(request, 'elasticsearch-v8'):
return ElasticsearchV8Client(settings.ES_V8_URL)
return ElasticsearchV7Client(settings.ES_V7_URL)
async def get_cache_backend(request: Request) -> CacheBackend:
if await is_flag_enabled(request, 'redis-cluster'):
return RedisClusterCache(settings.REDIS_CLUSTER_NODES)
return RedisSingleCache(settings.REDIS_URL)
# routes.py
@router.get("/search")
async def search(
query: str,
engine: SearchEngine = Depends(get_search_engine),
cache: CacheBackend = Depends(get_cache_backend),
):
cached = await cache.get(f"search:{query}")
if cached:
return cached
results = await engine.search(query)
await cache.set(f"search:{query}", results, ttl=300)
return results
FastAPI middleware flags:
# middleware.py
from starlette.middleware.base import BaseHTTPMiddleware
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if await is_flag_enabled(request, 'jwt-v2-validation'):
user = await validate_jwt_v2(request.headers.get('Authorization'))
else:
user = await validate_jwt_v1(request.headers.get('Authorization'))
request.state.user = user
response = await call_next(request)
return response
FastAPI's Depends() system means flag-controlled dependencies are invisible at the route handler level. The search function above has no idea that it might receive an Elasticsearch v7 or v8 client -- the flag decision is entirely hidden in the dependency. When the flag becomes stale, the dead dependency function survives because it is still a valid dependency provider.
Flask: Decorator and extension patterns
Flask's decorator-centric design leads to flag patterns that wrap route handlers:
# decorators.py
from functools import wraps
def feature_required(flag_name, fallback=None):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if is_flag_enabled(flag_name):
return f(*args, **kwargs)
if fallback:
return fallback(*args, **kwargs)
abort(404)
return decorated_function
return decorator
# routes.py
@app.route('/api/analytics')
@feature_required('advanced-analytics', fallback=basic_analytics_view)
def advanced_analytics_view():
data = analytics_service.get_advanced_metrics()
return jsonify(data)
def basic_analytics_view():
data = analytics_service.get_basic_metrics()
return jsonify(data)
Flask extension flags:
# extensions.py
class FeatureFlaggedCache:
def __init__(self, app=None):
if app:
self.init_app(app)
def init_app(self, app):
if is_flag_enabled('memcached-backend'):
self.backend = MemcachedBackend(app.config['MEMCACHED_SERVERS'])
else:
self.backend = RedisBackend(app.config['REDIS_URL'])
cache = FeatureFlaggedCache()
Flask's decorator pattern is particularly insidious for flag debt because the decorator hides the flag completely from the route definition. A developer reading @feature_required('advanced-analytics') might assume the flag is actively controlling access, when in reality it has been 100% enabled for months and is just adding an unnecessary function call to every request.
Python-specific detection challenges
Dynamic typing makes dead code invisible
In statically typed languages, removing a flag that changes a return type from OldResponse to NewResponse would trigger type errors at every call site. In Python, both return types are equally valid at every call site, and no tool will flag the inconsistency:
def get_pricing(user, plan_id):
if is_flag_enabled('new-pricing-engine'):
# Returns a PricingResult with detailed breakdown
return new_pricing_engine.calculate(user, plan_id)
# Returns a simple dict with just the total
return {'total': legacy_calculate_price(plan_id)}
When the flag is permanently enabled, the dict-returning branch is dead. But every consumer of get_pricing handles both return types because Python's duck typing never complained. The dead code path's return format lives on in downstream isinstance checks, dict.get() calls, and try/except blocks throughout the codebase.
Decorator-based flags resist static analysis
Python decorators transform functions at import time, making static analysis of flag behavior difficult:
@feature_flag('experiment-checkout')
def checkout_handler(request):
# This function may or may not be the active handler
pass
# Without running the code, static analysis cannot determine:
# 1. Whether the decorator replaces or wraps the function
# 2. What the fallback behavior is
# 3. Whether the flag is currently enabled
Conditional imports create hidden dependencies
Python's ability to import conditionally based on flags creates dependencies that only exist at runtime:
if is_flag_enabled('new-payment-processor'):
from payments.stripe_v2 import StripeProcessor as PaymentProcessor
else:
from payments.stripe_v1 import StripeProcessor as PaymentProcessor
When the flag becomes stale, the payments.stripe_v1 module and everything it imports becomes dead code. But no import analysis tool will catch it because the import is valid Python syntax that does not error until the code path is actually executed.
Quantifying Python flag debt
| Detection Challenge | Impact | Detection Difficulty |
|---|---|---|
| Dead code in flag-off branches | Codebase bloat, confusion | High (no type system help) |
| Stale decorator wrappers | Performance overhead per request | Medium |
| Orphaned imports and modules | Unused dependencies, larger deployments | High |
| Dead test fixtures and factories | Slower test suites, maintenance burden | Medium |
| Stale template conditionals (Django) | Template rendering overhead | High |
| Unused dependency providers (FastAPI) | Hidden dead code | High |
| Dead Celery tasks behind flags | Wasted worker resources if accidentally triggered | Critical |
Detection strategies for Python
Python's AST module
Python ships with a powerful AST module that can parse source code without executing it:
import ast
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass
class FlagReference:
file: str
line: int
flag_name: str
context: str # 'call', 'decorator', 'template'
def find_flag_references(directory: str) -> list[FlagReference]:
references = []
for path in Path(directory).rglob('*.py'):
try:
tree = ast.parse(path.read_text())
except SyntaxError:
continue
for node in ast.walk(tree):
# Find function calls: is_flag_enabled('name'), waffle.flag_is_active(req, 'name')
if isinstance(node, ast.Call):
flag_name = extract_flag_name(node)
if flag_name:
references.append(FlagReference(
file=str(path),
line=node.lineno,
flag_name=flag_name,
context='call',
))
# Find decorator usage: @feature_required('name')
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
for decorator in node.decorator_list:
if isinstance(decorator, ast.Call):
flag_name = extract_decorator_flag(decorator)
if flag_name:
references.append(FlagReference(
file=str(path),
line=decorator.lineno,
flag_name=flag_name,
context='decorator',
))
return references
def extract_flag_name(node: ast.Call) -> str | None:
"""Extract flag name from calls like is_flag_enabled('flag-name')"""
if isinstance(node.func, ast.Name) and node.func.id in (
'is_flag_enabled', 'flag_is_active', 'check_flag'
):
if node.args and isinstance(node.args[0], ast.Constant):
return node.args[0].value
# Handle waffle.flag_is_active(request, 'flag-name')
if isinstance(node.func, ast.Attribute) and node.func.attr == 'flag_is_active':
if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
return node.args[1].value
return None
This script can be integrated into CI to produce a flag inventory on every build.
Tree-sitter for cross-framework detection
Tree-sitter's Python grammar handles all the syntactic patterns across Django, FastAPI, and Flask without needing to understand each framework's semantics:
# Tree-sitter query for Python flag detection (S-expression syntax)
# Direct function calls: is_flag_enabled('key')
(call
function: (identifier) @func_name
arguments: (argument_list
(string (string_content) @flag_key))
(#any-of? @func_name
"is_flag_enabled"
"flag_is_active"
"check_flag"
"feature_enabled"))
# Method calls: waffle.flag_is_active(request, 'key')
(call
function: (attribute
object: (identifier) @obj
attribute: (identifier) @method)
arguments: (argument_list
(_)
(string (string_content) @flag_key))
(#eq? @method "flag_is_active"))
# Decorator usage: @feature_required('key')
(decorator
(call
function: (identifier) @decorator_name
arguments: (argument_list
(string (string_content) @flag_key))
(#any-of? @decorator_name
"feature_required"
"feature_flag"
"flag_gate")))
Tree-sitter is the detection engine that tools like FlagShark use to scan Python codebases. It handles edge cases that regex-based approaches miss -- multiline function calls, string concatenation in flag names, and nested decorator syntax -- and works across all Python frameworks with a single grammar.
Django template scanning
Django templates require separate scanning because they use a template language, not Python:
import re
from pathlib import Path
WAFFLE_TAG_PATTERN = re.compile(
r'\{%\s*flag\s+"([^"]+)"\s*%\}',
re.MULTILINE
)
WAFFLE_SWITCH_PATTERN = re.compile(
r'\{%\s*switch\s+"([^"]+)"\s*%\}',
re.MULTILINE
)
def scan_django_templates(template_dir: str) -> dict[str, list[str]]:
"""Returns a mapping of flag names to template files that reference them."""
flag_usage = {}
for path in Path(template_dir).rglob('*.html'):
content = path.read_text()
for pattern in [WAFFLE_TAG_PATTERN, WAFFLE_SWITCH_PATTERN]:
for match in pattern.finditer(content):
flag_name = match.group(1)
flag_usage.setdefault(flag_name, []).append(str(path))
return flag_usage
Safe removal patterns for Python
Django flag cleanup: The full stack removal
Removing a flag from a Django application requires touching multiple layers. Here is the complete process for removing a stale new-dashboard waffle flag:
Step 1: Remove from views
# Before
def dashboard_view(request):
if waffle.flag_is_active(request, 'new-dashboard'):
return render(request, 'dashboard/new.html', get_new_dashboard_context(request))
return render(request, 'dashboard/legacy.html', get_legacy_dashboard_context(request))
# After
def dashboard_view(request):
return render(request, 'dashboard/new.html', get_new_dashboard_context(request))
Step 2: Remove from templates
<!-- Before -->
{% load waffle_tags %}
{% flag "new-dashboard" %}
<div class="new-metrics-panel">{{ new_metrics_html }}</div>
{% else %}
<div class="legacy-metrics-panel">{{ legacy_metrics_html }}</div>
{% endflag %}
<!-- After -->
<div class="new-metrics-panel">{{ new_metrics_html }}</div>
Step 3: Remove from middleware
# Before
class DashboardMiddleware:
def __call__(self, request):
if waffle.flag_is_active(request, 'new-dashboard'):
request.dashboard_version = 'v2'
else:
request.dashboard_version = 'v1'
return self.get_response(request)
# After
class DashboardMiddleware:
def __call__(self, request):
request.dashboard_version = 'v2'
return self.get_response(request)
Step 4: Clean up the database
# Create a Django migration to remove the waffle flag
from django.db import migrations
def remove_stale_flags(apps, schema_editor):
Flag = apps.get_model('waffle', 'Flag')
Flag.objects.filter(name='new-dashboard').delete()
class Migration(migrations.Migration):
dependencies = [
('waffle', '0004_auto_20200422_2029'),
('yourapp', '0050_previous_migration'),
]
operations = [
migrations.RunPython(remove_stale_flags, migrations.RunPython.noop),
]
Step 5: Delete dead code
# Delete these functions entirely:
# - get_legacy_dashboard_context()
# - Any helper functions only used by the legacy path
# Delete these templates:
# - dashboard/legacy.html
Step 6: Clean up tests
# Before
class DashboardViewTest(TestCase):
def test_new_dashboard_when_flag_active(self):
with self.settings(FLAGS={'new-dashboard': {'everyone': True}}):
response = self.client.get('/dashboard/')
self.assertTemplateUsed(response, 'dashboard/new.html')
def test_legacy_dashboard_when_flag_inactive(self):
with self.settings(FLAGS={'new-dashboard': {'everyone': False}}):
response = self.client.get('/dashboard/')
self.assertTemplateUsed(response, 'dashboard/legacy.html')
# After
class DashboardViewTest(TestCase):
def test_dashboard(self):
response = self.client.get('/dashboard/')
self.assertTemplateUsed(response, 'dashboard/new.html')
FastAPI flag cleanup: Simplifying dependencies
# Before
async def get_search_engine(request: Request) -> SearchEngine:
if await is_flag_enabled(request, 'elasticsearch-v8'):
return ElasticsearchV8Client(settings.ES_V8_URL)
return ElasticsearchV7Client(settings.ES_V7_URL)
# After
async def get_search_engine() -> SearchEngine:
return ElasticsearchV8Client(settings.ES_V8_URL)
Note that the request parameter may no longer be needed after removing the flag check. Update the dependency signature and remove the Request import if no other dependency needs it.
Then delete the dead implementation:
# DELETE: ElasticsearchV7Client and its module
# DELETE: settings.ES_V7_URL from configuration
# DELETE: elasticsearch7 from requirements.txt (if no other code uses it)
This last point is critical for Python. Unlike compiled languages where the linker removes unused code, Python packages listed in requirements.txt are installed and deployed even when no code imports them. Removing a stale flag that was the last consumer of a library can reduce your deployment size and attack surface.
Flask flag cleanup: Unwrapping decorators
# Before
@app.route('/api/analytics')
@feature_required('advanced-analytics', fallback=basic_analytics_view)
def advanced_analytics_view():
data = analytics_service.get_advanced_metrics()
return jsonify(data)
def basic_analytics_view():
data = analytics_service.get_basic_metrics()
return jsonify(data)
# After
@app.route('/api/analytics')
def advanced_analytics_view():
data = analytics_service.get_advanced_metrics()
return jsonify(data)
# DELETE: basic_analytics_view function
# DELETE: analytics_service.get_basic_metrics() if no other caller
Pytest strategies for validating flag removal
Testing flag removal in Python requires more discipline than in typed languages because there is no compiler to catch mistakes. Here is a pytest strategy that validates cleanup:
Before removal: Characterization tests
Before removing a flag, write characterization tests that capture the current behavior of the winning path:
# test_characterization.py
"""
Characterization tests for the 'new-dashboard' flag removal.
These tests capture the expected behavior BEFORE the flag is removed.
After removal, these tests should still pass unchanged.
"""
class TestDashboardBehaviorCharacterization:
"""These tests describe the behavior we want to preserve."""
def test_dashboard_returns_new_metrics_format(self, client):
response = client.get('/api/dashboard/metrics')
data = response.json()
assert 'breakdown' in data # New format has breakdown
assert 'trend' in data # New format has trend data
assert isinstance(data['breakdown'], list)
def test_dashboard_uses_v2_serialization(self, client):
response = client.get('/api/dashboard/export')
assert response.headers['Content-Type'] == 'application/json; charset=utf-8'
data = response.json()
assert 'metadata' in data # V2 includes metadata
def test_dashboard_performance_baseline(self, client, benchmark):
"""Ensure the winning path performance is captured."""
result = benchmark(client.get, '/api/dashboard/metrics')
assert result.status_code == 200
After removal: Verify and clean up
After the flag is removed, run the characterization tests to confirm behavior is preserved, then clean up flag-specific test infrastructure:
# conftest.py - Remove flag fixtures
# DELETE this fixture:
# @pytest.fixture
# def with_new_dashboard(settings):
# settings.FLAGS = {'new-dashboard': {'everyone': True}}
# yield
# settings.FLAGS = {}
# DELETE this fixture:
# @pytest.fixture
# def without_new_dashboard(settings):
# settings.FLAGS = {'new-dashboard': {'everyone': False}}
# yield
# settings.FLAGS = {}
Test coverage check
After removal, run coverage analysis to find dead code that tests no longer reach:
# Run tests with coverage
pytest --cov=yourapp --cov-report=html
# Look for modules with 0% coverage - they may be dead code
# from the removed flag's disabled branch
pytest --cov=yourapp --cov-report=term-missing | grep "0%"
Measuring Python flag debt
| Metric | Django | FastAPI | Flask |
|---|---|---|---|
| Typical flag locations | Views, templates, middleware, models, tasks | Dependencies, middleware, routes | Decorators, extensions, routes |
| Cleanup complexity per flag | High (multi-layer) | Moderate (Python only) | Moderate (decorator complexity) |
| Detection difficulty | High (templates + Python) | Medium (Python only) | Medium (decorator complexity) |
| Database cleanup needed | Yes (waffle/custom tables) | Sometimes | Sometimes |
| Orphaned packages risk | Medium | High (per-dependency) | Medium |
Django tends to accumulate more flag debt than FastAPI or Flask simply because flags can live in more places -- views, templates, middleware, models, and Celery tasks. The wider the surface area, the more places debt hides.
Building prevention into Python projects
Flag registry pattern
Create a central registry that enforces flag discipline:
# flags/registry.py
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Optional
import warnings
@dataclass(frozen=True)
class FlagDefinition:
name: str
owner: str
created: date
expires: date
description: str
jira_ticket: Optional[str] = None
FLAG_REGISTRY: dict[str, FlagDefinition] = {
'new-pricing': FlagDefinition(
name='new-pricing',
owner='billing-team',
created=date(2025, 3, 1),
expires=date(2025, 6, 1),
description='New tiered pricing engine',
jira_ticket='BILL-234',
),
}
def is_flag_enabled(flag_name: str, **kwargs) -> bool:
"""Check flag with registry validation."""
definition = FLAG_REGISTRY.get(flag_name)
if definition is None:
warnings.warn(
f"Flag '{flag_name}' is not in the registry. "
f"Register it in flags/registry.py",
stacklevel=2,
)
elif definition.expires < date.today():
warnings.warn(
f"Flag '{flag_name}' expired on {definition.expires}. "
f"Owner: {definition.owner}. Remove it.",
stacklevel=2,
)
return _check_flag_backend(flag_name, **kwargs)
Pre-commit hooks
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: check-expired-flags
name: Check for expired feature flags
entry: python scripts/check_expired_flags.py
language: python
types: [python]
pass_filenames: false
CI enforcement
# .github/workflows/flag-hygiene.yml
name: Flag Hygiene
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install -r requirements.txt
- name: Check for expired flags
run: python scripts/check_expired_flags.py --fail-on-expired
- name: Scan for unregistered flags
run: python scripts/scan_unregistered_flags.py
- name: Flag count report
run: |
count=$(python scripts/count_flag_references.py)
echo "Total flag references: $count"
if [ "$count" -gt 200 ]; then
echo "::warning::Flag reference count exceeds threshold"
fi
Python's dynamic nature means feature flag debt accumulates silently and resists detection with standard tooling. The language will not tell you when a code path is dead, a decorator is unnecessary, or a template conditional has been permanently true for six months. That silence is what makes Python flag debt so dangerous -- and why proactive detection, registry enforcement, and automated cleanup are not optional practices but essential ones. Whether you are maintaining a Django monolith, a FastAPI microservice fleet, or a Flask application, the principles are the same: track every flag from creation, enforce expiration dates, and treat cleanup as a first-class part of the development process. The teams that build these habits ship faster, deploy smaller, and spend their time writing new features instead of untangling old conditionals.