Back to blog
By Rasul

Django Analytics with PostHog

Build reliable Django analytics with PostHog: stable identity, browser intent, server-truth events, verified billing conversions, and tests.

Reliable Django analytics for a SaaS funnel often separates three event sources: the browser records intent, Django records state the application accepted, and verified provider webhooks record external outcomes. Connect the sources you use with one immutable user identifier, a small event contract, and tests. PostHog then receives facts you can distinguish instead of a collection of clicks.

PostHog for Django, in one paragraph: install posthog, initialize one server client, and optionally add the official context middleware after AuthenticationMiddleware. Use one immutable ID in the browser and Django. Capture intent in the browser, committed state in Django, and provider outcomes after a verified webhook. Queue non-critical capture after commit, then test the event names, properties, identity, and negative paths.

This guide implements that model without making analytics part of the critical request path. It covers browser page views, signup and project-activation events, queued server capture, paid Stripe conversions, identity continuity, privacy boundaries, and regression tests.

The examples follow the PostHog Python SDK 7.x and Django 6 documentation verified on July 30, 2026. If your installed SDK is older, check the current PostHog Django integration guide before copying an initialization or context example.

Prerequisites

  • A Django application on Python 3.10 or newer.
  • A PostHog project ingest key and the correct regional ingest host.
  • A browser JavaScript entrypoint or base template for interaction events.
  • A worker such as Django Q2 only if capture runs outside the request.
  • A verified Stripe webhook endpoint only if billing events are in scope.

Implementation map

  1. Configure browser and server clients from the same ingest settings.
  2. Use one canonical authenticated ID across the browser and Django; merge anonymous history when the person identifies.
  3. Define event names, owners, and required properties.
  4. Put server capture behind one application function.
  5. Queue non-critical events only after the database commits.
  6. Record paid conversion from a verified provider webhook.
  7. Test payloads, rollback, retries, and negative paths.
  8. Enforce privacy, consent, retention, and deletion policy.

On this page: event ownership · installation · identity · event contract · queued capture · billing truth · tests · privacy

Start with a three-source event model

“The user clicked Upgrade” and “the payment succeeded” are different facts. Putting both under one event name creates a funnel that looks precise but cannot answer basic operational questions.

Use this ownership model:

Source What it can prove Good examples Do not treat as truth for
Browser A page rendered or a person expressed intent $pageview, pricing_viewed, checkout_clicked Committed database state, payment, email delivery
Django The application validated and committed a transition user_signed_up, project_created, checkout_started Outcomes controlled by Stripe, an email provider, or another service
Verified provider webhook The provider confirmed an external transition checkout_succeeded, invoice_payment_failed Earlier browser intent

This is the central implementation rule: track intent where it happens, but track truth where it becomes knowable.

Djass uses the same split in its current application. Its page templates enable automatic PostHog page views. Django queues UI/API-produced signup, authentication, project, and Checkout-intent events. Its Stripe webhook verifies the signature before dispatch, and the paid-event handler emits the outcome after the Checkout Session reports a paid state. The Djass PostHog funnel runbook lists the core signup and project-activation map plus focused verification commands.

The distinction improves more than reporting. When paid conversions fall while Checkout clicks stay flat, you know to inspect the billing boundary rather than the pricing page. If you add a browser project-intent event, a gap between that event and committed project_created can expose validation, quota, or queue failures.

Step 1: Install and configure PostHog

Install the current Python SDK:

uv add posthog

Keep the project ingest key and host in environment variables. A browser project key beginning with phc_ is designed for event ingestion; it is not a PostHog personal or management API key. Management credentials do not belong in Django settings, rendered HTML, or a generated repository.

# settings.py
import os


POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", "")
POSTHOG_HOST = os.environ.get(
    "POSTHOG_HOST",
    "https://us.i.posthog.com",
)

Djass documents this secret boundary in its environment-variable reference. Keep capture disabled when the key is empty so local development and tests do not send accidental production events.

Create one application-owned client instead of configuring the SDK in every view:

# apps/analytics/client.py
from django.conf import settings
from posthog import Posthog


posthog = Posthog(
    project_api_key=settings.POSTHOG_API_KEY,
    host=settings.POSTHOG_HOST,
)

