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

  1. Flag reaches 100%: Feature is fully rolled out
  2. Monitoring period: Watch for issues (1-2 weeks)
  3. Remove flag checks: Delete the if/else, keep only new code
  4. Remove flag definition: Clean up config
  5. 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:

  1. Simple flags: Redis-backed, changed via internal tooling
  2. Percentage rollouts: Consistent hashing for stability
  3. Internal bypass: All employees see all features
  4. Kill switches: Every feature can be disabled instantly
  5. Flag registry: Owners and cleanup dates documented
  6. 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.