Feature Flags¶
Control feature rollout without deploying code.
Why Feature Flags?¶
- Gradual rollout — Release to 1%, then 10%, then 100%
- Quick rollback — Disable features without deploying
- A/B testing — Test variations with real users
- Trunk-based development — Merge incomplete features
Simple Implementation¶
Configuration-Based Flags¶
# feature_flags.py
from enum import Enum
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class Environment(str, Enum):
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
class FeatureFlags(BaseSettings):
environment: Environment = Environment.DEVELOPMENT
# Feature definitions
NEW_CHECKOUT: bool = False
DARK_MODE: bool = True
AI_RECOMMENDATIONS: bool = False
model_config = SettingsConfigDict(env_prefix="FF_") # FF_NEW_CHECKOUT=true
flags = FeatureFlags()
def is_enabled(feature: str) -> bool:
"""Check if a feature is enabled."""
return getattr(flags, feature.upper(), False)
# Usage
if is_enabled("NEW_CHECKOUT"):
return new_checkout_flow()
else:
return legacy_checkout_flow()
Environment-Specific Flags¶
FEATURE_FLAGS = {
"new_checkout": {
"development": True,
"staging": True,
"production": False,
},
"dark_mode": {
"development": True,
"staging": True,
"production": True,
},
"ai_recommendations": {
"development": True,
"staging": True,
"production": {
"enabled": True,
"percentage": 10, # 10% of users
},
},
}
def is_enabled(feature: str, user_id: Optional[str] = None) -> bool:
config = FEATURE_FLAGS.get(feature, {})
env_config = config.get(settings.environment)
if isinstance(env_config, bool):
return env_config
if isinstance(env_config, dict):
if not env_config.get("enabled", False):
return False
# Percentage-based rollout
if "percentage" in env_config and user_id:
return hash(f"{feature}:{user_id}") % 100 < env_config["percentage"]
return env_config.get("enabled", False)
return False
Database-Backed Flags¶
Model¶
from sqlalchemy import Column, String, Boolean, JSON, DateTime
from sqlalchemy.orm import declarative_base
from datetime import datetime
Base = declarative_base()
class FeatureFlag(Base):
__tablename__ = "feature_flags"
id = Column(String, primary_key=True)
name = Column(String, unique=True, nullable=False)
enabled = Column(Boolean, default=False)
config = Column(JSON, default={})
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, onupdate=datetime.utcnow)
# Example config:
# {
# "percentage": 50,
# "user_ids": ["user-123", "user-456"],
# "segments": ["beta_testers"],
# "start_date": "2024-01-01",
# "end_date": "2024-02-01",
# }
Service¶
from functools import lru_cache
from datetime import datetime, date
import hashlib
class FeatureFlagService:
def __init__(self, db):
self.db = db
self._cache: dict = {}
self._cache_ttl = 60 # seconds
async def is_enabled(
self,
feature: str,
user_id: Optional[str] = None,
user_segments: Optional[list[str]] = None,
) -> bool:
flag = await self._get_flag(feature)
if not flag or not flag.enabled:
return False
config = flag.config or {}
# Check date range
if "start_date" in config:
if date.today() < date.fromisoformat(config["start_date"]):
return False
if "end_date" in config:
if date.today() > date.fromisoformat(config["end_date"]):
return False
# Check specific user IDs
if "user_ids" in config:
if user_id in config["user_ids"]:
return True
# Check segments
if "segments" in config and user_segments:
if any(s in config["segments"] for s in user_segments):
return True
# Percentage rollout
if "percentage" in config and user_id:
hash_value = int(hashlib.md5(f"{feature}:{user_id}".encode()).hexdigest(), 16)
return (hash_value % 100) < config["percentage"]
return flag.enabled
async def _get_flag(self, feature: str) -> Optional[FeatureFlag]:
# Check cache
cached = self._cache.get(feature)
if cached and cached["expires"] > datetime.utcnow().timestamp():
return cached["flag"]
# Fetch from database
flag = await self.db.query(FeatureFlag).filter(
FeatureFlag.name == feature
).first()
# Update cache
self._cache[feature] = {
"flag": flag,
"expires": datetime.utcnow().timestamp() + self._cache_ttl,
}
return flag
async def set_flag(
self,
feature: str,
enabled: bool,
config: Optional[dict] = None,
):
flag = await self._get_flag(feature)
if flag:
flag.enabled = enabled
if config:
flag.config = config
else:
flag = FeatureFlag(
id=str(uuid4()),
name=feature,
enabled=enabled,
config=config or {},
)
self.db.add(flag)
await self.db.commit()
# Invalidate cache
self._cache.pop(feature, None)
Frontend Integration¶
React Context¶
import { createContext, useContext, useEffect, useState } from 'react';
interface FeatureFlags {
[key: string]: boolean;
}
const FeatureFlagContext = createContext<FeatureFlags>({});
export function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
const [flags, setFlags] = useState<FeatureFlags>({});
useEffect(() => {
// Fetch flags from API
fetch('/api/feature-flags')
.then(res => res.json())
.then(setFlags);
}, []);
return (
<FeatureFlagContext.Provider value={flags}>
{children}
</FeatureFlagContext.Provider>
);
}
export function useFeatureFlag(feature: string): boolean {
const flags = useContext(FeatureFlagContext);
return flags[feature] ?? false;
}
// Usage
function CheckoutPage() {
const newCheckoutEnabled = useFeatureFlag('new_checkout');
if (newCheckoutEnabled) {
return <NewCheckout />;
}
return <LegacyCheckout />;
}
Feature Component¶
interface FeatureProps {
name: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function Feature({ name, children, fallback = null }: FeatureProps) {
const isEnabled = useFeatureFlag(name);
return isEnabled ? <>{children}</> : <>{fallback}</>;
}
// Usage
function App() {
return (
<div>
<Feature name="dark_mode">
<DarkModeToggle />
</Feature>
<Feature name="new_dashboard" fallback={<LegacyDashboard />}>
<NewDashboard />
</Feature>
</div>
);
}
A/B Testing¶
Variant Assignment¶
import hashlib
from dataclasses import dataclass
from typing import Optional
@dataclass
class Experiment:
name: str
variants: list[str]
weights: list[int] # e.g., [50, 50] for 50/50 split
def get_variant(experiment: Experiment, user_id: str) -> str:
"""Deterministic variant assignment based on user ID."""
hash_value = int(
hashlib.md5(f"{experiment.name}:{user_id}".encode()).hexdigest(),
16
)
# Calculate cumulative weights
total_weight = sum(experiment.weights)
bucket = hash_value % total_weight
cumulative = 0
for variant, weight in zip(experiment.variants, experiment.weights):
cumulative += weight
if bucket < cumulative:
return variant
return experiment.variants[-1]
# Usage
experiment = Experiment(
name="checkout_flow",
variants=["control", "variant_a", "variant_b"],
weights=[34, 33, 33], # ~33% each
)
variant = get_variant(experiment, user.id) # Always same for user
Tracking¶
async def track_experiment_exposure(
experiment: str,
variant: str,
user_id: str,
):
await analytics.track("experiment_exposure", {
"experiment": experiment,
"variant": variant,
"user_id": user_id,
"timestamp": datetime.utcnow().isoformat(),
})
async def track_conversion(
experiment: str,
variant: str,
user_id: str,
event: str,
value: Optional[float] = None,
):
await analytics.track("experiment_conversion", {
"experiment": experiment,
"variant": variant,
"user_id": user_id,
"event": event,
"value": value,
"timestamp": datetime.utcnow().isoformat(),
})
Third-Party Services¶
LaunchDarkly¶
import ldclient
from ldclient import Context
ldclient.set_config(ldclient.Config(settings.launchdarkly_sdk_key))
client = ldclient.get()
def is_enabled(feature: str, user: User) -> bool:
context = Context.builder(user.id) \
.set("email", user.email) \
.set("plan", user.plan) \
.build()
return client.variation(feature, context, False)
PostHog¶
from posthog import Posthog
posthog = Posthog(settings.posthog_api_key)
def is_enabled(feature: str, user_id: str) -> bool:
return posthog.feature_enabled(feature, user_id)
def get_variant(feature: str, user_id: str) -> str:
return posthog.get_feature_flag(feature, user_id)
Admin API¶
from fastapi import APIRouter, Depends, HTTPException
router = APIRouter(prefix="/admin/flags")
@router.get("/")
async def list_flags(
admin: Admin = Depends(require_admin),
service: FeatureFlagService = Depends(),
):
return await service.list_all()
@router.post("/{feature}")
async def update_flag(
feature: str,
enabled: bool,
config: Optional[dict] = None,
admin: Admin = Depends(require_admin),
service: FeatureFlagService = Depends(),
):
await service.set_flag(feature, enabled, config)
# Audit log
await audit_log.record(
action="feature_flag_updated",
actor=admin.id,
resource=feature,
details={"enabled": enabled, "config": config},
)
return {"status": "updated"}
@router.delete("/{feature}")
async def delete_flag(
feature: str,
admin: Admin = Depends(require_admin),
service: FeatureFlagService = Depends(),
):
await service.delete_flag(feature)
return {"status": "deleted"}
Best Practices¶
| Practice | Benefit |
|---|---|
| Default to off | Safe deploys |
| Use consistent hashing | Same user gets same variant |
| Set expiration dates | Clean up old flags |
| Log flag evaluations | Debug issues |
| Test both paths | Ensure both work |
| Document flags | Team awareness |
| Clean up old flags | Reduce complexity |
| Audit changes | Accountability |
Flag Lifecycle¶
┌─────────────┐
│ Create │ Feature in development
└──────┬──────┘
│
┌──────┴──────┐
│ Staged │ Enabled in staging only
└──────┬──────┘
│
┌──────┴──────┐
│ Rollout │ Gradual % increase
└──────┬──────┘
│
┌──────┴──────┐
│ Released │ 100% enabled
└──────┬──────┘
│
┌──────┴──────┐
│ Cleanup │ Remove flag, code cleanup
└─────────────┘