The snippets below use this explicit client and pass distinct_id on every capture. Current PostHog documentation offers another coherent path: configure the global posthog client and place posthog.integrations.django.PosthogContextMiddleware after AuthenticationMiddleware. Do not expect that middleware to configure or use a separate application-owned client automatically.

The official middleware supports WSGI and ASGI. It can receive X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID from a client, and current PostHog JS configuration exposes a tracing_headers option for context propagation to selected API hosts. Call identify_context() after login or signup when the user first becomes known. Follow the current Django integration instructions as a complete alternative if you choose that context-based path.

For browser capture, install posthog-js in the frontend bundle:

npm install posthog-js

Expose only the ingest configuration and canonical analytics ID through a Django context processor. The ID is not authorization data:

# apps/analytics/context_processors.py
from django.conf import settings


def posthog_context(request):
    distinct_id = None
    if request.user.is_authenticated:
        distinct_id = f"user:{request.user.pk}"

    return {
        "posthog_config": {
            "apiKey": settings.POSTHOG_API_KEY,
            "apiHost": settings.POSTHOG_HOST,
            "distinctId": distinct_id,
        }
    }

Render the mapping with Django's json_script filter, which escapes values for safe JSON embedding:

{{ posthog_config|json_script:"posthog-config" }}
<script type="module" src="{% static 'js/analytics.js' %}"></script>

Then initialize and identify in the bundled entrypoint:

import posthog from "posthog-js";

const configNode = document.getElementById("posthog-config");
const config = configNode ? JSON.parse(configNode.textContent) : null;
const analyticsAllowed =
  window.localStorage.getItem("analytics-consent") === "granted";

if (config?.apiKey && analyticsAllowed) {
  posthog.init(config.apiKey, {
    api_host: config.apiHost,
    capture_pageview: true,
  });

  if (config.distinctId) {
    posthog.identify(config.distinctId);
  }
}

Replace the local-storage check with your product's real consent state. For audiences where prior consent is not required, the guard can follow that documented policy instead. On logout, call posthog.reset() so the next person on a shared browser does not inherit the previous identity.

Let the browser own navigation and interaction events; do not repeat every page view from Django. A privacy-policy mention alone is not runtime consent.

Done when: an empty project key produces no capture, an allowed anonymous visit produces a page view, and login changes subsequent browser events to the same ID Django will use.

Step 2: Choose one immutable distinct ID

Every event needs the same answer to “who did this?” A practical canonical identifier for an authenticated Django user is:

def posthog_distinct_id(user) -> str:
    return f"user:{user.pk}"

The value should be unique, non-secret, and stable for the lifetime of the account. Email is a useful person property, but it is a poor canonical distinct_id because users change addresses. Usernames can change too. Do not use a session key: sessions expire and multiply.

PostHog's identify guide and identity-resolution documentation describe how anonymous browser history becomes associated with an authenticated person. The exact browser call depends on how your frontend receives the authenticated ID, but the contract should remain simple:

  1. Before authentication, the browser uses its anonymous ID.
  2. After authentication, identify the browser as user:<pk>.
  3. Django server events use the same user:<pk> value.
  4. Email and plan are mutable properties, never alternate canonical IDs.

Do not expose an ID merely by embedding more user data in it. user:1842 is enough. Authorization still belongs to Django; a PostHog ID is not a credential.

A repository audit found that Djass currently attaches its immutable profile_id to server events but captures them under email. The anonymous browser ID triggers a queued alias attempt during signup and login when the expected PostHog cookie is available. That demonstrates identity stitching, but the email-based server ID is migration work, not the pattern to copy. Choosing one immutable ID before launch avoids a later split-person cleanup.

Done when: the anonymous browser becomes user:<pk> after login and a server event for the same account appears under that exact value.

Step 3: Define an event contract before adding calls

An event name should represent one state transition. Give it a small required property set and one owner.

Event Owner Emit when Required properties
user_signed_up Django User and profile commit signup_method, source
user_authenticated Django Login succeeds auth_method, source
project_created Django Project row commits project_id, source
checkout_started Django Stripe returns a usable Checkout URL checkout_session_id, price_id
checkout_succeeded Stripe webhook handler Signature is valid and payment is paid checkout_session_id, stripe_event_id, amount, currency
checkout_failed Django or webhook handler A named failure boundary is reached reason, source

