Writing dataclasses
SkillDev toolsHouse 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.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Writing dataclasses skill
What this skill tells your AI
The instructions your AI receives, as published by posthog/posthog in .agents/skills/writing-dataclasses/SKILL.md and read by ahel’s review.
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.
Signals
- GitHub stars
- 40k
- Forks
- 3k
- Last commit
- Sep 2026
Others that do the same job
Advanced
- Catalog kind
- skill
- Gateway key
writing-dataclasses- Source
- github.com/posthog/posthog