Skip to content
← All articles

make_dataclass and building record types at runtime

Schema-driven pipelines reach for dict[str, Any] because "we don't know the columns until runtime". make_dataclass gives you real objects instead — and confining the dynamism to one factory is what stops Any spreading through everything downstream.

“We don’t know the columns until runtime” is the sentence that puts dict[str, Any] into a codebase, and it is usually true. A CSV loader, a Parquet reader, a database introspector, a plugin that declares its own config schema — none of them can have their record type written out in advance.

What follows from that is not “so we use dicts”. dataclasses.make_dataclass builds a real class at runtime, from data, with a real __init__, a real __repr__, real __eq__, working fields(), replace() and asdict(). The dynamism is genuine; the question is how much of your codebase it is allowed to infect.

The call

from dataclasses import field, make_dataclass

Row = make_dataclass(
    "Row",
    [
        ("id", int),
        ("name", str),
        ("tags", list[str], field(default_factory=list)),
    ],
    frozen=True,
    kw_only=True,
    module=__name__,
)

The field list takes three shapes per entry: a bare "name", a ("name", type) pair, or a ("name", type, field(...)) triple. Everything after the field list is the same keyword set @dataclass takes — frozen, slots, kw_only, eq, order — plus three that only exist here: bases, namespace and module.

A bare field name silently gets typing.Any. make_dataclass("Row", ["id", "name"]) produces a perfectly valid class whose every field is Any. It runs, it reprs, it compares — and it has erased the one thing you were building a class to get. Always pass the pair or the triple.

💡module=__name__ looks like cosmetic metadata. What breaks without it? click to reveal

Pickling — and therefore multiprocessing, most caching layers, and any ProcessPoolExecutor in the pipeline.

Pickle does not serialise a class; it serialises a reference to one, as module.qualname, and unpickling does getattr(import_module(module), qualname). make_dataclass sets __module__ by inspecting the caller’s frame, so a class created inside a helper function in myapp.schema gets __module__ = "myapp.schema" — and then getattr(myapp.schema, "Row") fails, because Row was never bound at module level. You get PicklingError: Can't pickle <class 'Row'>: attribute lookup Row on myapp.schema failed.

module= lets you name the module the class will actually be reachable from, and the usual pattern is to complete the contract by binding it there:

Row = make_dataclass("Row", spec, module=__name__)
globals()["Row"] = Row

The failure is nasty because it is deferred and conditional. Everything works in tests and in the single-process path; the first ProcessPoolExecutor.map in production is where you find out.

decorator= (⚠ 3.14)

3.14 added a decorator= parameter: instead of applying @dataclass, make_dataclass applies whatever you give it. That means a runtime-built class can be an attrs class, a pydantic.dataclasses.dataclass, or your own house @entity:

Row = make_dataclass("Row", spec, decorator=attrs.define)
Row = make_dataclass("Row", spec, decorator=entity)

Before 3.14 the workaround was to build with make_dataclass and then re-decorate — which works for attrs but is subtly wrong for anything that reads the class body at decoration time, and outright wrong for slots=True, which returns a different class object than the one you registered.

3.14 also changed annotation handling here (gh-134370) so that annotations set by make_dataclass behave consistently with PEP 649 lazy evaluation. If you have code that reaches into cls.__annotations__ on a generated class, test it on 3.14 specifically.

💡The namespace= parameter lets you inject methods into the generated class. Why is that usually a worse idea than putting the methods on a base class passed via bases=? click to reveal

Because a method in namespace= is a function object you built at runtime, and nothing can see it.

Methods on a base class are ordinary source: mypy checks them, your IDE finds them, grep finds them, coverage measures them, a stack trace names a file and a line you can open. Methods stuffed into namespace= are typically lambdas or locally-defined closures — invisible to the checker, awkward in tracebacks, and untestable except through the factory.

bases=(RowMixin,) gets you the same methods with none of that. The mixin is a normal class, in a normal file, with normal types; only the fields are dynamic. That is the shape you want: dynamic data, static behaviour.

Keep namespace= for genuinely per-class data — a __doc__, a schema-version constant, a registry key — where the value differs per generated class and there is nothing to type-check.

The honest limit: the checker sees nothing

This is the part that matters, and it is not a criticism of make_dataclass. It is the boundary of gradual typing.

make_dataclass is stubbed as returning type. That is the truthful annotation — the return type genuinely depends on a runtime value — and it means mypy knows nothing about the resulting class. Row(id=1, name="x") is unchecked. row.nmae is unchecked. Every attribute read off an instance is Any, and Any spreads: assign it, and the destination is Any; compute with it, and the result is Any.

Nothing you can write fixes that. The class does not exist when the checker runs.

What you can do is decide how far it spreads.

The containment pattern

One factory, returning type[DataclassInstance], and everything downstream operates through fields() and a Protocol — never through attribute access.

from typing import TYPE_CHECKING, Protocol

if TYPE_CHECKING:
    from _typeshed import DataclassInstance


def build_row_type(spec: Sequence[tuple[str, type]]) -> "type[DataclassInstance]":
    return make_dataclass("Row", list(spec), frozen=True, kw_only=True, module=__name__)


def to_csv_line(row: "DataclassInstance") -> str:
    return ",".join(str(getattr(row, f.name)) for f in fields(row))

to_csv_line is fully checked. It does not know the field names — it cannot know them — but it knows it has a dataclass instance, and fields() is a typed API. The getattr is the one untyped operation, it is one line, and the str() around it closes the hole immediately.

Compare that with the dict[str, Any] version, where row["name"] is Any and so is every expression built from it, in every function, forever. Same amount of runtime knowledge; wildly different blast radius.

💡Your pipeline reads a Parquet file whose schema is known only at runtime, then applies a fixed set of transformations, then writes JSON. Where exactly does the dynamism have to live, and what is checked on either side of it? click to reveal

Three regions, and only the middle one is dynamic.

Region 1 — the schema reader. Fully typed. It produces a Sequence[tuple[str, type]] (or a small FieldSpec dataclass per column). This is data describing a schema, and data is checkable.

Region 2 — the factory. One function, a handful of lines, returning type[DataclassInstance]. This is the only place make_dataclass is called and the only place the checker is blind. Test it directly: build a type from a known spec and assert on fields() — names, types, defaults, __name__, frozen.

Region 3 — everything downstream. Fully typed, because it never mentions a field name. Transformations take DataclassInstance and use fields(), getattr, replace() and asdict(). Serialization walks fields(). Filtering takes a Callable[[DataclassInstance], bool].

The failure mode to avoid is letting a field name leak into region 3 — one row.customer_id and you have both lost the checking and silently coupled a “generic” pipeline to one specific schema.

If some stage genuinely needs a named field, that stage is not generic. Give it a real, hand-written dataclass and a conversion step at its boundary. The conversion is checked, the stage is checked, and the dynamism stops at region 2 where it belongs.

When not to reach for it

make_dataclass is for schemas that are unknown until runtime. It is not for schemas that are merely tedious to write out.

If you know the fields at authoring time, write the class. Generating a class from a constant list buys you nothing and costs you everything the checker would have given you — __init__ signatures, attribute names, field types, refactoring, autocomplete, and the ability to jump to the definition. “It saves typing” is not a reason; the fields have to be spelled out somewhere either way, and one of the two places is checked.

The related anti-pattern is code generation into .py files as a substitute for make_dataclass. That is a legitimate third option and it has the opposite trade: fully checkable output, at the cost of a build step and generated code in your repository. Choose it when the schema changes on a release cadence rather than a request cadence.