The table is a proposed contract. Current Djass uses entrypoint where the example uses source, and checkout_id where it uses checkout_session_id. Pick one vocabulary for your application and keep it stable. A source value might be ui, api, cli, or mcp; keep the values bounded. Do not send full request bodies, exception messages, cookies, payment details, or arbitrary form values as properties.

Version the contract in code:

from dataclasses import dataclass


@dataclass(frozen=True)
class AnalyticsEvent:
    name: str
    required_properties: frozenset[str]


PROJECT_CREATED = AnalyticsEvent(
    name="project_created",
    required_properties=frozenset({"project_id", "source"}),
)

This can remain a small module. Its job is to stop three views from emitting three meanings under the same name.

The Django allauth production guide applies the same principle to signup side effects: account creation must have a clear result even when analytics or another non-critical integration fails.

Done when: every event has one owner, one emission condition, a bounded property schema, and no second producer with a different meaning.

Step 4: Capture server events behind one function

Views, signals, and webhook handlers should not know SDK initialization details. Give them one narrow function:

# apps/analytics/events.py
from django.conf import settings

from .client import posthog


def capture_event(*, user, event: str, properties: dict) -> None:
    if not settings.POSTHOG_API_KEY:
        return

    posthog.capture(
        distinct_id=f"user:{user.pk}",
        event=event,
        properties=properties,
    )

Set mutable person properties such as email deliberately during identification or a dedicated profile update. Repeating email on every product event increases data exposure and blurs the event schema.

Treat analytics errors as observable integration failures, but usually not as a reason to return a 500 after the product transition already succeeded. Log the event name and an internal record ID; avoid logging the entire property payload when it may contain personal data.

The PostHog Python SDK buffers events. Its Python integration documentation notes that short-lived processes may exit before the buffer flushes and documents shutdown or synchronous capture for those environments. A long-lived Django or worker process has a different lifecycle from a serverless function. Choose delivery behavior for the runtime you actually deploy, and monitor SDK failures instead of assuming capture is exactly once.

Done when: views call one application function, an empty key is a no-op, and an SDK delivery error cannot silently change a successful product response into an ambiguous failure.

Step 5: Queue non-critical events after commit

A background worker keeps analytics network calls out of the response, but queueing before commit creates a race: the worker may read a row that later rolls back.

Django's transaction.on_commit() runs a callback only after a successful database commit. Pass primitive identifiers to the worker:

from django.db import transaction
from django_q.tasks import async_task


def record_project_created(*, project, source: str) -> None:
    transaction.on_commit(
        lambda: async_task(
            "apps.analytics.tasks.track_project_created",
            user_id=project.user_id,
            project_id=project.pk,
            source=source,
        )
    )
# apps/analytics/tasks.py
from django.contrib.auth import get_user_model

from .events import capture_event


def track_project_created(*, user_id: int, project_id: int, source: str) -> None:
    user = get_user_model().objects.get(pk=user_id)
    capture_event(
        user=user,
        event="project_created",
        properties={
            "project_id": str(project_id),
            "source": source,
        },
    )

Retry, redelivery, and in-flight loss depend on the queue and broker configuration. A worker can send an event and stop before recording success; some broker failures can also lose a task before retry. Design the task for possible duplicate execution and verify the chosen broker's loss contract. For high-value conversions, persist an analytics outbox row with a unique source event ID and record attempts. Whether PostHog or another destination deduplicates that ID is a destination-specific contract you must verify.

Djass uses Django Q2 for its server event tasks. The Q2 architecture guide covers worker operation and serializable task inputs; the background-task decision guide covers retries, idempotency, and when a larger queue system is justified.

Done when: a committed project queues one primitive-ID job, a rolled-back project queues nothing, and broker failure behavior is documented.

Step 6: Track billing truth from verified webhooks

A Checkout button click is intent. A Checkout Session URL proves Stripe accepted a session request. Neither proves payment.

Stripe's Checkout fulfillment guide recommends webhook-based fulfillment because the customer is not guaranteed to load your success page. Verify the signature against the raw request body, endpoint secret, and Stripe-Signature header as described in the Stripe webhook documentation.

Then separate the events:

