All Articles
Backend

Server-Side PostHog Feature Flags in Django — Gating a Rollout Without a Consent Banner

Shahid MalikBy Shahid MalikSeptember 4, 20266 min read

Tabeer.ai already used PostHog client-side for analytics, gated behind cookie consent. Rolling out a new backend feature gradually needed flags that work independent of consent entirely — here's the server-side helper and why it fails open, not closed, on error.

Tabeer.ai already had PostHog wired into the frontend for analytics, gated behind cookie consent the way GDPR-conscious analytics should be. That's the right call for tracking user behavior — but it created a real question when a new backend feature (a question-search endpoint for content staff) needed a gradual rollout: should that also wait on consent?

Flags Aren't Analytics

The answer is no, and the reasoning is worth being explicit about. A feature flag controlling whether an authenticated staff member's request hits a new code path isn't tracking anything about that person's behavior for measurement purposes — it's an operational switch, the same category as an environment variable, just one that can be flipped without a deploy. Gating that behind cookie consent would be conflating two different concerns: "can we measure what this user does" and "should this server-side code branch run." The architecture split that came out of that reasoning:

  • Operational rollout flags → server-side, via PostHog's Python SDK, no consent-gating, because they control app behavior, not tracking.
  • A/B test experiments → client-side, through the already consent-gated PostHog JS SDK, because experiments inherently need analytics to measure outcomes — there's no version of an A/B test that doesn't require tracking which variant a user saw and what they did next.

The Helper

A thin wrapper around the posthog Python package, not a direct dependency scattered through every view that needs a flag check:

from django.conf import settings

_client = None

def _get_client():
    global _client
    if _client is None and settings.POSTHOG_API_KEY:
        import posthog as posthog_module
        posthog_module.api_key = settings.POSTHOG_API_KEY
        posthog_module.host = settings.POSTHOG_HOST
        _client = posthog_module
    return _client

def is_enabled(flag_key: str, user, default: bool = False) -> bool:
    client = _get_client()
    if client is None or user is None or not getattr(user, "is_authenticated", False):
        return default
    try:
        result = client.feature_enabled(flag_key, str(user.pk))
    except Exception:
        return default
    return bool(result) if result is not None else default

Three deliberate decisions baked into those few lines:

  • Lazy client init — no PostHog connection attempt happens at all if POSTHOG_API_KEY isn't set, so local development and CI never need a real API key just to import this module.
  • str(user.pk) as the distinct ID — the same identifier PostHog would use for that user if they were also tracked client-side, so a flag or experiment defined once in the PostHog dashboard evaluates consistently whether it's checked from Django or from the browser.
  • Fails open to default, not closed — any exception from the PostHog call (network blip, API outage, rate limit) returns the caller's specified default rather than propagating. A feature flag SDK going down should never be able to 500 an otherwise-working endpoint.

Using It

def get_queryset(self):
    if not feature_flags.is_enabled("staff-question-search", self.request.user, default=False):
        return Question.objects.none()
    ...

default=False here is intentional and matches "fail closed on absence, fail open on error" — if PostHog has no opinion (flag doesn't exist, or the API call itself errors), the safer default for a not-yet-fully-tested feature is off, not on. A different flag guarding, say, a performance optimization with no correctness risk might reasonably default to True instead — the helper doesn't hardcode a philosophy, it takes the default as an explicit argument per call site.

Tested Without a Real PostHog Connection

def test_calls_feature_enabled_with_user_pk_as_distinct_id(self):
    mock_module = MagicMock()
    mock_module.feature_enabled.return_value = True
    with patch.dict("sys.modules", {"posthog": mock_module}):
        with override_settings(POSTHOG_API_KEY="test-key"):
            result = feature_flags.is_enabled("some-flag", self.user)
    mock_module.feature_enabled.assert_called_once_with("some-flag", str(self.user.pk))
    assert result is True

Four tests total: no API key configured → returns the default; anonymous or None user → returns the default without ever calling PostHog; a real call passes the correct distinct ID; an exception from PostHog fails open to the default. None of them touch the network — the point is verifying the helper's logic, which doesn't require a live PostHog account to test correctly.

Why This Split Was Worth Getting Right

Getting flags-vs-experiments backwards would have meant either forcing an operational rollout switch to depend on a user having accepted a cookie banner (breaking the feature for anyone who declined analytics, for no actual privacy benefit), or running an A/B test without the tracking needed to ever read its results. Neither is a subtle mistake to unwind later — worth deciding deliberately up front instead.

If you're rolling out a backend feature gradually and want it decoupled from your analytics consent flow, this pattern generalizes past PostHog specifically. Get in touch.

Related Articles

Security

Rate Limiting, Token Blacklisting, and Admin MFA — Auth Hardening for Tabeer.ai

Part two of the production-readiness checklist: authentication and account security. Five of fifteen items were genuinely missing — rate limiting, real logout, admin MFA, consent records, and self-service data export/deletion — and here's exactly how each got fixed without a new dependency for most of them.

10 min read
Shahid Malik - AI-First Odoo Consultant

Shahid Malik

AI-First Odoo ERP Specialist

Shahid Malik is an AI-first Odoo consultant helping businesses solve complex ERP and business process challenges. His work combines Odoo consulting, process optimization, automation, integrations, migrations, and practical AI solutions to build scalable and reliable business systems.

Book a consultation for your Odoo project
Discuss Your Odoo Project