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
- Configure browser and server clients from the same ingest settings.
- Use one canonical authenticated ID across the browser and Django; merge anonymous history when the person identifies.
- Define event names, owners, and required properties.
- Put server capture behind one application function.
- Queue non-critical events only after the database commits.
- Record paid conversion from a verified provider webhook.
- Test payloads, rollback, retries, and negative paths.
- 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:
- Before authentication, the browser uses its anonymous ID.
- After authentication, identify the browser as
user:<pk>. - Django server events use the same
user:<pk>value. - 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:
- Disabled capture: an empty key produces no SDK call.
- Transaction boundary: rollback discards the queue callback; commit runs it. Django's test utilities can capture on-commit callbacks.
- Producer contract: signup, login, project creation, and failure paths emit the expected name and required properties.
- Webhook contract: invalid signatures emit nothing; unpaid Checkout Sessions emit no success; duplicate Stripe events do not repeat the local billing transition.
- 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.