mrkeyoor.com_
Thu 06 Aug 15:40 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability2/5Version 6.0.0 changed capture() so the event name is first and the rest are keyword-only, deliberately breaking old positional calls, 7.0.0 dropped Python 3.9, and the 7.x line ships minor and patch releases most days, several of which quietly change flag payload types or dedupe behavior.
Docs3/5Docstrings in the source are unusually thorough, with argument tables and runnable examples for every public function, and posthog.com covers Python, Django and Flask; the README itself is a stub that only links out, and the AI and context features are documented mostly in changelog entries.
Maintenance5/5Pushed the same day this was written, 7.38.0 released 5 August 2026, only 3 open issues (9 counting PRs), and a changelog full of specific bug fixes from PostHog staff rather than drive-by patches.
Ecosystem4/5Around 11.5 million downloads a week with first-party Django and Celery integrations, an OpenFeature provider, and AI wrappers for the major model providers, though everything it does is tied to one vendor's backend.

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
Skip it if

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 tenant

Tags 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})
    raise

Calling 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 = True

The 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_send

Returning 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

PackageRegistryPick it when
sentry-sdkPyPIYour real need is error and performance monitoring rather than product analytics, and you want grouping, releases and alerting out of the box.
mixpanelPyPIYou want product analytics from a vendor with a longer track record and no interest in feature flags or session replay.
analytics-pythonPyPIYou want to fan events out to many downstream tools through Segment instead of coupling your backend to one analytics vendor.
opentelemetry-sdkPyPIYou need vendor-neutral traces and metrics for engineering rather than product questions, and want to choose the backend later.