The most dangerous word in software is “deploy.” It carries the weight of “I hope nothing breaks” even when you’ve done everything right. Feature flags change that equation entirely.
Deployment vs Release#
Most teams conflate two distinct concepts:
- Deployment: Code reaches production servers
- Release: Users see new functionality
Feature flags separate these. You can deploy code on Monday and release features on Thursday. You can deploy to everyone but release to 5% of users. You can deploy globally but release only to internal testers.
This separation transforms deployments from risky events into routine operations.
The Simplest Feature Flag#
Start here:
1
2
3
4
5
6
7
8
9
10
11
| # config.py
FEATURE_FLAGS = {
"new_checkout_flow": False,
"enhanced_search": True,
}
# usage
if FEATURE_FLAGS.get("new_checkout_flow"):
return new_checkout()
else:
return legacy_checkout()
|
No external service. No complexity. Just a config dictionary.
This works for small teams and simple use cases. When you need more, you add more.
Levels of Sophistication#
Level 1: Config File Flags#
1
2
3
4
| # flags.yaml
features:
new_checkout_flow: false
enhanced_search: true
|
Pros: Simple, version controlled, easy to understand
Cons: Requires redeployment to change
Level 2: Environment Variables#
1
2
| FEATURE_NEW_CHECKOUT=true
FEATURE_ENHANCED_SEARCH=false
|
Pros: Can change without redeploying
Cons: Limited to boolean, requires restart
Level 3: Runtime Configuration#
1
2
3
4
5
6
7
8
9
10
11
12
13
| class FeatureFlags:
def __init__(self, redis_client):
self.redis = redis_client
def is_enabled(self, flag_name: str) -> bool:
value = self.redis.get(f"feature:{flag_name}")
return value == "true"
def enable(self, flag_name: str):
self.redis.set(f"feature:{flag_name}", "true")
def disable(self, flag_name: str):
self.redis.set(f"feature:{flag_name}", "false")
|
Pros: Real-time changes, no restarts
Cons: External dependency, need admin tooling
Level 4: Percentage Rollouts#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| import hashlib
class GradualRollout:
def __init__(self, redis_client):
self.redis = redis_client
def is_enabled(self, flag_name: str, user_id: str) -> bool:
percentage = int(self.redis.get(f"feature:{flag_name}:percentage") or 0)
# Consistent hash ensures same user always gets same result
user_hash = int(hashlib.md5(f"{flag_name}:{user_id}".encode()).hexdigest(), 16)
user_bucket = user_hash % 100
return user_bucket < percentage
|
Key insight: Use consistent hashing so each user stays in their cohort. User “alice” at 10% should remain in the “enabled” group when you increase to 20%.
Level 5: Targeting Rules#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| def evaluate_flag(flag_config: dict, user: User, context: dict) -> bool:
# Check kill switch first
if flag_config.get("enabled") is False:
return False
# Check targeting rules
for rule in flag_config.get("rules", []):
if matches_rule(rule, user, context):
return rule["result"]
# Fall back to percentage
return check_percentage(flag_config, user.id)
def matches_rule(rule: dict, user: User, context: dict) -> bool:
attribute = rule["attribute"]
operator = rule["operator"]
value = rule["value"]
user_value = getattr(user, attribute, None) or context.get(attribute)
if operator == "equals":
return user_value == value
elif operator == "contains":
return value in user_value
elif operator == "in":
return user_value in value
return False
|
Example rule configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| {
"new_checkout_flow": {
"enabled": true,
"rules": [
{
"attribute": "email",
"operator": "contains",
"value": "@company.com",
"result": true
},
{
"attribute": "country",
"operator": "in",
"value": ["US", "CA"],
"result": true
}
],
"percentage": 10
}
}
|
Common Patterns#
The Kill Switch#
Every feature should have an instant off-switch:
1
2
3
4
5
6
7
8
9
| def risky_feature():
if not flags.is_enabled("risky_feature_active"):
return safe_fallback()
try:
return new_risky_logic()
except Exception:
flags.disable("risky_feature_active") # Auto-disable on failure
return safe_fallback()
|
Internal Testing First#
1
2
3
4
5
6
7
8
9
| INTERNAL_DOMAINS = ["@company.com", "@contractor.company.com"]
def is_internal_user(user: User) -> bool:
return any(user.email.endswith(domain) for domain in INTERNAL_DOMAINS)
def new_feature(user: User):
if is_internal_user(user) or flags.is_enabled("new_feature", user.id):
return new_implementation()
return old_implementation()
|
Beta Users#
1
2
3
4
5
6
7
8
9
| def is_beta_user(user: User) -> bool:
return user.id in flags.get_beta_list("feature_name")
def feature_with_beta(user: User):
if is_beta_user(user):
return beta_version()
elif flags.is_enabled("feature_ga"):
return ga_version()
return old_version()
|
Time-Based Releases#
1
2
3
4
5
6
7
8
| from datetime import datetime, timezone
def is_released(flag_name: str) -> bool:
release_time = flags.get_release_time(flag_name)
if release_time is None:
return flags.is_enabled(flag_name)
return datetime.now(timezone.utc) >= release_time
|
Flag Lifecycle Management#
Flags accumulate. Without discipline, you end up with hundreds of stale flags cluttering your codebase.
The Flag Registry#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| from dataclasses import dataclass
from datetime import date
from enum import Enum
class FlagStatus(Enum):
DEVELOPMENT = "development"
TESTING = "testing"
ROLLOUT = "rollout"
COMPLETE = "complete"
DEPRECATED = "deprecated"
@dataclass
class FlagDefinition:
name: str
description: str
owner: str
created: date
status: FlagStatus
cleanup_by: date # When this flag should be removed
FLAG_REGISTRY = {
"new_checkout": FlagDefinition(
name="new_checkout",
description="Redesigned checkout flow with fewer steps",
owner="checkout-team",
created=date(2026, 2, 1),
status=FlagStatus.ROLLOUT,
cleanup_by=date(2026, 4, 1),
),
}
|
Automated Cleanup Reminders#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| def check_stale_flags():
today = date.today()
stale_flags = []
for name, definition in FLAG_REGISTRY.items():
if definition.cleanup_by < today:
stale_flags.append({
"name": name,
"owner": definition.owner,
"overdue_days": (today - definition.cleanup_by).days,
})
if stale_flags:
notify_teams(stale_flags)
|
The Cleanup Process#
- Flag reaches 100%: Feature is fully rolled out
- Monitoring period: Watch for issues (1-2 weeks)
- Remove flag checks: Delete the if/else, keep only new code
- Remove flag definition: Clean up config
- Deploy: The old code path is gone
Anti-Patterns to Avoid#
Flag Nesting Hell#
1
2
3
4
5
6
| # Don't do this
if flags.is_enabled("feature_a"):
if flags.is_enabled("feature_b"):
if flags.is_enabled("feature_c"):
# What even is this state?
do_something()
|
When flags depend on other flags, you’ve created an exponential state space. Each flag should be independent.
Long-Lived Flags#
A flag that’s been at 50% for six months isn’t a feature flag—it’s an A/B test. Either commit to a variant or run a proper experiment.
Flags in Libraries#
Feature flags belong in application code, not shared libraries. Libraries should have stable interfaces.
Database Schema Flags#
1
2
3
4
5
| # This is a trap
if flags.is_enabled("new_schema"):
query = "SELECT * FROM users_v2"
else:
query = "SELECT * FROM users"
|
Database migrations should be permanent. Use proper migration strategies, not flags.
Build vs Buy#
When to build your own:
- You need simple on/off flags
- You have <10 flags
- You want zero external dependencies
When to use a service (LaunchDarkly, Split, Unleash):
- You need targeting rules
- You want a management UI
- Multiple teams manage flags
- You need audit logs and compliance features
The services aren’t cheap, but they’re cheaper than building and maintaining a full-featured system.
Putting It Together#
A practical setup for most teams:
- Simple flags: Redis-backed, changed via internal tooling
- Percentage rollouts: Consistent hashing for stability
- Internal bypass: All employees see all features
- Kill switches: Every feature can be disabled instantly
- Flag registry: Owners and cleanup dates documented
- Weekly review: Check for stale flags
Feature flags transform deployment anxiety into deployment confidence. You’re not asking “will this break production?” You’re asking “what percentage should we start with?”
That’s a much better question to be answering.
Ship code to production without shipping features to users. The separation gives you control you didn’t know you were missing.