1. Home
  2. Blog
  3. Software Development

Software Development

Python Dataclasses Explained: Less Boilerplate, Safer Defaults and Real Examples

Dataclasses write the dull parts of a class for you: the constructor, a readable repr and equality checks. Learn the options that matter, from default_factory to frozen and slots, with every example run.

9 min read
Python dataclasses explained: less boilerplate, safer defaults and real examples

Key takeaways

  • The @dataclass decorator generates __init__, __repr__ and __eq__ from type-annotated fields.
  • Use field(default_factory=list) for lists and dicts. A plain [] default is rejected.
  • frozen=True makes instances read-only and hashable. order=True makes them sortable.
  • Type hints are not enforced at runtime, so validate important values in __post_init__.

Writing a class to hold a product’s name, price and quantity used to mean a constructor that copies each argument, a __repr__ so it prints usefully, and an __eq__ so two identical products compare equal. That is a lot of code that says nothing interesting. Dataclasses, added in Python 3.7, generate all of it from a short list of fields.

We ran every example below with Python 3.13, and the outputs shown are from that run. If classes are new to you, start with our Python classes and objects guide.

What is a dataclass?

A dataclass is an ordinary Python class decorated with @dataclass from the standard library dataclasses module. The decorator reads the class’s annotated attributes and writes the common special methods for you. You can still add your own methods and properties, so a dataclass is not limited to holding data. It simply starts from a sensible base.

Your first dataclass

from dataclasses import dataclass, asdict, astuple

@dataclass
class Product:
    name: str
    price: float
    qty: int = 0

p = Product("Pen", 10.0, 3)
print(p)                              # Product(name='Pen', price=10.0, qty=3)
print(p == Product("Pen", 10.0, 3))   # True
print(asdict(p), astuple(p))
# {'name': 'Pen', 'price': 10.0, 'qty': 3} ('Pen', 10.0, 3)

Three lines of fields gave us a constructor with a default for qty, a readable printout and value-based equality. Without the decorator, p == Product("Pen", 10.0, 3) would be False, because normal classes compare by identity. asdict() is handy for turning objects into JSON-ready dictionaries, and it converts nested dataclasses too.

Defaults and default_factory

Mutable defaults are a classic Python trap: a single list shared by every instance. Dataclasses protect you from the most common form of it. When we tried a list default, the class was rejected when it was defined:

@dataclass
class Bad:
    tags: list = []
# ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory

The fix is default_factory, which calls a function to create a fresh value for each instance:

from dataclasses import field

@dataclass
class Cart:
    owner: str
    items: list = field(default_factory=list)

a, b = Cart("Asha"), Cart("Ben")
a.items.append("pen")
print(a.items, b.items)   # ['pen'] []

Each cart got its own list. Fields with defaults must also come after fields without them, just as in a normal function signature. We got TypeError: non-default argument 'b' follows default argument 'a' when we broke that rule.

Frozen dataclasses

from dataclasses import FrozenInstanceError, replace

@dataclass(frozen=True)
class Point:
    x: int
    y: int

pt = Point(1, 2)
pt.x = 5
# FrozenInstanceError: cannot assign to field 'x'

print({pt: "origin-ish"}[Point(1, 2)])   # origin-ish
print(replace(pt, x=9))                   # Point(x=9, y=2)

A frozen dataclass cannot be changed after creation, and it is hashable, so it can be a dictionary key or a set member. To “change” one, replace() returns a new copy with some fields swapped. Immutable objects are easier to reason about, especially when they are passed between functions or threads. Note that frozen is shallow: a list stored in a frozen field can still be modified.

Ordering

@dataclass(order=True)
class Version:
    major: int
    minor: int

print(sorted([Version(1, 10), Version(1, 2), Version(0, 9)]))
# [Version(major=0, minor=9), Version(major=1, minor=2), Version(major=1, minor=10)]

order=True compares instances field by field, in the order the fields are declared, like comparing tuples. Version 1.10 correctly sorted after 1.2, which plain string sorting gets wrong, a topic we cover in semantic versioning explained. To leave a field out of comparisons, use field(compare=False).

Validation with __post_init__

The generated __init__ calls __post_init__ at the end, if you define one. It is the place for validation and calculated fields:

@dataclass
class Order:
    subtotal: float
    tax_rate: float = 0.18
    total: float = field(init=False)

    def __post_init__(self):
        if self.subtotal < 0:
            raise ValueError("subtotal cannot be negative")
        self.total = round(self.subtotal * (1 + self.tax_rate), 2)

print(Order(100))   # Order(subtotal=100, tax_rate=0.18, total=118.0)
Order(-1)           # ValueError: subtotal cannot be negative

init=False keeps total out of the constructor, since it is always calculated. Validating in one place means a bad Order can never exist, which saves defensive checks everywhere else.

Type hints are not checks

