How to Build Feature Flags in Redis
Feature flags let you enable or disable functionality without deploying new code. They decouple releases from deployments: you can ship code to production with a feature turned off, then flip it on for specific users, a percentage of traffic, or everyone at once. This enables gradual rollouts, A/B testing, kill switches for problematic features, and beta access for specific customers.
Redis is a natural fit for feature flags because flag checks happen on nearly every request and need to be fast. A hash lookup in Redis takes microseconds, far faster than querying a relational database or making an API call to a feature flag service. This guide covers three approaches: simple on/off flags for global toggles, percentage-based rollouts for gradual releases, and user-specific targeting for enabling features for specific users or groups.
Which Redis data types we will use
String is used in two ways in this implementation:
- As boolean flags for simple on/off toggles. One key per feature, with a value of "1" for enabled or "0" for disabled. The
GETcommand checks the flag,SETchanges it, andMGETchecks multiple flags at once. - As percentage values for gradual rollouts. Store a number 0-100 representing the rollout percentage. Hash the user ID to a number 0-99 and compare against the percentage to determine access.
Set tracks which users have explicit access to a feature. Beta testers, internal employees, or customers who requested early access can be added to a set. The SISMEMBER command checks membership in O(1) time, letting you give specific users access regardless of percentage rollouts.
Hash stores rich flag metadata when needed. Combine the percentage, update timestamp, and admin who changed it in a single hash per feature. This keeps related data together and reduces key proliferation.
The combination of these data types gives you layered control: check user targeting first (Set), then percentage rollout (String), then global flag (String). Each layer is fast and the logic is straightforward.
Simple on/off flags for global toggles
The simplest feature flag is a key that exists or does not. If the key exists and has a truthy value, the feature is on. If it is missing or falsy, the feature is off. Redis strings work perfectly for this. One GET per flag check, one SET to change it.
This approach is ideal for global kill switches and features that should be fully on or fully off. When your payment provider has an outage, set a flag to disable checkout. When a new feature is ready for everyone, flip it on. The flag applies to all users equally.
The downside is the lack of granularity. You cannot roll out to 10% of users or enable a feature only for beta testers. For those use cases, you need the approaches below. Boolean flags also require a Redis round trip per check, though you can batch multiple flags with MGET or cache them briefly in your application.
- Redis
- Python
- TypeScript
- Go
# Enable a feature globally
SET feature:dark-mode 1
> OK
# Check if feature is enabled
GET feature:dark-mode
> "1" (enabled)
# Disable the feature
SET feature:dark-mode 0
> OK
# Or delete to use "missing means off" semantics
DEL feature:dark-mode
> 1
# Check multiple flags at once
MGET feature:dark-mode feature:new-checkout feature:beta-dashboard
> ["1", "0", nil]
def is_feature_enabled(client, feature: str) -> bool:
"""Check if a global feature flag is enabled."""
value = client.get(f'feature:{feature}')
return value == '1'
def set_feature(client, feature: str, enabled: bool):
"""Enable or disable a global feature flag."""
client.set(f'feature:{feature}', '1' if enabled else '0')
# Check multiple flags efficiently
def get_features(client, features: list[str]) -> dict[str, bool]:
"""Get multiple feature flags in one round trip."""
keys = [f'feature:{f}' for f in features]
values = client.mget(keys)
return {f: v == '1' for f, v in zip(features, values)}
async function isFeatureEnabled(client: Redis, feature: string): Promise<boolean> {
const value = await client.get(`feature:${feature}`);
return value === '1';
}
async function setFeature(client: Redis, feature: string, enabled: boolean) {
await client.set(`feature:${feature}`, enabled ? '1' : '0');
}
// Check multiple flags efficiently
async function getFeatures(client: Redis, features: string[]): Promise<Record<string, boolean>> {
const keys = features.map(f => `feature:${f}`);
const values = await client.mget(keys);
return Object.fromEntries(features.map((f, i) => [f, values[i] === '1']));
}
func isFeatureEnabled(ctx context.Context, client *redis.Client, feature string) bool {
val, err := client.Get(ctx, "feature:"+feature).Result()
return err == nil && val == "1"
}
func setFeature(ctx context.Context, client *redis.Client, feature string, enabled bool) {
value := "0"
if enabled {
value = "1"
}
client.Set(ctx, "feature:"+feature, value, 0)
}
// Check multiple flags efficiently
func getFeatures(ctx context.Context, client *redis.Client, features []string) map[string]bool {
keys := make([]string, len(features))
for i, f := range features {
keys[i] = "feature:" + f
}
values, _ := client.MGet(ctx, keys...).Result()
result := make(map[string]bool)
for i, f := range features {
result[f] = values[i] == "1"
}
return result
}
Percentage-based rollouts for gradual releases
Percentage rollouts enable a feature for a fraction of users, letting you gradually increase exposure while monitoring for problems. The key insight is to use consistent hashing: hash the user ID to a number between 0 and 99, then check if that number falls below the rollout percentage. The same user always gets the same result, so their experience is consistent across requests.
Store the rollout percentage in Redis. When checking the flag, hash the user ID and compare against the stored percentage. To increase the rollout, just update the percentage. Users below the threshold stay in, and new users get added.
This approach works well for gradual rollouts where you want to start at 5%, watch metrics, bump to 25%, then 50%, then 100%. It also enables simple A/B testing by checking whether a user falls into the enabled group. The limitation is that you cannot target specific users. If a VIP customer hashes to 85 and you are at 50% rollout, they will not see the feature until you increase the percentage.
- Redis
- Python
- TypeScript
- Go
# Set rollout to 25% of users
SET feature:new-checkout:percent 25
> OK
# Application hashes user ID to 0-99
# hash("user123") % 100 = 42
# 42 >= 25, so feature is OFF for this user
# hash("user456") % 100 = 18
# 18 < 25, so feature is ON for this user
# Increase rollout to 50%
SET feature:new-checkout:percent 50
> OK
# Now user123 (hash 42) is also included
# Store additional metadata in a hash
HSET feature:new-checkout percent 50 updated_at 1699900000 updated_by admin@example.com
> 3
import hashlib
def get_user_bucket(user_id: str) -> int:
"""Hash user ID to a consistent bucket 0-99."""
hash_bytes = hashlib.sha256(user_id.encode()).digest()
return int.from_bytes(hash_bytes[:4], 'big') % 100
def is_feature_enabled_for_user(client, feature: str, user_id: str) -> bool:
"""Check if a percentage-based feature is enabled for a user."""
percent = client.get(f'feature:{feature}:percent')
if not percent:
return False
bucket = get_user_bucket(user_id)
return bucket < int(percent)
def set_rollout_percent(client, feature: str, percent: int):
"""Set the rollout percentage for a feature (0-100)."""
client.set(f'feature:{feature}:percent', percent)
import { createHash } from 'crypto';
function getUserBucket(userId: string): number {
const hash = createHash('sha256').update(userId).digest();
return hash.readUInt32BE(0) % 100;
}
async function isFeatureEnabledForUser(
client: Redis, feature: string, userId: string
): Promise<boolean> {
const percent = await client.get(`feature:${feature}:percent`);
if (!percent) return false;
const bucket = getUserBucket(userId);
return bucket < parseInt(percent);
}
async function setRolloutPercent(client: Redis, feature: string, percent: number) {
await client.set(`feature:${feature}:percent`, percent);
}
import (
"crypto/sha256"
"encoding/binary"
"strconv"
)
func getUserBucket(userID string) int {
hash := sha256.Sum256([]byte(userID))
return int(binary.BigEndian.Uint32(hash[:4])) % 100
}
func isFeatureEnabledForUser(ctx context.Context, client *redis.Client, feature, userID string) bool {
percent, err := client.Get(ctx, "feature:"+feature+":percent").Int()
if err != nil {
return false
}
bucket := getUserBucket(userID)
return bucket < percent
}
func setRolloutPercent(ctx context.Context, client *redis.Client, feature string, percent int) {
client.Set(ctx, "feature:"+feature+":percent", strconv.Itoa(percent), 0)
}
User-specific targeting for beta access
Sometimes you need a feature enabled for specific users regardless of percentage rollouts. Beta testers, internal employees, customers who requested early access, or users where you are debugging an issue. A Redis set stores which users have explicit access to a feature. Check set membership with SISMEMBER, which is O(1) and fast.
The typical pattern is to check user targeting first, then fall back to percentage rollout, then to global flag. This layered approach gives you fine-grained control: you can enable a feature for your QA team while it is at 0% rollout, then gradually roll out to real users while keeping beta testers in the enabled group.
User targeting adds operational complexity. Someone needs to manage who is in each set, and the sets can grow large for popular beta programs. Consider adding expiration or periodic cleanup for targeting sets, or use a hash to store additional metadata like when the user was added and by whom.
- Redis
- Python
- TypeScript
- Go
# Add users to a feature's allowlist
SADD feature:new-checkout:users user123 user456 user789
> 3
# Check if a specific user has access
SISMEMBER feature:new-checkout:users user123
> 1 (user has access)
SISMEMBER feature:new-checkout:users user999
> 0 (user does not have access)
# Remove a user from the allowlist
SREM feature:new-checkout:users user456
> 1
# See all users with access (careful with large sets)
SMEMBERS feature:new-checkout:users
> ["user123", "user789"]
# Count users in the allowlist
SCARD feature:new-checkout:users
> 2
def is_user_targeted(client, feature: str, user_id: str) -> bool:
"""Check if a user is explicitly targeted for a feature."""
return client.sismember(f'feature:{feature}:users', user_id)
def add_user_to_feature(client, feature: str, user_id: str):
"""Add a user to a feature's allowlist."""
client.sadd(f'feature:{feature}:users', user_id)
def remove_user_from_feature(client, feature: str, user_id: str):
"""Remove a user from a feature's allowlist."""
client.srem(f'feature:{feature}:users', user_id)
# Combined check: targeting, then percentage, then global
def is_feature_enabled(client, feature: str, user_id: str) -> bool:
"""Check feature flag with targeting, percentage, and global fallback."""
# First check explicit targeting
if client.sismember(f'feature:{feature}:users', user_id):
return True
# Then check percentage rollout
percent = client.get(f'feature:{feature}:percent')
if percent and get_user_bucket(user_id) < int(percent):
return True
# Finally check global flag
return client.get(f'feature:{feature}') == '1'
async function isUserTargeted(client: Redis, feature: string, userId: string): Promise<boolean> {
return await client.sismember(`feature:${feature}:users`, userId) === 1;
}
async function addUserToFeature(client: Redis, feature: string, userId: string) {
await client.sadd(`feature:${feature}:users`, userId);
}
async function removeUserFromFeature(client: Redis, feature: string, userId: string) {
await client.srem(`feature:${feature}:users`, userId);
}
// Combined check: targeting, then percentage, then global
async function isFeatureEnabled(client: Redis, feature: string, userId: string): Promise<boolean> {
// First check explicit targeting
if (await client.sismember(`feature:${feature}:users`, userId) === 1) {
return true;
}
// Then check percentage rollout
const percent = await client.get(`feature:${feature}:percent`);
if (percent && getUserBucket(userId) < parseInt(percent)) {
return true;
}
// Finally check global flag
return await client.get(`feature:${feature}`) === '1';
}
func isUserTargeted(ctx context.Context, client *redis.Client, feature, userID string) bool {
result, _ := client.SIsMember(ctx, "feature:"+feature+":users", userID).Result()
return result
}
func addUserToFeature(ctx context.Context, client *redis.Client, feature, userID string) {
client.SAdd(ctx, "feature:"+feature+":users", userID)
}
func removeUserFromFeature(ctx context.Context, client *redis.Client, feature, userID string) {
client.SRem(ctx, "feature:"+feature+":users", userID)
}
// Combined check: targeting, then percentage, then global
func isFeatureEnabled(ctx context.Context, client *redis.Client, feature, userID string) bool {
// First check explicit targeting
if targeted, _ := client.SIsMember(ctx, "feature:"+feature+":users", userID).Result(); targeted {
return true
}
// Then check percentage rollout
if percent, err := client.Get(ctx, "feature:"+feature+":percent").Int(); err == nil {
if getUserBucket(userID) < percent {
return true
}
}
// Finally check global flag
val, _ := client.Get(ctx, "feature:"+feature).Result()
return val == "1"
}
Choosing an approach
Use boolean flags for global on/off switches where all users should have the same experience. Kill switches, maintenance mode, and fully-launched features all fit this pattern.
Use percentage rollouts when releasing new features gradually. Start at a low percentage, monitor error rates and performance, and increase as confidence grows. This catches problems before they affect all users.
Use user targeting when specific users need access regardless of rollout status. Beta testers, internal users, and customers with contractual access all benefit from explicit targeting.
In practice, most feature flag systems combine all three. Check user targeting first for explicit overrides, then percentage rollout for gradual releases, then fall back to a global default. This layered approach gives you the flexibility to handle any rollout strategy while keeping flag checks fast and simple.