Writing Dataclasses
House rules for Python dataclasses in PostHog: when to reach for one instead of a tuple or `dict[…
by posthog · MIT
The skill
House rules for Python dataclasses in PostHog: when to reach for one instead of a tuple or `dict[str, Any]`, which decorator to use (`@frozen` from `posthog.dataclasses`), how to name, construct, consume and evolve them, how to keep secrets out of `repr`, and when a function should accept a dataclass instead of its unpacked fields. Use when adding or changing a dataclass, returning or passing several values from a function, converting a tuple or dict payload, deciding `frozen=`/`slots=`/`kw_only=`, or passing a facade contract DTO through internal layers. Not for pydantic models used as HogQL/query schema, DRF serializers, or Django models.
Writing dataclasses
The point of every rule here is the same: values that share a type get swapped silently, and a positional tuple or a dict[str, Any] lets that happen. A named, frozen dataclass makes the swap a typecheck failure instead of a runtime bug. Everything below follows from that; if a rule doesn't serve it in your case, say so in the PR and skip it.
When a dataclass, when not
- Return or pass a dataclass instead of a tuple when two or more elements share a type (
(start, end),(width, height),(rows, columns)), or when the tuple has roughly 3+ elements and positional access hurts readability. A small tuple of clearly different types ((user, count)) is fine as-is. - Prefer a dataclass over
dict[str, Any]when a fixed set of values crosses a function boundary. A dict key typo fails at runtime; a dataclass field typo fails typecheck. Dicts stay for genuinely dynamic key sets. NamedTupleis not the answer for the swap problem: it still unpacks positionally.
Which decorator
Use @frozen from posthog.dataclasses for internal value and result objects. It is @dataclass with frozen=True, kw_only=True, slots=True as defaults, and every flag is overridable:
from posthog.dataclasses import frozen
@frozen
class BillingPeriod:
start: datetime
end: datetime
@frozen(slots=False) # class uses functools.cached_property
class ParsedQuery: ...
@frozen(frozen=False) # genuinely mutated after construction (a builder, an accumulator)
class RunAccumulator: ...
kw_only=Trueis what actually prevents swaps at construction:BillingPeriod(start=a, end=b), neverBillingPeriod(a, b). Don't override it without a reason.slots=Trueblocksfunctools.cached_propertyand ad-hoc attributes; override withslots=Falserather than dropping@frozen.- A bare
@dataclasswith no explicitfrozen=fails the ratchet inposthog/test/repo_invariants/test_dataclass_defaults.pyand is flagged by the advisoryprefer-frozen-dataclassessemgrep rule.@dataclass(frozen=False)passes; the ratchet asks for a stated choice, not immutability. If you only moved an existing bare@dataclass, regenerate the baseline withpython posthog/test/repo_invariants/test_dataclass_defaults.pyinstead of decorating it. - Don't add
frozen=Falseto a bare@dataclassyou didn't otherwise touch. The ratchet counts per file against a baseline; an unchanged count passes, and the edit is churn in someone else's file. - Facade contracts (
products/<name>/backend/facade/contracts.py) are frozen dataclasses too, usuallypydantic.dataclasses.dataclass(frozen=True)so they validate on construction. See products/architecture.md.
Naming
Name the class after the domain concept, not the plumbing: ClickHouseCredentials, BillingPeriod, SnapshotManifestItem. *Result only when the function's outcome genuinely is the concept; never *Info, *Data, *Tuple, *Response for an internal object. Underscore-prefix classes private to one module.
Constructing and enforcing invariants
- Enforce invariants in
__post_init__(start <= end, exactly one auth method set, value in range) so an invalid instance fails at construction rather than deep in later logic. On a frozen class useobject.__setattr__only if you must normalize; prefer raising. - Type a field holding a closed set of strings as
Literal[...]or an enum, not barestr. Check call sites: narrowing an existing field can surface mypy errors where callers pass a plainstr; fix the callers, don't widen the field back. - A frozen dataclass hashes, so use a small keyed class as a dict or set key instead of a tuple when the key has two or more same-typed parts.
Consuming and evolving
- Read fields with dot notation (
result.start). Nevera, b = result.a, result.binto positional locals; that reintroduces the swap. - Evolve a frozen instance with
dataclasses.replace(instance, field=value), not by copying fields by hand. - Dispatch on variants with
match/case(case BinaryOp(left=left, right=right):) when there are several; a single-type check stays a plainisinstanceguard.
Secrets
Mark secret fields with field(repr=False) so they cannot leak through repr() into tracebacks and logs, and never asdict() such a dataclass into log output, which reintroduces what repr=False hides.
Passing a dataclass through layers
When a function's parameters mirror the fields of a dataclass the caller already holds, accept the dataclass instead of the unpacked fields. That stops same-typed positionals being threaded through several layers, and it is Fowler's Preserve Whole Object.
Three carve-outs, in priority order:
- Invariants win over mirroring. Never pass a wider type into a callee that needs a narrower one. If the dataclass has
url: str | Noneand the helper needs astr, keep thestrparameter (or narrow the type at the boundary); do not accept the dataclass and add a runtimeValueError. That trades a typecheck for a guard clause, which is the opposite of the point. - Wire signatures keep their shape. Temporal
@activity.defn/@workflow.runand celery task boundaries take what they take; the helper behind them accepts the dataclass. - Facade contracts that are also request bodies are wire signatures too. A contract in
facade/contracts.pybacked byDataclassSerializerand@validated_requestis the HTTP body, the OpenAPI schema, and the shape shipped clients send. A product'slogicmay accept its own contract while the fields are one-to-one with what logic needs; do not pre-emptively build a parallel internal DTO. Split into an internal parameter object at the first real divergence: the wire carries dead or deprecated fields logic ignores, logic needs values the wire must not accept, or logic needs an invariant the wire can't promise. The split lives infacade/api.py, the only in-process caller.
What enforces this
posthog/test/repo_invariants/test_dataclass_defaults.py(blocking ratchet): new bare@dataclasswithoutfrozen=..semgrep/rules/devex/prefer-frozen-dataclasses.yamlandtuple-return-prefer-dataclass.yaml(advisory).- Everything else is review.
Use this skill
Connect your agent to the Skill Store MCP server and ask for it by ref: posthog/writing-dataclasses-39206607017.
Details
- Publisher
- posthog
- Category
- Software Development › Languages & frameworks
- License
- MIT
- Updated
- 2026-10-19
References
More from posthog
- Adding Warehouse Person Properties Sync columns from a synced data warehouse table onto PostHog person or group properties, so wareh…
- Analyzing Expensive Users Analyze the most expensive users in AI observability and explain why they cost so much.
- Analyzing Experiment Session Replays Analyze session replay patterns across experiment variants to understand user behavior differences.
- Analyzing Offline Evaluations Inspect and compare offline AI evaluation experiments, diagnose case-level regressions, and follo…