Skip to content
← All Python articles

Python explainer

Build Dataclasses at Runtime with make_dataclass

About 4 min to read. Allow extra time to work through examples.

Turn a CSV header into a record class with make_dataclass, build the class once, and check the rows yourself, because mypy cannot see their fields.

This article uses Python. You can make it your practice mode to browse related content across the site.

An admin uploads a CSV export, and you learn its columns only when you read the header. You still want each row to be a record with named attributes, a readable repr and ==. dataclasses.make_dataclass builds a dataclass from a list of fields while the program runs:

from dataclasses import fields, make_dataclass

header = ["id", "name", "email"]  # read from the file
Row = make_dataclass("Row", [(name, str) for name in header], frozen=True)

row = Row(id="1", name="Ada", email="ada@example.com")
print(row)  # Row(id='1', name='Ada', email='ada@example.com')
print([f.name for f in fields(Row)])  # ['id', 'name', 'email']

The result is an ordinary dataclass. Code that walks fields() works on it the same way as on a class you wrote by hand.

Describe each field as a name, a pair or a triple

Each entry in the list is one field:

  • A bare name, such as "id". Its annotation means Any.
  • A (name, type) pair, such as ("id", int).
  • A (name, type, field(...)) triple, for a default or another field option.

The keyword arguments are the options that @dataclass takes, such as frozen=True and kw_only=True:

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,
)

first = Row(id=1, name="Ada")
second = Row(id=2, name="Lin")
first.tags.append("reviewed")
print(first.tags, second.tags)  # ['reviewed'] []

As with any dataclass, Python does not check the values against the types, so Row(id="one", name=2) builds a row.

mypy cannot see the fields

mypy reads your source code, and the fields of Row exist only once the program runs. So mypy types Row as a plain type and every row as Any. A typo such as row.emial passes mypy --strict, then raises AttributeError when the program runs.

Keep generated classes at the boundary of the program, where the data comes in. Check each value yourself before the rest of the code relies on it. If later code needs fixed fields, convert each row into a class declared in your source, so that mypy can check the code that uses it.

Common mistakes

Building the class again for each file or request. Every call makes a new class, even with the same name and fields. The generated __eq__ compares only objects of the same class, so two rows with the same values from different calls are not equal. Build the class once per schema and reuse it.

Trusting the header. A field name must be a valid identifier, must not be a keyword, and must not repeat. make_dataclass raises TypeError for "first name", for "class" and for a repeated name. Check the header first and report an error that names the bad column. If the schema also names types, look them up in a dict of the types you allow, such as {"int": int, "str": str}. Never pass schema text to eval.

Pickling rows of a class made inside a function. pickle saves a class by its module and name, and looks the class up again when it loads the data. A class built inside a function has no module-level name, so pickle.dumps raises PicklingError. Assign the class to a module-level variable with the same name as the class, or send plain dicts instead.

Check your understanding You call make_dataclass("Row", columns) once per request with the same columns. Two rows from different requests hold the same values but compare unequal. Why? Show answer

Each call creates a new class. The generated __eq__ returns NotImplemented when the other object has a different class. Python then falls back to comparing identity, and two separate objects are never identical. Build the class once, when you first load the schema, and reuse it.

Going further

  • namespace={"name": function} adds methods to the class, and bases=(Base,) gives it base classes.
  • Python 3.14 added a decorator= argument, a function to call in place of dataclasses.dataclass. It receives the class and the same keyword arguments.
  • If you know the schema before the program runs, generate Python source from it instead. Then mypy sees the fields.

The make_dataclass reference lists every option.

step 26 of 28

Modelling Data with Dataclasses

Up next Resolve Dataclass Field Types in Python 3.14