Django Feature Flags: Production Release Guide
Build Django feature flags for safe rollouts: separate access from release, define fallbacks, test both paths, audit changes, and remove stale flags.
A production Django feature flag is a runtime release decision, not a Boolean setting or a permission check. Evaluate it on the server with an explicit subject and safe default; deploy the guarded code before enabling it; ramp by measured blast radius; record configuration changes and outcomes; test both paths; then remove the flag and losing branch after the release stabilizes.
That definition matters because four controls are often called “feature flags” even though they solve different jobs:
- Build: is the module or code present in this deployment?
- Access: may this account or user perform the action?
- Release: should the present, authorized behavior run for this subject now?
- Evidence: which decision was made, why, and what happened afterward?
This guide calls that the BARE model: Build, Access, Release, Evidence. It keeps deployment, authorization, rollout, and telemetry from collapsing into one ambiguous conditional.
The implementation below covers Django-native packages and hosted flag providers. It focuses on the operational contract around the library: server-side evaluation, rollout and rollback policy, cached state, background jobs, data migrations, tests, audit records, and stale-flag cleanup.
On this page: the BARE model · tool choice · release contract · server evaluation · failure policy · background jobs · tests · cleanup
Use the BARE model
Before creating a flag, classify the decision. The answer determines where it belongs and what failure would mean.
| Layer | Question | Typical mechanism | Failure to avoid |
|---|---|---|---|
| Build | Is the capability installed and configured? | package, generated module, deployment setting | evaluating a flag for code that is absent |
| Access | Is the actor entitled and authorized? | Django permission, tenant role, subscription policy | trusting browser-visible flag state for security |
| Release | Should the authorized path run for this subject now? | runtime flag and targeting context | treating a static environment variable as a gradual rollout |
| Evidence | Can we explain the decision and outcome? | change audit, evaluation metadata, exposure and outcome events | logging clicks but not committed results |
The layers may all be required for one request. A billing export could require the export module to be built, the account to have a paid plan, the new export engine to be released to 10% of eligible accounts, and the selected variant to be attached to the completion event.
Keep the checks separate:
def start_export(request):
if not request.user.has_perm("exports.create_export"):
raise PermissionDenied
decision = release_flags.export_variant(
user=request.user,
workspace=request.user.workspace,
)
return queue_export(user_id=request.user.pk, variant=decision.value)
The flag does not grant permission. The permission does not select the release variant. A forged browser request still reaches the server-side authorization check.
The distinction is explicit in django-waffle's goals: Waffle is not a security or permissions system. Apply that rule even if a provider calls its targeting rules “entitlements.”
Do not confuse generator options with runtime flags
Djass exposes y/n choices for modules such as payments, PostHog, and Sentry.
Those choices are evaluated while a repository is generated. They determine
which code and configuration enter the artifact, and Djass records the input
payload and generated manifest for traceability. They cannot change behavior
inside an already deployed application.
That is the Build layer. See the Djass generator options and project generation pipeline for the current artifact boundary.
Environment switches such as PAYMENTS_ENABLED are also not cohort release
flags. They are useful startup controls, but changing one normally requires a
process restart and applies to every process that reads it. A runtime flag
system evaluates a named decision during application execution and can update
release state without a new deployment. Django-Flags, for example, documents
that database-backed conditions can change without restarting Django, while
settings-defined conditions cannot.
Choose a Django flag system
Choose from the context and operating model, not from a feature checklist.
| Option | Good fit | Important tradeoff |
|---|---|---|
django-waffle |
Django-admin ownership, users/groups, percentages, request-aware flags | Django-specific storage and cache behavior; targeting criteria use Waffle's documented semantics |
django-flags |
Composable conditions such as user, group, URL, language, date, or percentage | settings-defined flags stay static; condition composition differs from Waffle |
| Hosted provider SDK | Cross-service flags, richer targeting, centralized governance, experiment analysis | remote configuration, credentials, cost, and provider failure modes enter the system |
| OpenFeature facade | An application-owned evaluation API across providers | standardizes evaluation semantics, not a provider's caching, targeting, or audit implementation |
Both django-waffle flags and Django-Flags conditions can use request context. Their rule composition is not interchangeable: read the exact package semantics before translating a policy between them.
Use a hosted provider when several services must share release state or when non-developers need a mature control plane. Prefer a Django-native package when one application owns the decision and Django admin, database backups, and existing operational controls are an advantage. A runtime flag still needs a separate restore-proof database backup policy for the durable state it changes.
Whichever option you select, hide it behind a small application interface. A call site should ask for a typed decision; it should not know cache keys, provider clients, request cookies, or remote fallback rules.
Write the release contract first
A flag without an operating contract is a delayed incident. Record these fields before the first production enablement:
| Field | Example |
|---|---|
| Key and purpose | streaming_exports: release the chunked export engine |
| Type | temporary release flag |
| Owner and expiry | data team; removal issue due after full rollout |
| Eligible population | authorized paid workspaces only |
| Safe default | control |
| Ramp | staff → 5% → 25% → 100%, with a hold at each stage |
| Success metrics | completion rate, duration, memory use |
| Abort threshold | error rate above the agreed baseline or corrupted output |
| Rollback action | serve the old export engine; stop new streaming jobs |
| Irreversible effects | files already written and notifications already sent remain |
The percentages are an example, not a standard ladder. A high-frequency path with a wide blast radius may begin below 1%; a low-volume internal workflow may start with named accounts. GitLab's feature-flag controls recommend setting the starting percentage from blast radius and evaluation frequency, enabling in pre-production first, and monitoring errors and performance during an incremental rollout. PostHog gives similar production flag guidance.
Use this release order:
- Merge and deploy code that supports both paths with the flag safely off.
- Verify the off path and the new path in pre-production.
- Enable for staff or named test accounts.
- Ramp in explicit stages while watching technical and user outcomes.
- Exercise the off switch before it is needed during an incident.
- Complete the rollout, remove the conditional and old path, deploy, then archive the control-plane flag.
Turning a flag off switches future evaluations to the configured off path. It does not undo database writes, revoke sent emails, cancel queued work, remove objects from storage, or reverse an external API call. LaunchDarkly's flag toggle documentation describes the behavior switch; rollback of effects remains an application job.
Centralize server-side evaluation
Evaluate backend behavior near authoritative user, account, plan, tenant, and permission data. Client-side evaluation is reasonable for presentation-only changes, but a browser result is neither authorization nor proof that the server used the same variant.
Create one facade with typed defaults and detailed evaluation metadata:
from dataclasses import dataclass
from typing import Literal
ExportVariant = Literal["control", "streaming"]
@dataclass(frozen=True)
class FlagDecision:
value: ExportVariant
reason: str
flag_key: str
class ReleaseFlags:
def export_variant(self, *, user, workspace) -> FlagDecision:
if not user.is_authenticated:
return FlagDecision("control", "anonymous", "streaming_exports")
try:
value, details = self.provider.evaluate(
key="streaming_exports",
subject_key=f"workspace:{workspace.pk}",
attributes={
"plan": workspace.plan_code,
"region": workspace.region,
},
default="control",
)
except Exception:
# This is the provider boundary. In application code, catch the
# installed SDK's documented evaluation exceptions where possible.
return FlagDecision("control", "provider_error", "streaming_exports")
if value not in {"control", "streaming"}:
return FlagDecision("control", "invalid_value", "streaming_exports")
return FlagDecision(value, details.reason, "streaming_exports")
def operational_stop_enabled(self, key: str, *, default: bool = True) -> bool:
try:
value, _details = self.provider.evaluate(
key=f"{key}_operational_stop",
subject_key="worker",
attributes={},
default=default,
)
return bool(value)
except Exception:
return default
The facade does four useful things:
- makes the subject key stable, so one workspace does not jump variants;
- limits attributes to an allow-list instead of forwarding an entire model;
- validates multivariate values before application code sees them;
- gives every failure an explicit default and reason.
OpenFeature's evaluation API requires abnormal evaluation to return the caller-supplied default rather than terminate the application. Its detailed result can include the flag key, variant, reason, and errors. Treat metadata as best effort because provider support varies.
Do not expose server evaluation credentials or sensitive targeting attributes to JavaScript. PostHog's local-evaluation documentation marks its secure key as server-only, and OpenFeature's evaluation-context guidance recommends filtering or anonymizing context according to provider handling.
Make caching and failure behavior explicit
Remote evaluation on every request adds latency and makes the provider part of your request's availability path. Server SDKs commonly fetch definitions in the background and evaluate locally from cached rules. This avoids a network round trip per decision, but creates bounded staleness.
Define three states separately:
- Warm and current enough: evaluate from the SDK's cached definitions.
- Temporarily disconnected: use last-known definitions if your risk model accepts delayed control-plane updates.
- Cold start with no usable definitions: return the flag's explicit safe default and record the reason.
Unleash recommends local cached evaluation and safe defaults in its feature-flag best practices. PostHog notes that local evaluation can be undefined before definitions arrive and documents shared-cache options for short-lived workers. Exact polling, fallback, and initialization behavior is SDK-specific, so test your installed version.
“Fail closed” is not a complete policy. False may be safe for a new UI, but
not for a migration read path, a paid entitlement, or an operational circuit
breaker. Choose the safest valid behavior per decision and name it in the
release contract.
Django-native storage also has staleness concerns. Waffle caches aggressively; its configuration guide documents a read-replica case where a cache miss immediately after an update can cache stale state. If flags are operational controls, verify cache invalidation and database routing under the topology you actually run.
Split background-job admission from the kill switch
A request and a worker may evaluate the same flag minutes or hours apart. If a queued job silently re-evaluates its rollout cohort, an account can enter the queue on one variant and execute another. If the job never re-checks anything, an urgent shutdown may not stop an irreversible side effect.
Use two decisions:
- Persist the admission variant when the job is queued.
- Check a separate operational kill switch immediately before an irreversible worker action.
decision = release_flags.export_variant(user=request.user, workspace=workspace)
job = ExportJob.objects.create(
workspace=workspace,
requested_by=request.user,
admitted_variant=decision.value,
flag_reason=decision.reason,
)
transaction.on_commit(lambda: queue_export(job.pk))
def run_export(job_id):
job = ExportJob.objects.select_related("workspace").get(pk=job_id)
if release_flags.operational_stop_enabled("streaming_exports", default=True):
job.mark_paused("operational_kill_switch")
return
export_with_variant(job.admitted_variant, job)
This preserves cohort consistency and retains an emergency brake. Make retries idempotent because the flag cannot reverse a side effect that already occurred. For queue design and transaction boundaries, see Django background tasks and scheduled-task operations.
Treat data changes as migrations
A Boolean rollback is unsafe when a release changes writes, cache keys, or state transitions. The old code must continue to understand the data written by the new path until rollback is no longer required.
Use an expand–migrate–contract sequence:
- Expand: deploy a backward-compatible schema and code that can read both representations.
- Migrate: backfill existing data, introduce dual reads or writes when justified, and verify reconciliation metrics.
- Contract: after the new path is stable and rollback is retired, remove the old representation and compatibility code.
GitLab specifies this compatible-version pattern for flags guarding writes, cache keys, and state transitions. LaunchDarkly models migration flags as staged states rather than one toggle. Their product-specific stages are examples; the general rule is to preserve compatibility across the rollback window.
Keep four evidence streams separate
“The flag was on” can describe four different records:
| Record | Question answered | Suggested fields |
|---|---|---|
| Configuration audit | Who changed the rule? | actor, key, old/new rule, time, ticket |
| Evaluation detail | Why did this request receive a value? | key, variant, reason, subject hash, release |
| Exposure event | Did the subject actually encounter the behavior? | key, variant, surface, stable subject ID |
| Outcome event | What committed or completed? | domain event, variant, job/request ID, result |
Django's admin
LogEntry
is a useful baseline for flag changes made through admin, but it does not cover
model saves from scripts, SQL, or external control planes. Django-Flags can
optionally log state checks and condition results. Those are separate concerns.
Avoid unconditional info logs for every evaluation on a hot path. OpenFeature warns that application-uncontrolled evaluation logging can create high volume; its hooks provide a place for sampled telemetry and error handling. Never put raw personal or sensitive targeting data into logs.
Djass applies the same evidence principle to analytics: browser intent, committed Django state, and provider-confirmed outcomes are different facts. The Django PostHog guide shows that event boundary, and the Django audit-log guide covers security and model-history evidence.
Test decisions, not library internals
Make flag state deterministic in automated tests. Waffle provides
override_flag, override_switch, and override_sample; Django-Flags shows
enabled and disabled tests with override_settings. Do not let percentage
allocation make unit tests random.
At minimum, cover this matrix:
| Case | Expected assertion |
|---|---|
| enabled | new path runs and records its variant |
| disabled | old path remains functional |
| missing flag | documented default runs |
| provider unavailable | safe fallback runs; request does not crash |
| invalid multivariate value | facade rejects it and uses the default |
| eligible and ineligible subjects | targeting uses authoritative server data |
| cold worker start | no-definition behavior is explicit |
| background retry | admission variant stays stable; effects are idempotent |
| rollback during mixed data | old path can read data produced by the new path |
Distinguish false from “not evaluated,” “missing,” and “provider failed.” The
return types differ among SDKs. PostHog explicitly notes that undefined is not
equivalent to false, while OpenFeature exposes reason and error metadata through
detailed evaluation.
Test the release procedure too. In staging, enable the new path, create durable state, disable it, and prove the off path still works. A unit test of two branches cannot prove cache invalidation, worker initialization, control-plane credentials, or data compatibility.
Remove temporary flags
Every temporary release flag adds another production state and another branch that future changes must preserve. Create it with an owner, type, and removal issue. Once rollout is complete:
- Confirm the winning value is stable and the rollback window is closed.
- Remove the conditional and losing code path.
- Deploy the cleanup and verify no process evaluates the key.
- Archive or deprecate the control-plane flag so history is retained.
Unleash's flag cleanup guide and LaunchDarkly's technical-debt guide both put code removal before archival. A permanent operational kill switch is an intentional exception, but it still needs an owner, tested fallback, and review schedule.
Do not import a vendor's 30-, 90-, or 120-day “stale” threshold as an industry standard. The right lifetime follows the release's risk and rollback window. The invariant is simpler: temporary flags should not become permanent by neglect.
Production checklist
Before enabling a Django feature flag, verify:
- [ ] The decision is Release, not Build or Access.
- [ ] Server authorization remains enforced independently.
- [ ] The flag has an owner, type, safe default, expiry, and cleanup issue.
- [ ] The code is deployed with both paths operational before enablement.
- [ ] The subject key and targeting attributes are stable and minimized.
- [ ] Cold start, provider failure, cache staleness, and invalid values have explicit behavior.
- [ ] Rollout stages, success metrics, abort thresholds, and rollback action are written down.
- [ ] Queued work persists its admission variant and checks a separate kill switch before irreversible effects.
- [ ] Data-changing releases use a backward-compatible migration sequence.
- [ ] Configuration audit, evaluation detail, exposure, and outcome evidence are distinguishable.
- [ ] Enabled, disabled, missing, failed, targeted, worker, and rollback paths are tested.
- [ ] The off path has been exercised in the deployed environment.
- [ ] Cleanup will remove code before the flag is archived.
Djass currently generates the Build layer and includes PostHog for analytics when selected; it does not claim to provide application-level runtime flag evaluation. Start with the generator feature catalog, add your chosen release system through the feature workflow, and keep its configuration in the documented environment-variable boundary. If you want the generated SaaS foundation before adding that release layer, review Djass pricing.