Immutable Slot Dataclasses: The Best Way to Model Python Configuration Objects
When configuration values should never change after creation, frozen dataclasses with slots give you immutability, memory efficiency, and clear validation—all without boilerplate. Learn how to use them safely and what trade‑offs to watch for.
31 Oct 2025, 19:28 UTC

The Problem: Configuration Objects That Change In‑Flight
In many Python applications, configuration is loaded once at startup and then used throughout the process. If a configuration object is mutable, a bug or a third‑party library can inadvertently alter a value, leading to subtle failures that are hard to trace. The classic example is a logging level that gets changed by a debugging helper, causing the production logger to become noisy.
Thesis: Use Frozen + Slots Dataclasses for Safe, Efficient Config Objects
Python’s dataclasses module lets you declare lightweight classes that automatically generate __init__, __repr__, __eq__, and more. Adding frozen=True makes instances immutable, while slots=True removes the per‑instance __dict__, cutting memory usage and preventing accidental attribute creation. Together they provide a clean, value‑object style that is ideal for configuration and domain primitives.
1. Boilerplate Reduction with dataclasses
Without dataclasses, a simple config class would look like:
class DbConfig:
def __init__(self, host: str, port: int, user: str, password: str):
self.host = host
self.port = port
self.user = user
self.password = password
def __repr__(self):
return f"DbConfig(host={self.host!r}, port={self.port!r}, user={self.user!r})"
def __eq__(self, other):
if not isinstance(other, DbConfig):
return NotImplemented
return (self.host, self.port, self.user, self.password) == (
other.host, other.port, other.user, other.password)
With a dataclass you reduce that to a single line:
from dataclasses import dataclass
@dataclass
class DbConfig:
host: str
port: int
user: str
password: str
Type hints remain visible, and the generated methods are suitable for debugging and equality checks.
2. Memory & Attribute Safety with slots
By default, every instance of a dataclass has a __dict__ that stores attributes. This allows dynamic attribute creation but costs memory and can lead to typos going unnoticed. Setting slots=True (Python 3.10+) instructs the interpreter to allocate a fixed set of attributes and store them in a tuple-like structure instead of a dictionary.
Example of the effect:
# Using slots
@dataclass(slots=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(hasattr(p, "__dict__")) # False
print(Point.__slots__) # ('x', 'y')
Memory savings become significant when you instantiate thousands of objects—common in parsing or data‑processing pipelines.
3. Validation & Post‑Init
Even immutable objects often need validation. Dataclasses provide a __post_init__ hook that runs after the generated __init__. When frozen=True, you must use object.__setattr__ to modify attributes inside __post_init__.
@dataclass(frozen=True, slots=True)
class Config:
timeout: int
retries: int
def __post_init__(self):
if self.timeout < 0:
raise ValueError("timeout must be non‑negative")
if self.retries < 0:
raise ValueError("retries must be non‑negative")
Attempting to assign to self.timeout directly would raise FrozenInstanceError, so object.__setattr__ is the only safe path.
Concrete Example: Immutable Database Config
Below is a realistic configuration dataclass that demonstrates the key points: frozen, slots, validation, and a default value for a mutable type (handled carefully).
from dataclasses import dataclass, field
from typing import Mapping
# Default mapping must be a new dict for each instance
DEFAULT_HEADERS = {"User-Agent": "MyApp/1.0"}
@dataclass(frozen=True, slots=True)
class DbConfig:
host: str
port: int
user: str
password: str
options: Mapping[str, str] = field(default_factory=lambda: DEFAULT_HEADERS.copy())
def __post_init__(self):
if not (0 <= self.port <= 65535):
raise ValueError("port out of range")
if not self.host:
raise ValueError("host cannot be empty")
Key points in the example:
- Using
field(default_factory=…)ensures each instance gets its own copy of the default dictionary. - Validation occurs before the instance is usable, preventing silent bugs.
- Because the class is frozen,
optionscannot be reassigned, but the dictionary itself can still be mutated. If you want deep immutability, replaceMappingwithMappingProxyTypefromtypes.
Trade‑Offs & Limitations
- Frozen instances surprise mutable code: Any library that expects to mutate configuration objects will raise
FrozenInstanceError. Test your integration points before switching. - Slots restrict dynamic attributes: Adding an attribute at runtime (e.g.,
obj.new_attr = 42) will raiseAttributeError. If you need extensibility, avoidslots=Trueor use a custom__setattr__. - Inheritance quirks: Slots are not automatically inherited; each subclass must redeclare
slots=Trueand list all attributes. Mixing slots with multiple inheritance can break pickling and weak references. - Python version:
slots=Trueis only available from Python 3.10. For earlier versions you must manually define__slots__and__setattr__. - Mutable defaults: Dataclasses do not deep‑copy mutable defaults. Use
default_factoryto avoid shared state.
Actionable Steps to Adopt
- Identify candidates: Pick configuration or value objects that are created once and should never change.
- Create frozen, slot dataclasses:
@dataclass(frozen=True, slots=True) class MyConfig: … - Validate at construction using
__post_init__to catch errors early. - Run a quick memory test:
import sys from dataclasses import dataclass @dataclass(slots=True) class Fast: a: int b: int print(sys.getsizeof(Fast(1, 2))) # compare to a normal class - Update callers to handle
FrozenInstanceErrorif they previously mutated objects. - Add unit tests that attempt mutation and assert the proper exception is raised.
- Document the design decision so future developers understand the immutability contract.
By following these steps you’ll create configuration objects that are safe, memory‑efficient, and easier to reason about—especially in concurrent or distributed systems where accidental mutation can cause hard‑to‑track bugs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.