posthog
posthog is the official Python SDK for PostHog, the product analytics platform. It does three jobs from your backend: it sends events (capture), it reads feature flags and experiment variants (feature_enabled, get_feature_flag, evaluate_flags), and it reports exceptions. Events go onto an in-memory queue that background worker threads batch and POST to the ingestion API, so a capture call costs microseconds in your request path rather than a network round trip. You can configure it globally by setting posthog.api_key and calling module-level functions, or create a Posthog(...) instance per application if you talk to more than one project. Recent versions also ship wrappers for OpenAI, Anthropic, Gemini and LangChain under posthog.ai, which record LLM calls as traces with token counts and cost attribution.
If your product already runs on PostHog, this is the SDK and it does the job well, with feature flags and LLM tracing that competitors charge extra for. Pin an exact version and wire up shutdown(), because the release pace is relentless and silent event loss on exit is the failure mode you will otherwise ship.
Use it if
- You already send frontend events with posthog-js and need the backend half: payments, webhook handlers, cron jobs and anything a browser never sees
- You want server-evaluated feature flags and experiments, including local evaluation that resolves flags from a polled definition set with no per-request HTTP call
- You are instrumenting LLM calls and want traces with token counts and provider costs without writing your own wrapper, which posthog.ai gives you for OpenAI, Anthropic, Gemini and LangChain
- You run Django and want request-scoped context for free: PosthogContextMiddleware attaches session and distinct IDs from headers so every event captured during a request is grouped
- You do not already run PostHog: the SDK is useless on its own, and self-hosting the platform means operating ClickHouse and Kafka, so in practice this is a commitment to PostHog Cloud and its usage-based bill
- You need a stable dependency you can leave alone: PyPI shows more than 260 releases, several landed on a single day in August 2026, and 6.0.0 reordered capture() so event name comes first, which broke every existing call
- Your process is short-lived: events sit in a queue that background threads flush every 5 seconds or every 100 events, so a Lambda handler, a management command or a forked Celery worker loses whatever is still buffered unless you call flush() or shutdown() yourself
- You are writing asyncio code and care about the event loop: the transport is requests, so flag reads such as get_feature_flag block the loop, and there is no async client to await
- You want error tracking as the main feature: exception capture here is a side feature bolted onto an analytics SDK, and Sentry gives you grouping, releases, alerting and source maps that this does not
Setup reality
pip install posthog pulls requests, backoff, distro and typing-extensions, and needs Python 3.10 or newer since 7.0.0 dropped 3.9. Configuration has two styles and mixing them is the usual first bug: either set posthog.api_key (or posthog.project_api_key) plus posthog.host at import time and call module functions, or build one Posthog(project_api_key, host=...) instance and pass it around. The host default points at US ingestion, so EU projects that forget host="https://eu.i.posthog.com" send data into a project that does not exist and see nothing. Failures are swallowed by design: a bad key, a blocked egress or an oversized payload produces no exception, so set debug=True locally, which also re-raises errors from public methods, or pass an on_error callback. Local flag evaluation needs a personal API key rather than the project key, which is a broader-scope credential you should treat as a secret and scope carefully; without it every flag read is an HTTP call to /flags. Extras are separate: posthog[langchain], posthog[otel] and posthog[zstd], and the posthog.ai wrappers expect you to install the provider SDK (openai, anthropic) yourself. Finally, always call posthog.shutdown() before the process exits, since it flushes the queue and joins the consumer threads.
Patterns
Configure the SDK globally or as an instanceinitialize-client
# global style: set once at import time, then call module functions
import posthog
posthog.api_key = "phc_your_project_key"
posthog.host = "https://eu.i.posthog.com" # omit only if your project is US
posthog.debug = True # local only: logs and re-raises errors
# instance style: preferred in libraries and multi-project apps
from posthog import Posthog
client = Posthog(
"phc_your_project_key",
host="https://eu.i.posthog.com",
flush_at=100,
flush_interval=5.0,
)The two styles do not share state: setting posthog.api_key does nothing for a Posthog instance you created yourself, and vice versa. If api_key is empty the client silently disables itself and every call becomes a no-op, which looks exactly like a working install that sends nothing.
Send an event with propertiescapture-event
import posthog
posthog.capture(
"subscription upgraded",
distinct_id="user_42",
properties={"plan": "pro", "mrr": 49, "source": "billing-webhook"},
)Since 6.0.0 the event name is the first positional argument and everything else is keyword-only; old code written as capture(distinct_id, event) now raises rather than sending a mislabelled event. Event names are conventionally 'noun verb' in past tense, because that is how PostHog's own docs group them.
Attach properties to a person recordidentify-person
import posthog
posthog.set(
distinct_id="user_42",
properties={"plan": "pro", "company": "Acme"},
)
# only write these if the person does not already have them
posthog.set_once(
distinct_id="user_42",
properties={"first_seen_at": "2026-08-06", "signup_source": "organic"},
)set overwrites on every call, so putting a mutable value such as last_login in it means the person record is rewritten constantly; set_once is the right call for anything that describes acquisition. Both need a distinct_id, from the argument or the active context, otherwise the call does nothing.
Check a feature flag for a userread-feature-flag
import posthog
if posthog.feature_enabled("new-dashboard", "user_42"):
render_new_dashboard()
# multivariate flags return the variant key as a string
variant = posthog.get_feature_flag(
"pricing-experiment",
"user_42",
person_properties={"plan": "pro"},
)
if variant == "control":
...Both calls hit the /flags endpoint synchronously unless local evaluation is configured, so they add real latency inside a request handler. Pass the person_properties your flag conditions use, otherwise the server has to look the person up and cohort-based conditions may not match.
Evaluate every flag once per request and reuse the resultevaluate-flags-once
from posthog import evaluate_flags, capture
flags = evaluate_flags(
"user_42",
person_properties={"plan": "enterprise"},
flag_keys=["new-dashboard", "pricing-experiment"],
)
if flags.is_enabled("new-dashboard", default_value=False):
render_new_dashboard()
capture("page viewed", distinct_id="user_42", flags=flags)One /flags request instead of one per flag, and passing the snapshot to capture(flags=...) records the exact values the code branched on rather than re-evaluating at capture time. flag_keys narrows the request when a project has hundreds of flags. The default_value argument on is_enabled arrived in 7.38.0.
Evaluate flags in-process with no network calllocal-flag-evaluation
from posthog import Posthog
client = Posthog(
"phc_your_project_key",
personal_api_key="phx_your_personal_key", # required for local evaluation
poll_interval=30,
enable_local_evaluation=True,
)
enabled = client.feature_enabled(
"new-dashboard",
"user_42",
person_properties={"plan": "pro"},
only_evaluate_locally=True,
)A background poller refreshes flag definitions every poll_interval seconds, so evaluation becomes a local comparison. It only works when you supply every property the flag conditions reference; with only_evaluate_locally=True a flag needing data you did not pass returns None instead of quietly falling back to an HTTP call.
Group everything in a request under one contextrequest-context
from posthog import new_context, identify_context, tag, capture
with new_context():
identify_context("user_42")
tag("request_id", request_id)
tag("tenant", "acme")
capture("invoice generated") # no distinct_id needed
capture("email queued") # both carry request_id and tenantTags set inside the block land on the properties of every event captured in it, including exceptions raised and caught inside. Contexts are stored per task and are reset in forked children, so a worker forked from a parent that had a context open starts clean instead of inheriting the parent's user.
Report an exception you caughtcapture-exception
from posthog import capture_exception
try:
charge_card(order)
except PaymentError as err:
capture_exception(err, distinct_id="user_42", properties={"order_id": order.id})
raiseCalling it twice with the same exception instance records one occurrence, so manual capture inside a context block does not double count. The stack trace only spans raise to capture, so catching far from the raise site gives you a shorter trace than you expect.
Stop losing events in scripts, jobs and serverlessflush-before-exit
import atexit
import posthog
posthog.api_key = "phc_your_project_key"
atexit.register(posthog.shutdown)
# in a Lambda-style handler, flush at the end of every invocation
def handler(event, context):
posthog.capture("job finished", distinct_id="job-runner")
posthog.flush(timeout_seconds=5)
# or take the latency hit and send inline
sync_client = posthog.Posthog("phc_your_project_key", sync_mode=True)Default batching flushes at 100 events or every 5 seconds, whichever comes first, so a process that exits sooner drops the tail. shutdown() flushes and joins the consumer threads and is terminal, meaning captures after it are ignored. sync_mode sends each event inline instead, which is correct in serverless and slow everywhere else.
Add per-request context in Djangodjango-middleware
# settings.py
MIDDLEWARE = [
# ...
"posthog.integrations.django.PosthogContextMiddleware",
]
POSTHOG_MW_EXTRA_TAGS = lambda request: {"path": request.path}
POSTHOG_MW_CAPTURE_EXCEPTIONS = TrueThe middleware opens a context per request and reads the session and distinct IDs from the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers, falling back to the authenticated user id, which is what stitches backend events to frontend sessions. Header values are sanitized and length-capped because they are user-controlled input.
Trace LLM calls with the drop-in provider wrapperllm-observability
from posthog import Posthog
from posthog.ai.openai import OpenAI
ph = Posthog("phc_your_project_key", host="https://eu.i.posthog.com")
client = OpenAI(api_key="sk-...", posthog_client=ph)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "summarize this ticket"}],
posthog_distinct_id="user_42",
posthog_properties={"feature": "ticket-summary"},
)The wrapper has the same surface as the real OpenAI client and adds posthog_ arguments, so swapping the import is the whole migration. Pointing it at an OpenAI-compatible endpoint still reports $ai_provider as openai unless you pass posthog_provider_override, which throws off cost attribution. Set privacy_mode=True on the client to record usage and metadata without prompt or completion text.
Drop or redact properties before they leave the processscrub-event-properties
import posthog
def before_send(event):
if event.get("event") == "debug ping":
return None # drop it entirely
props = event.get("properties", {})
if "email" in props:
props["email"] = "[redacted]"
return event
posthog.before_send = before_sendReturning None drops the event, returning the dict sends the modified version. This is the only hook that runs before the payload is queued, so it is the right place for PII rules; a callback that raises is caught and the consumer stays alive rather than taking the process down with it.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| sentry-sdk | PyPI | Your real need is error and performance monitoring rather than product analytics, and you want grouping, releases and alerting out of the box. |
| mixpanel | PyPI | You want product analytics from a vendor with a longer track record and no interest in feature flags or session replay. |
| analytics-python | PyPI | You want to fan events out to many downstream tools through Segment instead of coupling your backend to one analytics vendor. |
| opentelemetry-sdk | PyPI | You need vendor-neutral traces and metrics for engineering rather than product questions, and want to choose the backend later. |