The Python SDK includes an asyncio-native client in version 7.45.0 and later. Continue to use Posthog in synchronous apps. For an asyncio app, install the optional async dependencies:
Terminal
bash
pip install "posthog[async]>=7.45.0"
Import AsyncPosthog, the customer-facing name for AsyncClient. Both names provide the same async context manager and lifecycle methods. Keep one client for the lifetime of your app. For example, use a FastAPI lifespan handler:
Exiting the context calls shutdown(). This flushes buffered events, waits for in-flight operations, stops the workers, and closes the HTTP transport. If you don’t use the context manager, call await posthog.shutdown() during app shutdown. await posthog.join() has the same effect.
Await evaluate_flags() once, then use its snapshot with synchronous in-memory accessors. Pass the same snapshot to capture() to attach the exact values used for branching without another feature flag request:
Python
python
flags = await posthog.evaluate_flags("user-distinct-id")if flags.is_enabled("new-checkout"): # Show the new checkout passposthog.capture( "checkout started", distinct_id="user-distinct-id", flags=flags,)
The snapshot provides synchronous is_enabled(), get_flag(), and get_flag_payload() accessors. The awaited evaluate_flags() call also accepts groups, person_properties, group_properties, disable_geoip, flag_keys, and device_id arguments.
Identifying users is required. Backend events need a distinct_id to associate events with the correct user.
In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct distinct_id. Typically, you would set a fresh context and identify at the top of each route.
Python
python
from posthog import new_context, identify_context, capture@app.get("/foo")def foo(current_user: User = Depends(get_current_user)): with new_context(): # Set context at the top of a route identify_context(current_user.id) capture("foo_viewed") return {"status": "ok"}
When possible, write a small piece of middleware that resolves your authenticated user, wrap a context around the request, and identifies it. Every capture() downstream is then attributed automatically. The SDK’s Django middleware does this automatically and you can replicate it when using the plain Python SDK.
# Events captured with no context or explicit distinct_id are marked as personless and have an auto-generated distinct_id:posthog.capture('some-anon-event')from posthog import identify_context, new_context# Use contexts to manage user identification across multiple capture callswith new_context(): identify_context('distinct_id_of_the_user') posthog.capture('user_signed_up') posthog.capture('user_logged_in') # You can also capture events with a specific distinct_id posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.
If you’re aiming for a backend-only implementation of PostHog and won’t be capturing events from your frontend, you can send pageviews from your backend like so:
To capture anonymous events (opens in a new tab) without person profiles, set the event’s $process_person_profile property to False. Events captured with no context or explicit distinct_id are marked as personless, and will have an auto-generated distinct_id:
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
In this case, you can use alias to assign another distinct ID to the same user.
The Python SDK uses nested contexts for managing state that’s shared across events. Contexts are the recommended way to manage things like “which user is taking this action” (through identify_context), rather than manually passing user state through your apps stack.
When events (including exceptions) are captured in a context, the event uses the user distinct ID (opens in a new tab), session ID (opens in a new tab), and tags that are (optionally) set in the context. This is useful for adding properties to multiple events during a single user’s interaction with your product.
You can enter a context using the with statement:
Python
python
from posthog import new_context, tag, set_context_session, identify_contextwith new_context(): tag("transaction_id", "abc123") tag("some_arbitrary_value", {"tags": "can be dicts"}) # Sessions are UUIDv7 values and used to track a sequence of events that occur within a single user session # See https://posthog.com/docs/data/sessions set_context_session(session_id) # Setting the context-level distinct ID. See below for more details. identify_context(user_id) # This event is captured with the distinct ID, session ID, and tags set above posthog.capture("order_processed")
Contexts are persisted across function calls. If you enter one and then call a function and capture an event in the called function, it uses the context tags and session ID set in the parent context:
Python
python
from posthog import new_context, tagdef some_function(): # When called from `outer_function`, this event is captured with the property some-key="value-4" posthog.capture("order_processed")def outer_function(): with new_context(): tag("some-key", "value-4") some_function()
Contexts are nested, so tags added to a parent context are inherited by child contexts. If you set the same tag in both a parent and child context, the child context’s value overrides the parent’s at event capture (but the parent context won’t be affected). This nesting also applies to session IDs and distinct IDs.
Python
python
from posthog import new_context, tagwith new_context(): tag("some-key", "value-1") tag("some-other-key", "another-value") with new_context(): tag("some-key", "value-2") # This event is captured with some-key="value-2" and some-other-key="another-value" posthog.capture("order_processed") # This event is captured with some-key="value-1" and some-other-key="another-value" posthog.capture("order_processed")
You can disable this nesting behavior by passing fresh=True to new_context:
Python
python
from posthog import new_context, tagwith new_context(fresh=True): tag("some-key", "value-2") # This event only has the property some-key="value-2" from the fresh context posthog.capture("order_processed")
Note: Distinct IDs, session IDs, and properties passed directly to calls to capture and related functions override context state in the final event captured.
Contexts can be associated with a distinct ID by calling posthog.identify_context:
Python
python
from posthog import identify_contextidentify_context("distinct-id")
Within a context associated with a distinct ID, all events captured are associated with that user. You can override the distinct ID for a specific event by passing a distinct_id argument to capture:
Python
python
from posthog import new_context, identify_contextwith new_context(): identify_context("distinct-id") posthog.capture("order_processed") # will be associated with distinct-id posthog.capture("order_processed", distinct_id="another-distinct-id") # will be associated with another-distinct-id
It’s recommended to pass the currently active distinct ID from the frontend to the backend, using the X-POSTHOG-DISTINCT-ID header. If you’re using our Django middleware, this is extracted and associated with the request handler context automatically.
Contexts can be associated with a session ID by calling posthog.set_context_session. When linking backend events to frontend sessions, use the session ID from the frontend SDK (PostHog session IDs are UUIDv7 strings).
Python
python
from posthog import new_context, set_context_sessionwith new_context(): set_context_session(request.get_header("X-POSTHOG-SESSION-ID"))
Using PostHog on your frontend too?
If you’re using the PostHog JavaScript Web SDK on your frontend, it generates a session ID for you. Configure tracing_headers (opens in a new tab) for your backend hostname to add the session and distinct ID headers to browser requests automatically.
You need to extract the header in your request handler (if you’re using our Django middleware integration, this happens automatically).
If you associate a context with a session, you’ll be able to do things like:
See backend events on the session timeline when viewing session replays
View session replays for users that triggered a backend exception in error tracking
By default exceptions raised within a context are captured and available in the error tracking (opens in a new tab) dashboard. You can override this behavior by passing capture_exceptions=False to new_context:
Python
python
from posthog import new_context, tagwith new_context(capture_exceptions=False): tag("transaction_id", "abc123") tag("some_arbitrary_value", {"tags": "can be dicts"}) # This event will be captured with the tags set above posthog.capture("order_processed") # This exception will not be captured raise Exception("Order processing failed")
The SDK exposes a function decorator. It takes the same fresh and capture_exceptions arguments as new_context and provides a handy way to mark a whole function as being in a new context. For example:
Python
python
from posthog import scoped, identify_context@scoped(fresh=True)def process_order(user, order_id): identify_context(user.distinct_id) posthog.capture("order_processed") # Associated with the user raise Exception("Order processing failed") # This exception is also captured and associated with the user
Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the Group Analytics (opens in a new tab) guide for more information.
Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on our pricing page (opens in a new tab).
To capture an event and associate it with a group:
The name is a special property which is used in the PostHog UI for the name of the group. If you don’t specify a name property, the group ID will be used instead.
The examples in this section use the synchronous Posthog client. For AsyncPosthog, use the awaited feature flag example. The returned snapshot uses the same accessors.
PostHog’s feature flags (opens in a new tab) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
There are two steps to implement feature flags in Python:
flags = posthog.evaluate_flags("distinct_id_of_your_user")if flags.is_enabled("flag-key"): # Do something differently for this user # Optional: fetch the payload matched_flag_payload = flags.get_flag_payload("flag-key")
flags = posthog.evaluate_flags("distinct_id_of_your_user")enabled_variant = flags.get_flag("flag-key")if enabled_variant == "variant-key": # replace "variant-key" with the key of your variant # Do something differently for this user # Optional: fetch the payload matched_flag_payload = flags.get_flag_payload("flag-key")
flags.get_flag() returns the variant string for multivariate flags, True for enabled boolean flags, False for disabled flags, and None when the flag wasn’t returned by the evaluation.
Note:posthog.feature_enabled(), posthog.get_feature_flag(), posthog.get_feature_flag_payload(), and posthog.capture(send_feature_flags=True) still work during the migration period, but they’re deprecated. Prefer posthog.evaluate_flags() for new code.
If you want use your feature flag to breakdown or filter events in your insights (opens in a new tab), you’ll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
Note: This step is only required for events captured using our server-side SDKs or API (opens in a new tab).
There are two methods you can use to include feature flag information in your events:
Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn’t make another /flags request.
Python
python
flags = posthog.evaluate_flags("distinct_id_of_your_user")if flags.is_enabled("flag-key"): # Do something differently for this user passposthog.capture( "event_name", distinct_id="distinct_id_of_your_user", flags=flags,)
By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.
To reduce event property bloat, pass a filtered snapshot:
Python
python
# Attach only flags accessed with is_enabled() or get_flag() before this callposthog.capture( "event_name", distinct_id="distinct_id_of_your_user", flags=flags.only_accessed(),)# Attach only specific flagsposthog.capture( "event_name", distinct_id="distinct_id_of_your_user", flags=flags.only(["checkout-flow", "new-dashboard"]),)
only_accessed() is order-dependent. If you call it before accessing any flags with is_enabled() or get_flag(), no feature flag properties are attached.
In the event properties, include $feature/feature_flag_name: variant_key:
Python
python
posthog.capture( "event_name", distinct_id="distinct_id_of_the_user", properties={ # Replace feature-flag-key with your flag key and "variant-key" with the key of your variant "$feature/feature-flag-key": "variant-key", },)
Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (opens in a new tab) on the flag. With posthog.evaluate_flags(), the SDK sends this event when you call flags.is_enabled() or flags.get_flag() for a flag.
The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don’t affect your analytics.
flags.get_flag_payload() doesn’t send $feature_flag_called events and doesn’t count as an access for only_accessed().
You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.
For example:
Python
python
flags = posthog.evaluate_flags( "distinct_id_of_the_user", person_properties={"property_name": "value"}, groups={ "your_group_type": "your_group_id", "another_group_type": "your_group_id", }, group_properties={ "your_group_type": {"group_property_name": "value"}, "another_group_type": {"group_property_name": "value"}, },)if flags.is_enabled("flag-key"): # Do something differently for this user
By default, a user’s GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you’re not using PostHog on your frontend, so the user has no GeoIP properties.
You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you’re evaluating flags on your backend and want to use the client’s location instead of your server’s location.
The following GeoIP properties can be overridden:
$geoip_country_code
$geoip_country_name
$geoip_city_name
$geoip_city_confidence
$geoip_continent_code
$geoip_continent_name
$geoip_latitude
$geoip_longitude
$geoip_postal_code
$geoip_subdivision_1_code
$geoip_subdivision_1_name
$geoip_subdivision_2_code
$geoip_subdivision_2_name
$geoip_subdivision_3_code
$geoip_subdivision_3_name
$geoip_time_zone
Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.
You can configure the feature_flags_request_timeout_seconds parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog’s servers are too slow to respond. By default, this is set to 3 seconds.
Python
python
posthog = Posthog( "<ph_project_token>", host="https://us.i.posthog.com", feature_flags_request_timeout_seconds=3, # Time in seconds. Defaults to 3.)
Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.
It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.
In multi-worker or edge environments, you can implement custom caching for flag definitions using Redis, Cloudflare KV, or other storage backends. This enables sharing definitions across workers and coordinating fetches. See our guide for local evaluation in distributed environments (opens in a new tab) for details.
Since experiments (opens in a new tab) use feature flags, the code for running an experiment is very similar to the feature flags code. This example uses the synchronous Posthog client:
Our Python SDK includes a built-in AI Observability feature. It enables you to capture LLM usage, performance, and more. Check out our analytics docs (opens in a new tab) for more details on setting it up.
The Python SDK can automatically capture the state of local variables when an exception occurs. This gives you a debugger-like view of your application state at the time of the error:
Tracing is new in the Python SDK and its API can still change in a minor release. Spans you send are kept – it’s the SDK surface that isn’t frozen yet.
Tracing records spans – timed units of work – so you can see where time went in a request and how work fans out across your services. Spans created inside a context automatically carry the person and session they belong to, so a slow trace links back to the person who experienced it.
Tracing is off until you set the traces option. No OpenTelemetry dependency is required. For what you can do with spans once they arrive, see Distributed tracing (opens in a new tab).
Python
python
from posthog import Posthogposthog = Posthog( "<ph_project_token>", host="https://us.i.posthog.com", traces={"service_name": "checkout-api"},)
If you use the module-level API instead, set posthog.traces = {"service_name": "checkout-api"} alongside your other options, before you start the first span.
Set service_name – PostHog groups operations by service and span name.
Tracing is available on the synchronous Posthog client and the module-level API. AsyncPosthog doesn’t support it yet.
start_span returns a span. Use it in a with block to make it the active span for the block and end it when the block exits. Spans started inside the block nest underneath it automatically.
Python
python
with posthog.start_span("POST /checkout", kind="server") as span: span.set_attribute("plan", user.plan) with posthog.start_span("create-order"): order = create_order(cart) with posthog.start_span("charge-card"): stripe.charge(order)
If an exception escapes the block, the span records it, its status is set to error, and the exception propagates unchanged. KeyboardInterrupt, GeneratorExit, and asyncio.CancelledError still end the span but aren’t recorded as failures.
The recorded exception includes the stack trace, which contains file paths from your server. If you’d rather those didn’t leave your process, remove exception.stacktrace in before_span_send.
For work that can’t wrap a block, call start_span without with. A span started this way isn’t active, so spans started afterwards aren’t its children unless you pass parent explicitly – and you must call end() yourself.
Python
python
span = posthog.start_span("background-sync", attributes={"queue": "emails"})try: # Explicitly parent a child to a span that isn't active. child = posthog.start_span("send-batch", parent=span) child.end()finally: span.end()
get_active_span() returns the span active in the current context, or None when there isn’t one.
The active span is tracked with contextvars (opens in a new tab), so it carries across await in asyncio code. Threads don’t reliably inherit it – pass parent=span to continue the trace in a thread you start or a ThreadPoolExecutor task. A forked child process starts with no active span, so pass parent there too.
start_span always returns a usable span, even when tracing is off, so your code never needs to check whether tracing is enabled.
Span names should be low-cardinality operation names – GET /users/:id, not GET /users/123. Variable values belong in attributes. Strings, booleans, integers, and floats keep their type. Lists and dictionaries are sent as arrays and maps, but PostHog stores them as serialized strings. Setting an attribute to None removes it, and any other value is converted to a string.
Python
python
with posthog.start_span("GET /users/:id", kind="server") as span: span.set_attributes({"user.id": user_id, "db.rows": len(rows)}) span.add_event("cache-miss") if not rows: span.set_status("error", "user not found")
Method
Description
set_attribute(key, value)
Set a single attribute
set_attributes(attributes)
Merge several attributes at once
add_event(name, attributes=None, timestamp=None)
Record a timestamped event within the span
set_status(code, message=None)
Set the outcome: "ok" or "error". "ok" is final – an exception raised later in the with block doesn’t override it
record_exception(exception)
Attach an exception event carrying the type, message, and – for a raised exception – stack trace, and set status to error. Use it for exceptions you catch and handle
update_name(name)
Replace the span name, e.g. once a route template resolves
traceparent()
This span’s W3C traceparent header value, or None
tracestate()
This span’s W3C tracestate value, or None when it has none
end(end_time=None)
End the span and queue it for export. A with block does this for you
Every method except traceparent(), tracestate(), and end() returns the span, so calls chain. Calls after end() are ignored.
start_span takes these keyword arguments:
Argument
Description
kind
What the work is: "internal" (default), "server" for an inbound request, "client" for an outbound call, "producer" or "consumer" for queue work
attributes
Attributes to set at span start
parent
A span, or an inbound W3C traceparent string to continue a trace another service started. Defaults to the active span
tracestate
The W3C tracestate accompanying a traceparent string. Ignored when parent is a span, which inherits its parent’s
start_time
Backdate the span’s start, as a datetime or seconds since the epoch. The server clamps a start more than 24 hours old to receive time; with debug on, the SDK prints a debug message when you pass one
Spans use W3C Trace Context (opens in a new tab), so a trace can span several services. Pass an inbound traceparent header as parent to continue a trace another service started, and send span.traceparent() onward when you call out.
app.py
python
import requestsfrom flask import request@app.post("/checkout")def checkout(): with posthog.start_span( "POST /checkout", kind="server", parent=request.headers.get("traceparent"), ) as span: traceparent = span.traceparent() requests.post( "https://payments.internal/charge", headers={"traceparent": traceparent} if traceparent else {}, ) return {"status": "ok"}
A malformed traceparent starts a new trace rather than raising. A missing one (None) falls back to the active span, if there is one.
A continued trace propagates the sampled flag it was handed, so a downstream sampler sees the decision the head service made. PostHog itself doesn’t sample – a span is recorded and exported whichever way that flag is set.
Spans created inside a context that has a distinct ID or session ID automatically carry posthogDistinctId and sessionId attributes, which is what makes a trace reachable from a person or a Session Replay recording. In Django, the contexts middleware (opens in a new tab) sets these for every request. Elsewhere, set them yourself:
Python
python
from posthog import new_context, identify_context, set_context_sessionwith new_context(): identify_context(user.id) set_context_session(session_id) with posthog.start_span("POST /checkout"): process_order()
Spans created outside a context with those values omit the attributes.
before_span_send runs on every finished span before it’s queued for export. It receives the span as a dict with name, kind, status, attributes, events, start_time_ns, end_time_ns, trace_id, span_id, and parent_span_id. Edit it and return it, or return None to drop the span entirely.
Attributes are plain Python values, not the OTLP wire encoding. The hook runs after PostHog attaches posthogDistinctId and sessionId, so those are visible to the hook and can be scrubbed too. An exception’s stack trace is on its event, under event["attributes"]["exception.stacktrace"].
trace_id, span_id, and parent_span_id are read-only. Rewriting them would orphan child spans that have already been exported, so changes are reverted.
A hook that raises drops the span rather than exporting it without scrubbing.
Pass a list to run several hooks in order. The first one to return None stops the chain.
The hook must be a regular function. An async hook drops every span.
If an entry isn’t callable, tracing turns off for the client rather than exporting spans the hook was meant to scrub.
A span is capped at 128 attributes and 128 events, each event at 128 attributes, and each string attribute value at 8192 characters. The endpoint rejects a span that’s too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there.
Past the cap, the earliest attributes and events are kept and the number dropped is reported alongside the span, so a truncated span reads as truncated rather than as quietly incomplete. The attributes PostHog attaches itself – posthogDistinctId and sessionId – don’t count toward the cap and are never dropped, so a span at the limit still links back to its person and session.
The event cap is absolute: an exception event the SDK records for you spends an ordinary slot like any other. A span that fills its events and then raises keeps its error status but not the exception detail, and reports the loss as a dropped event. Raise max_events_per_span on spans that record many events and can also fail.
The length bound reaches inside a value, including strings nested in lists and dictionaries, and applies to exception.stacktrace like any other attribute – a long stack trace keeps its last 8192 characters. All four caps are re-applied after before_span_send, so a hook that enriches a span can’t push it back over.
Spans are exported on a background interval, even with sync_mode on. Both flush() and shutdown() export spans that have already ended. A span still open at flush() is exported once it ends; a span still open at shutdown() is discarded with a warning, so end your spans before shutting down – a with block does this for you. shutdown() gives queued spans up to 30 seconds to send.
In a serverless handler, call flush() before returning. Events and spans are flushed concurrently, so it costs one round trip, not two.
Python
python
def handler(event, context): with posthog.start_span("handler"): do_work() posthog.flush()
A script that exits without calling shutdown() still gets a brief best-effort flush at exit, but don’t rely on it for spans you need.
Before posthog-python v3.0, we added GeoIP properties to all incoming events by default. We also used these properties for feature flag evaluation, based on the IP address of the request. This isn’t ideal since they are created based on your server IP address, rather than the user’s, leading to incorrect location resolution.
As of posthog-python v3.0, the default now is to disregard the server IP, not add the GeoIP properties, and not use the values for feature flag evaluations.
You can go back to previous behavior by doing setting the disable_geoip argument in your initialization to False:
Python
python
posthog = Posthog('api_key', disable_geoip=False)
The list of properties that this overrides:
$geoip_city_name
$geoip_country_name
$geoip_country_code
$geoip_continent_name
$geoip_continent_code
$geoip_postal_code
$geoip_time_zone
You can also explicitly chose to enable or disable GeoIP for a single capture request like so:
If you’re not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what’s happening.
You can enable debug mode by setting the debug option to True in the PostHog object. This will enable verbose logs about the inner workings of the SDK.
You can disable requests during tests by setting the disabled option to True in the PostHog object. This means no events will be captured or no requests will be sent to PostHog.
The SDK uses HTTP connection pooling internally for better performance. These settings typically need not be changed, but in some environments, such as when running behind NAT gateways, pooled connections may be terminated non-gracefully, causing request failures.
You can configure connection behavior in several ways. The following settings should be called during initialization, before any API requests are made.
TCP keepalive probes help prevent idle connections from being dropped by network infrastructure. This is the recommended approach for most cases where idle connections are terminated.
Python
python
import posthogposthog.enable_keep_alive()
This enables TCP keepalive with sensible defaults (60 second idle time, 60 second probe interval, 3 probes before timeout).
If you need each request to use a fresh connection, you can disable connection reuse entirely. This will incur additional overhead per request but may be desirable in some circumstances.
You can use the Python or Node SDK to run historical migrations (opens in a new tab) of data into PostHog. To do so, set the historical_migration option to true when initializing the client.
By default, the synchronous Posthog client buffers events before sending them to the capture endpoint. This can lead to lost events if the platform terminates the Python process before the buffer is fully flushed. To avoid this, you can either:
Call posthog.shutdown() before the process ends. This blocking call attempts to deliver queued events and cleans up the client.
Enable sync_mode when initializing the client so each posthog.capture() call attempts delivery before it returns.
Keep one AsyncPosthog client for the lifetime of your application. Use buffered capture() by default, or await capture_immediate() when one invocation must wait for an event’s delivery attempt. Call await posthog.shutdown() once during application cleanup. Don’t shut down the client after each request.
As our open source project PostHog (opens in a new tab) shares the same module name, we created a special posthoganalytics package, mostly for internal use to avoid module collision. It is the exact same.
These docs cover version 7.x of the PostHog Python SDK, which requires Python 3.10 or higher. Python 3.9 is no longer supported on 7.x.x and higher — pin to the 6.x line with pip install 'posthog<7', where 6.9.3 is the final release.
Everything on this page works the same way on 6.9.3. Event capture, the context API (new_context, identify_context, set_context_session), and PosthogContextMiddleware are identical on 6.9.3 and 7.0.0 — 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. That includes the middleware identifying the request context from the X-POSTHOG-DISTINCT-ID header and falling back to the authenticated user, which behaves the same across both lines.
Later 7.x releases add what the 6.x line does not receive, such as the Celery integration, tracing header sanitization, and set_context_device_id. They also changed the middleware’s own captured properties: 7.x sends the request IP as $ip, where 6.9.3 sends it as $ip_address, and 7.x additionally captures $request_path, $raw_user_agent, and the authenticated user’s email.