This catches many beginners. The annotations tell the decorator which attributes are fields, but Python does not enforce them:

print(Product("Book", "free"))
# Product(name='Book', price='free', qty=0)

A string slipped into a field annotated as float without complaint. A type checker such as mypy or Pyright would flag this in your editor or CI, and __post_init__ can check it at runtime. When you need full validation and conversion of outside data, such as JSON from an API, a library like Pydantic is a better fit.

slots and kw_only

Python 3.10 added two useful options.

@dataclass(slots=True)
class Slim:
    a: int
    b: int

Slim(1, 2).c = 3
# AttributeError: 'Slim' object has no attribute 'c' and no __dict__ for setting new attributes

slots=True removes the per-instance dictionary. That blocks attribute typos like the one above and saves memory. In our measurement a slotted instance took 48 bytes, while a normal instance plus its attribute dictionary took 336 bytes. That difference adds up when you create millions of objects.

@dataclass(kw_only=True)
class Config:
    host: str
    port: int = 8000

print(Config(host="localhost"))   # Config(host='localhost', port=8000)
Config("localhost")
# TypeError: Config.__init__() takes 1 positional argument but 2 were given

kw_only=True forces callers to name every argument. For configuration objects with many fields, this prevents values being passed in the wrong order, the same benefit described in our *args and **kwargs guide.

field() options

Option Effect
default A fixed default value
default_factory A function that creates a fresh default
init=False Leave the field out of __init__
repr=False Hide it when printing, useful for secrets
compare=False Ignore it in equality and ordering
kw_only=True Make just this field keyword-only
@dataclass
class Secret:
    user: str
    password: str = field(repr=False)

print(Secret("asha", "hunter2"))   # Secret(user='asha')

Hiding sensitive fields from repr keeps them out of logs and error messages, which matters for the same reasons covered in our environment variables guide.

Dataclass vs alternatives

Option Mutable Validation Best for
Dataclass Yes, or frozen Manual Internal data objects
NamedTuple No None Small, tuple-like records
TypedDict Yes Type checker only Describing dict-shaped JSON
Pydantic model Yes Automatic Parsing outside data, APIs
attrs Yes, or frozen Validators built in Advanced needs, older Pythons

Common mistakes

  • Forgetting annotations. An attribute without a type hint is a class variable, not a field.
  • Using [] or {} as a default. Use default_factory.
  • Expecting type checking. Add a type checker or validate in __post_init__.
  • Writing your own __init__. It replaces the generated one. Use __post_init__ instead.
  • Assuming frozen means deeply immutable. Nested lists can still change.

Inheritance and JSON round trips

Dataclasses can inherit from each other. Fields from the parent come first, followed by the child’s fields:

@dataclass
class Base:
    id: int
    created_by: str = "system"

@dataclass
class Invoice(Base):
    amount: float = 0.0
    lines: list = field(default_factory=list)

inv = Invoice(7, amount=99.5, lines=["pen"])
print(inv)
# Invoice(id=7, created_by='system', amount=99.5, lines=['pen'])

Because parent fields come first, a child field without a default cannot follow a parent field that has one. Our attempt failed with non-default argument 'amount' follows default argument 'created_by'. Marking the child with @dataclass(kw_only=True) solved it, since keyword-only fields have no ordering rule.

Converting to and from JSON is a two-liner for flat data:

s = json.dumps(asdict(inv))
# {"id": 7, "created_by": "system", "amount": 99.5, "lines": ["pen"]}
back = Invoice(**json.loads(s))
print(back == inv)   # True

This works only while the JSON matches the fields exactly. An extra key raised Invoice.__init__() got an unexpected keyword argument 'unknown', and nested dataclasses, dates or decimals need converting by hand. That is the point where a library such as Pydantic, or a dedicated converter, starts to save real time.

Final thoughts

Dataclasses remove boilerplate without hiding how Python classes work. Start with a plain @dataclass, reach for default_factory for collections, frozen=True for values that should never change, and __post_init__ for validation. When data arrives from outside your program, consider a validating library instead.

Frequently asked questions

What is a dataclass in Python?

It is a class decorated with @dataclass, which generates methods such as __init__, __repr__ and __eq__ from the class’s type-annotated fields. It was added in Python 3.7.

How do I use a list as a default in a dataclass?

Use field(default_factory=list). A plain [] default raises a ValueError, because it would be shared by every instance.

What does frozen=True do?

It makes instances read-only after creation and hashable, so they can be used as dictionary keys. Use dataclasses.replace() to create a modified copy.

Do dataclasses check types?

No. Type hints are not enforced at runtime. Use a type checker such as mypy, validate in __post_init__, or use a library such as Pydantic.

Dataclass or Pydantic?

Use a dataclass for internal objects you create yourself. Use Pydantic when parsing and validating outside data such as JSON requests or config files.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →