Most of the Python I write is small. A script that cleans a CSV export, a tool that checks a folder of files, a helper that scores a batch of AI outputs against a rubric. For a long time those scripts passed data around as plain dictionaries. That worked until it did not. A typo in a key name, or a field that was a string in one place and a number in another, and suddenly I was debugging something that should never have been a bug.

Dataclasses fixed most of that for me. They have been in the standard library since Python 3.7, so there is nothing to install, and they turn a loose bag of keys into a small, named, typed record. Here is how I use them and why.

The problem with dictionaries everywhere

A dictionary accepts anything you put in it, and it only complains when you read a key that is not there.

job = {"name": "weekly_report", "retries": "3", "enabled": True} if job["retires"] > 0: print("will retry") 

There are two bugs here. The key is misspelled, so this raises a KeyError, but only when that line finally runs, which might be deep into a long job. And retries is the string "3", not the number 3, so even with the right key the comparison fails. Nothing warned me when the dictionary was created.

A first dataclass

from dataclasses import dataclass @dataclass class Job: name: str retries: int = 3 enabled: bool = True job = Job(name="weekly_report") print(job) # Job(name='weekly_report', retries=3, enabled=True) 

The decorator writes the __init__, a readable __repr__ and equality comparison for you. My editor now autocompletes job.retries, and if I type job.retires the editor flags it before I run anything. Two records with the same values compare equal, which makes tests simple to write.

Type hints are not enforcement, so I add __post_init__

One thing surprised me early on. The annotations are documentation, not conversion. Job(name="x", retries="3") happily stores the string. So I validate and convert in __post_init__, which runs right after the generated __init__:

from dataclasses import dataclass @dataclass class Job: name: str retries: int = 3 enabled: bool = True def __post_init__(self): self.retries = int(self.retries) if self.retries < 0: raise ValueError(f"retries must be zero or more, got {self.retries}") if not self.name.strip(): raise ValueError("name cannot be blank") 

Now bad data fails at the moment it enters the program, with a message that says exactly what is wrong. Years of production and logistics work taught me the same thing about physical parts: the cheapest place to catch a bad part is at receiving, not after it has been built into ten other things.

Loading records from a CSV

This is the pattern I reach for most. Each row becomes a record, and rows that do not fit get collected with their line number instead of crashing the whole run.

import csv from dataclasses import dataclass @dataclass class Row: sku: str qty: int price: float def __post_init__(self): self.qty = int(self.qty) self.price = float(self.price) def load_rows(path): good, bad = [], [] with open(path, newline="") as f: for line_no, raw in enumerate(csv.DictReader(f), start=2): try: good.append(Row(**raw)) except (TypeError, ValueError) as err: bad.append((line_no, err)) return good, bad 

If the file has an extra or missing column, Row(**raw) raises a TypeError. If a quantity says "ten", int() raises a ValueError. Either way the row lands in bad with its line number, and I can print a short report at the end instead of guessing which of thousands of rows broke things.

Frozen dataclasses for settings

Settings should not change halfway through a run. Passing frozen=True makes any assignment raise an error:

from dataclasses import dataclass @dataclass(frozen=True) class Settings: input_dir: str output_dir: str dry_run: bool = True skip_ext: tuple = (".tmp", ".bak") 

If some function tries settings.dry_run = False, Python raises FrozenInstanceError right there. I like that a dry run stays a dry run unless I change it on purpose where the settings are built.

Watch out for mutable defaults

Writing items: list = [] inside a dataclass raises a ValueError when the class is defined. That is Python protecting you from every instance sharing one list. Use field instead:

from dataclasses import dataclass, field @dataclass class Batch: label: str items: list = field(default_factory=list) 

Turning records back into plain data

When I need JSON for a log file or an API call, asdict converts a record, including nested dataclasses, back into dictionaries:

import json from dataclasses import asdict job = Job(name="weekly_report", retries=2) print(json.dumps(asdict(job), indent=2)) 

When I skip them

For a five line throwaway script, a dictionary is fine. For deeply nested input from an outside API where I want strict validation and detailed error messages, a dedicated validation library can be a better fit. Dataclasses sit in the middle, and most of my everyday scripts live in that middle.

What I took away

  • Give every record a name and a shape, and let the editor catch typos for you.
  • Annotations do not convert values, so validate in __post_init__.
  • Collect bad rows with line numbers instead of crashing on the first one.
  • Freeze settings so they cannot drift during a run.
  • Use field(default_factory=list) for list and dict defaults.

Small habits like these are what keep a quick script from turning into a weekend of debugging.

Originally published at tonycantave.com by Tony Sterlin Cantave, a Connecticut software developer.