Skip to content

Improve Python SDK typing for strict pyright/mypy projects #559

@alberttwong

Description

@alberttwong

Summary

While integrating the PostHog Python SDK into a strict Python client, we hit several quality-gate failures around strict typing and optional SDK initialization. We were able to work around them locally, but the experience was rough enough that I wanted to report it upstream.

The main friction points were:

  • pyright reported partially unknown types when using the PostHog client object, especially around shutdown.
  • mypy could not find library stubs for posthog, which forced us to avoid a normal static import.
  • Optional initialization paths were easy to make type-checker-hostile because the SDK client type is not available in a typed way.

Environment

  • Package: posthog
  • Language: Python
  • Type checkers:
    • pyright
    • mypy
  • Project using it: Python client
  • Python observed in test run: 3.14.3

Errors observed

Pyright

analytics.py: Type of "shutdown" is partially unknown

This happened when registering shutdown with atexit from a lazily initialized PostHog client.

Mypy

analytics.py: Unused "type: ignore" comment
Cannot find implementation or library stub for module named "posthog"

The direct import:

from posthog import Posthog

caused mypy to complain about missing stubs. Adding a type: ignore was not acceptable in our repository's quality policy, and in one run it also became an unused ignore.

Downstream optional-client fallout

After the SDK wrapper passed lint/type checks, our test suite exposed a separate crash caused by analytics code assuming a fully initialized serving client:

AttributeError: 'ServingClient' object has no attribute '_base_url'

That one was our bug, but it was triggered in the analytics call path and made the integration more fragile than expected.

Workaround used

We ended up avoiding the static import and defining a local protocol for the subset of the SDK we use:

from importlib import import_module
from typing import Any, Protocol, cast


class _PostHogClient(Protocol):
    def capture(self, *, distinct_id: str, event: str, properties: dict[str, Any]) -> None: ...

    def capture_exception(self, exc: BaseException, distinct_id: str) -> None: ...

    def shutdown(self) -> None: ...


posthog_module = import_module("posthog")
posthog_class = posthog_module.Posthog
client = cast(_PostHogClient, posthog_class(project_token, **kwargs))

This works, but it is more ceremony than we expected for a small optional analytics integration.

Expected behavior

It would be helpful if the Python SDK shipped enough typing metadata for strict projects to use the normal import path without local protocols/casts:

from posthog import Posthog

client = Posthog(project_token, enable_exception_autocapture=True)
client.capture(...)
client.capture_exception(...)
client.shutdown()

Ideally this should pass both pyright and mypy without downstream projects needing custom stubs, local protocols, or ignore comments.

Why this matters

Strict Python projects often prohibit type: ignore/noqa style suppressions in CI. Without typed exports or bundled stubs, integrating PostHog requires either loosening quality policy or hiding the SDK behind local dynamic-import wrappers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions