One word in the decorator changes three things about the class. The third one is the one people meet by accident.
@dataclass(frozen=True)
class Config:
host: str
port: int = 8000
config = Config('localhost')
config.port = 9000 # raises FrozenInstanceError
{config: 'dev'} # works — frozen dataclasses are hashable
# Changing a field means building a new one
from dataclasses import replace
other = replace(config, port=9000)
# And normalising a field needs the back door, even inside the class
def __post_init__(self):
object.__setattr__(self, 'host', self.host.lower())
- Frozen and hashable arrive together, on purpose
A plain @dataclass generates __eq__, which sets __hash__ to None and makes instances unusable as keys. frozen=True generates a __hash__ from the fields instead. The pairing is not a convenience: a mutable object used as a key would hash to one bucket, then change, and go missing from the dictionary it is sitting in. Read-only is the price of being findable.
- replace() is the frozen way to change something
dataclasses.replace(config, port=9000) builds a new instance from the old one’s fields with your overrides applied — so adding a field later does not mean revisiting every place that copies a config. Writing Config(config.host, 9000, config.debug) by hand is the same thing today and a bug the day a fourth field appears.
- __post_init__ needs object.__setattr__ to normalise
The freeze applies inside the class too, so self.host = self.host.lower() raises even in __post_init__. object.__setattr__ goes around the frozen class’s own __setattr__ and writes the field directly. It looks like a hack because it is one — it is the documented way, and its ugliness is a fair warning that you are doing the one thing the class exists to prevent.
Three annotated fields under @dataclass(frozen=True), a __post_init__ for the lowercasing, a property for the url, and a with_port that builds something new. Try the obvious self.host = ... first and read the error — it is the lesson.
In __post_init__, use object.__setattr__(self, 'host', self.host.lower()) — the frozen class blocks the normal assignment even here. Decorate url with @property so it is read as an attribute. For with_port, dataclasses.replace does the copying.
from dataclasses import dataclass, replace. url returns f’http://{self.host}:{self.port}'. with_port returns replace(self, port=port) — one line, and it stays correct when a fourth field is added, which Config(self.host, port, self.debug) would not.
from dataclasses import dataclass, replace @dataclass(____=True) class Config: host: str port: int = ____ debug: bool = ____ def __post_init__(self): ____.__setattr__(self, 'host', self.host.____()) @____ def url(self): return f’http://{self.host}:{self.port}' def with_port(self, port): return ____(self, port=port)
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Config:
"""Settings for one running service."""
host: str
port: int = 8000
debug: bool = False
def __post_init__(self):
# The freeze applies inside the class too, so normalising a field
# has to go around it.
object.__setattr__(self, "host", self.host.lower())
@property
def url(self):
return f"http://{self.host}:{self.port}"
def with_port(self, port):
return replace(self, port=port)
replace(config, port=9000) and config.with_port(9000) do the same thing; the second says what kind of change it is and gives you somewhere to put validation later. Naming the handful of changes that are actually expected also documents the ones that are not — nobody writes with_host() by accident when the class does not offer it.
A property is right when the value is derived, cheap and free of side effects — it reads like the data it is standing in for. Make it a method the moment it does real work, can fail, or takes an argument, because config.url looking like a plain attribute is a promise that reading it costs nothing.
frozen=True stops you rebinding the fields; it does not stop anyone mutating what a field points at. A list or dict field on a frozen dataclass is still fully editable — and hashing it will raise, because the generated __hash__ hashes the field values. Keep frozen classes to immutable field types, or reach for tuple and frozenset when you need a collection in one.
Something that is genuinely meant to accumulate state — a connection pool, a request in flight, a game world — is not helped by being frozen; you will end up rebuilding it constantly and reading replace() calls instead of assignments. The rule of thumb is whether the thing is a value or a thing: a configuration, a coordinate, a money amount and a date range are values, and freezing them costs nothing you wanted. And for settings specifically, once there are environment variables, .env files and type coercion involved, pydantic-settings is doing considerably more than this.
- Add validation to __post_init__ — a port outside 1 to 65535 raises ValueError — and notice that this is the one place a frozen class can still refuse bad data.
- Add a from_url class method that parses 'http://host:port' back into a Config, then check that from_url(config.url) == config for a few configs.
- Give it a tags field holding a frozenset, then try the same thing with a set and watch what hashing does.