dataclasses is stdlib, so it moves with the interpreter, and a codebase that supports more than one Python version supports more than one dataclasses. Most of the changes below are additive and harmless. A few are not, and those few produce the worst class of bug there is: works on my machine, fails in CI, with no type error anywhere.
This is the reference table. Read it once now, and come back to it the week before you bump a version floor.
3.10
| change | notes |
|---|---|
slots=True |
Generates __slots__. Returns a new class object — the source of most slots surprises. |
kw_only=True |
Makes every field keyword-only, which dissolves the “non-default argument follows default argument” wall. |
match_args=True (default) |
Generates __match_args__ from the positional fields, for structural pattern matching. |
KW_ONLY sentinel |
_: KW_ONLY in a class body makes everything after it keyword-only. One per class; the name is ignored. |
3.11
| change | notes |
|---|---|
weakref_slot=True |
Adds __weakref__ to the generated __slots__. Requires slots=True. Without it a slotted class cannot be weakly referenced. |
| Mutable-default check switched to unhashability |
The rule is no longer “is it a list/dict/set” but type(default).__hash__ is None. The docs are explicit that “unhashability is used to approximate mutability… a partial solution”. |
Inherited __slots__ names excluded |
A subclass’s generated __slots__ no longer repeats names already slotted by a base. __slots__ has not been the field list since this release — use fields(). |
💡The 3.11 mutable-default rule change made one category of bug possible that was impossible before. What is it, and why did the change happen anyway? click to reveal
Before 3.11 the check was a type test against (list, dict, set). After 3.11 it is type(default).__hash__ is None. So a class that is mutable but defines __hash__ is now accepted as a default and shared across every instance:
class Bag:
def __init__(self) -> None:
self.items: list[str] = []
def __hash__(self) -> int:
return 0
@dataclass
class Leaky:
bag: Bag = Bag() # accepted. one Bag, for the life of the process.
No ValueError, no mypy error, no ruff error — RUF008 matches syntax, and this looks like an ordinary object default.
The change happened anyway because the old rule was narrower in a worse way: it missed bytearray, array.array, collections.deque, Counter, and every mutable user-defined class, while a hashability test catches all of those. Trading a set of common misses for one uncommon miss was the right call. It is still a proxy, and knowing which proxy is in use is how you know where it leaks.
3.12
| change | notes |
|---|---|
dataclass_transform(frozen_default=...) |
PEP 681 gains the parameter, so a house @entity decorator can finally declare that it freezes. |
_ATOMIC_TYPES fast path in asdict/astuple |
Skips copy.deepcopy for None, bool, int, float, str, complex, bytes, range, type, property and functions. Not datetime, UUID, Decimal, Path, Enum, or anything you own. |
3.13
| change | notes |
|---|---|
__eq__ compares fields individually |
Previously a tuple comparison. Faster, and it changes the answer for NaN. |
copy.replace() and __replace__ |
A general protocol; @dataclass now generates __replace__. |
ReadOnly for TypedDict (PEP 705) |
Adjacent, and relevant to any record type you hand out. |
The __eq__ change is the first genuinely behaviour-altering entry in this table. With a NaN field:
-
3.13+:
x == yisFalse, whilex <= yandx >= yare bothTrue. -
3.12: all three were
True.
Same class, same data, different answer to == depending on the interpreter.
💡copy.replace() is the newer, more general API. Why should you keep using dataclasses.replace() for dataclasses anyway?
click to reveal
Because mypy has a plugin for one of them and not the other.
dataclasses.replace is special-cased: the checker validates the keyword names against the field list and the types against the field types, and it does so even through generic parameterisation.
copy.replace is stubbed **changes: Any. There is no plugin. So:
dataclasses.replace(user, age="four") # error: incompatible type "str"; expected "int"
dataclasses.replace(user, nonexistent=1) # error: unexpected keyword argument
copy.replace(user, age="four") # no error
copy.replace(user, nonexistent=1) # no error
On a codebase where replace() sits on every write path — which is what a frozen-dataclass codebase looks like — that is the difference between a typo’d field name being a compile error and being a silent data-integrity bug.
Use copy.replace for the cases only it can serve: non-dataclass types implementing __replace__, and generic code that must work across both. Use dataclasses.replace everywhere else, until the gap closes.
3.14
| change | notes |
|---|---|
| PEP 649 lazy annotations |
Field.type may now be a real type, a str, or a ForwardRef. Anything switching on f.type needs all three branches. |
Zero-arg super() under slots=True fixed |
gh-90562. It raised TypeError on 3.12 and 3.13. |
field(doc=...) |
Per-field documentation. |
make_dataclass(decorator=...) |
Substitute attrs.define, pydantic.dataclasses.dataclass or your own decorator. |
super() in a NamedTuple method now raises |
The mirror image of the slots fix: code that worked on 3.13 raises TypeError on 3.14. |
💡The slots/super() fix is described as "the worst entry in this table". Why is a bug *fix* dangerous?
click to reveal
Because it makes the failure appear when you move backwards or sideways, not forwards.
@dataclass(slots=True)
class Audited(Base):
def save(self) -> None:
super().save() # 3.14: fine. 3.13 and 3.12: TypeError at call time.
Zero-arg super() compiles to a closure over an implicit __class__ cell. @dataclass(slots=True) builds a new class object, and on 3.12/3.13 that cell still pointed at the original — which is no longer in the MRO, so super() raised.
The developer on 3.14 writes it, tests it, ships it. CI runs 3.13, or a customer runs 3.12, or the container base image is older than the laptop — and it raises. And it raises at call time, not import time, so it only shows up on the code path that calls the method.
Nothing warns you. mypy does not model it. Ruff does not flag it. The only defences are (a) run your test suite on your lowest supported version in CI, not just your highest, and (b) if you support < 3.14, spell it super(Audited, self).save() in any slotted dataclass.
And note the symmetry: 3.14 added the same failure for NamedTuple methods. So on 3.14 you must not use zero-arg super() in a NamedTuple, and on < 3.14 you must not use it in a slotted dataclass. The safe habit across all versions is to avoid zero-arg super() in generated record types entirely.
3.15 (final 2026-10-01)
| change | notes |
|---|---|
MISSING and KW_ONLY become PEP 661 sentinels |
repr(MISSING) changes, and isinstance(x, dataclasses._MISSING_TYPE) stops working. Use x is MISSING. |
The private _MISSING_TYPE was never public API, but it appears in a lot of library code because it was the obvious way to write the check before is was obviously enough. Grep for it now; the fix is mechanical and the failure is silent (an isinstance against a type that no longer matches simply returns False, so “has a default” quietly becomes “has no default”).
The three that actually bite
Everything above is worth knowing. These three are worth a CI job:
-
Zero-arg
super()in a slotted dataclass — raises on < 3.14, fine on 3.14+. No static warning. Test on your lowest supported version. -
Zero-arg
super()(or__class__) in aNamedTuplemethod — fine on < 3.14, raises on 3.14+. Same shape, opposite direction. -
Field.typehandling — code written before 3.14 that assumesf.typeis atype(or that it is always astr) silently takes the wrong branch on the other versions. Any schema generator, ORM mapper or serializer that walksfields()needs revisiting.
💡You maintain a library supporting 3.11 through 3.15. Which of these features can you use unconditionally, and which need a version guard or a policy? click to reveal
Unconditional (available since 3.10/3.11, unchanged since): slots=True, kw_only=True, KW_ONLY, weakref_slot=True, match_args. These are the ones worth adopting as house style.
Unconditional but with a policy attached: slots=True — plus a rule banning zero-arg super() in any slotted class, because you support < 3.14.
Guarded or avoided: copy.replace/__replace__ (3.13+), field(doc=) (3.14+), make_dataclass(decorator=) (3.14+), dataclass_transform(frozen_default=) (3.12+, so fine here). If you need any of these on 3.11, you need a fallback branch — and for copy.replace specifically the fallback is just dataclasses.replace, which is better typed anyway.
Behaviour differences to write tests for, not guards: the 3.13 __eq__ change (only observable with NaN, so either forbid float fields in ordered dataclasses or test both branches), and Field.type (write the three-branch resolver once and test it on every supported version).
Fix now, before it bites: every isinstance(x, dataclasses._MISSING_TYPE) in the codebase, ahead of 3.15.