# After Stripe returns a Checkout Session with a usable URL
record_checkout_started(
    user=request.user,
    checkout_session_id=session.id,
    price_id=price.id,
)
# Inside the verified checkout.session.completed handler
if session["payment_status"] == "paid":
    reconcile_paid_checkout(session)
    record_checkout_succeeded(
        user_id=local_user_id,
        checkout_session_id=session["id"],
        stripe_event_id=stripe_event["id"],
        amount=session["amount_total"],
        currency=session["currency"],
    )

Persist the Stripe event ID with a uniqueness constraint before applying the billing transition. That protects entitlement reconciliation from duplicate webhook delivery. Queue analytics after the local reconciliation commits. Still make the analytics task retry-safe: webhook deduplication and downstream capture are separate boundaries.

Delayed payment methods need their later success/failure webhook events, such as checkout.session.async_payment_succeeded, rather than assuming checkout.session.completed always means funds are final. The paid-state check shown here fits a card-only Checkout policy; extend the event contract when you enable other methods.

Djass follows the intent-versus-truth split and tests the paid event's amount, currency, price, Checkout ID, payment ID, and Stripe event ID. The Django Stripe subscriptions guide shows the full verified-webhook and entitlement lifecycle.

Done when: a success-page load cannot emit the paid event, an invalid or unpaid webhook emits no checkout_succeeded event, and one verified paid event maps to one committed billing transition.

Worked example: one Checkout funnel

Assume an anonymous visitor opens pricing, signs in as Django user 42, starts Checkout, and pays. The expected sequence is:

Order Source Event Identity Evidence
1 Browser pricing_viewed PostHog anonymous ID Pricing page rendered after capture was allowed
2 Browser identify("user:42") Anonymous history joins user:42 Django authentication succeeded
3 Browser checkout_clicked user:42 Authenticated person clicked the Checkout control
4 Django checkout_started user:42 Stripe returned a usable Checkout Session URL
5 Verified Stripe webhook checkout_succeeded user:42 Signature passed, paid state was confirmed, and local billing state committed

The browser calls posthog.identify("user:42") after authentication, so the earlier anonymous events can join the authenticated person according to PostHog's identity-resolution rules. Django resolves the same ID from the local user row. The verified webhook resolves its Stripe customer or metadata back to that local user; it does not trust a browser-supplied user ID.

If order 2 rises but order 3 falls, inspect session creation. If order 3 rises but order 4 falls, inspect payment and webhook processing. That diagnostic value comes directly from assigning each event to the layer that can prove it.

Step 7: Test the analytics contract

Do not make production dashboards the first place you discover a renamed event or missing property.

Start with the capture wrapper:

from unittest.mock import patch


@patch("apps.analytics.events.posthog.capture")
def test_capture_event_uses_stable_identity(capture, user, settings):
    settings.POSTHOG_API_KEY = "phc_test"

    capture_event(
        user=user,
        event="project_created",
        properties={"project_id": "42", "source": "api"},
    )

    capture.assert_called_once_with(
        distinct_id=f"user:{user.pk}",
        event="project_created",
        properties={
            "project_id": "42",
            "source": "api",
        },
    )

Add five test layers:

  1. Disabled capture: an empty key produces no SDK call.
  2. Transaction boundary: rollback discards the queue callback; commit runs it. Django's test utilities can capture on-commit callbacks.
  3. Producer contract: signup, login, project creation, and failure paths emit the expected name and required properties.
  4. Webhook contract: invalid signatures emit nothing; unpaid Checkout Sessions emit no success; duplicate Stripe events do not repeat the local billing transition.
  5. Browser smoke test: after consent where required, confirm one anonymous page view, authenticate, identify the same browser, and complete one staging funnel.

Set the PostHog client to disabled or mock capture during tests. Tests should assert your event contract without sending data over the network.

Djass currently covers server payloads, disabled-key behavior, signup/auth queueing, project events, paid webhook properties, and duplicate webhooks. Its test inventory does not directly prove browser cookie parsing or the eventual alias API call, so those remain browser/integration-test work rather than claimed coverage.

Done when: tests prove the stable ID and required properties, rollback suppresses dispatch, invalid/unpaid webhooks suppress success, and one staging browser completes the identity handoff.

Step 8: Define the privacy and deletion boundary

Analytics design is data design. Before capture, answer:

  • Which events are necessary for product decisions?
  • Which properties are personal data?
  • Is consent required before browser capture?
  • How long is event and person data retained?
  • What happens in PostHog when an account is deleted in Django?
  • Who can read analytics and export data?

Do not capture passwords, API keys, authorization headers, session or CSRF cookies, raw webhook bodies, complete form payloads, or payment instrument data. Apply an application-owned property allowlist before the SDK boundary. A before_send hook can add another filter, but PostHog documents that an exception inside that callback can leave the original event eligible to send; do not make it the only privacy boundary.

Deleting a Django user does not automatically prove deletion of the associated PostHog person. If your policy promises external deletion, implement and test that provider operation separately. Likewise, a privacy-policy disclosure does not prove that runtime capture honors a consent choice.

Done when: captured properties follow an allowlist, runtime behavior matches the consent policy, and account deletion has an explicit local and PostHog outcome.

Common Django analytics mistakes

Avoid these failure patterns:

  • Using email, username, or session ID as the canonical identity. Use an immutable local user/profile ID and keep mutable values as person properties.
  • Capturing the same page view in JavaScript and Django. Give navigation to the browser and committed transitions to the server.
  • Queueing before commit. Dispatch in transaction.on_commit() so a worker cannot observe state that rolls back.
  • Treating a success-page redirect as payment truth. Emit paid conversion from a verified webhook after local reconciliation.
  • Sending arbitrary dictionaries. Bound event names and properties; never forward cookies, request bodies, or exception text.
  • Letting tests send network events. Disable or mock the client and assert the application contract.
  • Assuming exactly-once capture. Workers and networks have failure windows. Persist an outbox for high-value events and verify the broker and destination contracts separately.

Production verification checklist

Before trusting the funnel:

  • Confirm development and test environments cannot write to production.
  • Confirm browser and server events use the same immutable distinct_id.
  • Trigger one signup, authentication, activation, Checkout intent, and paid webhook in staging.
  • Compare each PostHog event with the corresponding Django or Stripe record.
  • Roll back a transaction and confirm its analytics job is not queued.
  • Replay a Stripe event and confirm entitlement reconciliation is unchanged.
  • Stop a worker during capture and inspect retry/duplicate behavior.
  • Search logs for analytics failures without logging sensitive properties.
  • Exercise consent and deletion behavior promised by your privacy policy.
  • Alert on a sustained drop in server-truth events, not just total page views.

If you generate a Djass project, use_posthog is available through the UI, API, CLI, and MCP option catalog. Review the generator options and use --set use_posthog=y with the Djass CLI to select the integration. The generated starter is a starting point; keep your product's identity, event ownership, consent, and billing truth explicit as the application evolves.

The result is a Django analytics system with useful failure boundaries. Browser events explain what people tried. Django events explain what the application accepted. Verified webhooks explain what external systems confirmed. When those facts share one stable identity and a tested contract, the funnel becomes an operational tool rather than a hopeful chart.

When analytics properties include a rollout variant, keep exposure and outcome events distinct from the release decision itself. The Django feature-flags production guide covers that decision boundary, server-side evaluation, rollback, and stale-flag cleanup.

Django and PostHog FAQ

Does PostHog support Django?

Yes. PostHog provides a Python SDK and Django context middleware for current WSGI and ASGI applications. You can also use an explicit application-owned client, as this guide does, when every capture call provides identity and properties directly.

Where does PostHog middleware go in Django?

Place posthog.integrations.django.PosthogContextMiddleware after Django's AuthenticationMiddleware. Configure the global SDK client for that path; do not assume it will use an unrelated Posthog instance from another module.

Which PostHog key belongs in Django?

Use the project ingest key and regional ingest host for event capture. The browser project key is intended to appear in client code. Personal and management keys are privileged credentials and must remain server-side.

How do browser and server events share identity?

The browser starts with a PostHog anonymous ID, then calls identify() with the same immutable ID used by Django server events. Call reset() on logout. Test the handoff in a real browser instead of building identity from a mutable email address.

Why must payment success come from a webhook?

A success-page redirect can be skipped, repeated, or loaded before an external outcome is final. A verified provider webhook supplies the evidence needed to reconcile local billing state and emit the corresponding server-truth event.