Don’t use dataclasses in your library’s public API

Python’s dataclasses module is lovely, and I reach for it whenever I’m modeling a new problem domain. It eliminates a whole lot of boilerplate, provides a nice __repr__ out of the box, plays nicely with type checkers, and even lets you provide read-only semantics by setting frozen=True. So why shouldn’t you use dataclasses in your library’s public API? Dataclasses commit you to a larger API surface than you might realise: migrating away from them is a breaking change for your users, and even something as seemingly harmless as changing the order of keyword-only arguments flows through to your public API. Let’s first cover a case where you might find yourself moving away from the happy dataclasses path. Then we’ll dive into the unexpectedly large API surface.


Dataclasses provide a quick and easy way to say “the argument foo is a list[int], and is available publicly as the foo attribute”. The further away your needs are from these simple semantics, the more knots you tie yourself in shoehorning your needs into the dataclass model.

For example, you might want to accept an argument but only store it privately. There are ways: you can declare arguments as InitVars so they’re arguments but not attributes, and process them in __post_init__. Even with frozen dataclasses, you can still set any necessary attributes using object.__setattr__. Of course, the type checker won’t know about any attributes set this way, so you’d need to declare attributes as ClassVars, although you’d be lying a bit since they’re really instance variables …

At that point, a custom __init__ starts to look pretty good in comparison, which is luckily also possible with dataclasses. And in some cases, like if you want the foo argument to accept any Iterable[int] while the foo attribute exposes a concrete Sequence[int], you’d have to jump straight to a custom __init__.


But still, what’s the problem? After all, dataclasses let you prototype quickly, and if your needs grow more complex they let you replace their default functionality with your own custom implementations. Here’s the gotcha: how confident are you that all the changes covered so far are backwards compatible? We have to think about all the API surfaces we’ve provided our users, not just the ones we care about.

You might be surprised to learn that changing the order that attributes are declared on your dataclass is always a breaking change. Makes sense if they’re defining the order of positional arguments, but what if you’re using kw_only=True or a custom __init__? Turns out it’s still a breaking change thanks to dataclasses.astuple, which returns a tuple of your dataclass fields in declaration order. Consider (bad) user code like name, url = astuple(Repo(name=’foo’, url=’...’), which will break if the order of the fields changes. Even adding a new optional field is a breaking change for dataclasses!

If we leave aside the dark and unhelpfully typed corners that are astuple and (to a lesser extent) asdict, changes must still be made with extra care. Converting a dataclass into a functionally equivalent vanilla class is tricky. In fact, it’s impossible if you’re not willing to lie to dataclasses.is_dataclass and continue to expose __dataclass_fields__ populated with dataclasses.field objects, so you won’t be able to replace a dataclass with a regular Python class without some API breakage.

And it’s not just dropping dataclasses that’s breaking – whenever you replace dataclasses functionality with your own, you’ll need to carefully think about what dataclasses was doing under the hood for you. For example, when we implemented our custom __init__, did we remember to call __post_init__ (conditionally, if we didn’t define one ourselves)? If not, any user classes that inherit from this dataclass and implement their own __post_init__ just broke.


Hopefully this is enough to convince you that dataclasses are a backwards compatibility nightmare for libraries. Of course, third party equivalents like Pydantic or attrs have the same pitfalls, so what should library authors do instead? First, you should continue to freely use class builders for prototyping and even library internals. But when it comes time to expose a class as part of your library’s public API, carefully consider what functionality you actually want to commit to, and write a regular Python class that provides exactly that. For example:

class D:
    def __init__(self, foo: Iterable[int]):
        self._foo = tuple(foo)
    @property
    def foo(self) -> Sequence[str]:
        return self._foo
    def __repr__(self) -> str:
        return f"{type(self).__name__}(foo={self._foo})"

It’s a little more verbose to write, but very easy to read, and infinitely easier to maintain compatibility with. With keystrokes as cheap as they are in the age of LLMs, that seems like a worthwhile tradeoff to me.

5 Likes

Soo this means you’re regretting about 80% of Scenario? the whole State API is dataclasses.

I think you make a good point that adopting a dataclass as public API commits you to more than you bargained for. On the other hand it’s such a nice, easy way to express complex declarative configs that I already miss it (not that I’ve been writing much code myself lately..).

I feel that the argument you’re making boils down to ‘if you want stability, write boring, boilerplaty code’. The pitfalls of using a dataclass in public API sound a lot like the pitfalls of using (multiple) inheritance or writing highly ‘magic’ code. Fair enough.

I wouldn’t say that, Scenario has been great! But see for example #2331 where we added custom init methods to decouple the argument typing from the attribute typing (e.g. Iterable argument becomes frozenset attribute). The end result while keeping frozen dataclass behaviour is (by necessity) not exactly beautiful.

I love dataclasses too, and I still reach for them whenever I’m writing code by hand. And of course for application code, scripting, or library internals there’s no reason not to stick with them. But with code generation as cheap, fast, and good as it is today, I don’t think I’d make them part of a public library API again.