Skip to main content

Python Dataclasses — Code Generation, Immutability,: Practice Problems & Exercises

Practice: Dataclasses — Code Generation, Immutability, and Production Patterns

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Define a Basic DataclassEasy
dataclassbasics

Define a Point dataclass with two float fields x and y. The @dataclass decorator will generate __init__ and __repr__ automatically.

Python
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p = Point(3.0, 4.0)
print(p)
print(p.x, p.y)
Solution
from dataclasses import dataclass

@dataclass
class Point:
x: float
y: float

p = Point(3.0, 4.0)
print(p) # Point(x=3.0, y=4.0)
print(p.x, p.y) # 3.0 4.0

# __eq__ is also generated:
p2 = Point(3.0, 4.0)
print(p == p2) # True
print(p is p2) # False

Explanation: @dataclass inspects class-level annotations and generates __init__ (with one parameter per annotated field), __repr__ (showing all fields), and __eq__ (comparing field values). This eliminates the ~10 lines of boilerplate that a regular class would need.

from dataclasses import dataclass

# TODO: define a Point dataclass with x: float and y: float

@dataclass
class Point:
  pass

p = Point(3.0, 4.0)
print(p)
print(p.x, p.y)
Expected Output
Point(x=3.0, y=4.0)\n3.0 4.0
Hints

Hint 1: Add x: float and y: float as class-level annotations inside the @dataclass class.

Hint 2: @dataclass generates __init__, __repr__, and __eq__ automatically.


#2Default Field ValuesEasy
dataclassdefaultsfield

Create two Config instances: one using all defaults, one with custom values. Verify the __repr__ output for both.

Python
from dataclasses import dataclass

@dataclass
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False

default_cfg = Config()
custom_cfg = Config(host="prod.example.com", port=443, debug=False)

print(default_cfg)
print(custom_cfg)
Solution
from dataclasses import dataclass

@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False

default_cfg = Config()
custom_cfg = Config(host="prod.example.com", port=443, debug=False)
partial_cfg = Config(port=9000) # only override port

print(default_cfg) # Config(host='localhost', port=8080, debug=False)
print(custom_cfg) # Config(host='prod.example.com', port=443, debug=False)
print(partial_cfg) # Config(host='localhost', port=9000, debug=False)

Explanation: Fields with default values become optional parameters in the generated __init__. Fields without defaults are mandatory. A critical rule: fields without defaults must be declared before fields with defaults, otherwise Python raises TypeError ("non-default argument follows default argument").

from dataclasses import dataclass

@dataclass
class Config:
  host: str = "localhost"
  port: int = 8080
  debug: bool = False

# TODO: create a default Config and a custom Config
default_cfg = Config()
custom_cfg = Config(host="prod.example.com", port=443, debug=False)

print(default_cfg)
print(custom_cfg)
Expected Output
Config(host='localhost', port=8080, debug=False)\nConfig(host='prod.example.com', port=443, debug=False)
Hints

Hint 1: Fields with defaults are optional in __init__.

Hint 2: Fields without defaults must come before fields with defaults.


#3field() with default_factoryEasy
dataclassfielddefault_factory

Run the code and verify that o1.items and o2.items are independent lists. Then try replacing default_factory=list with = [] and observe the ValueError dataclasses raises to protect you.

Python
from dataclasses import dataclass, field
from typing import List

@dataclass
class Order:
    order_id: int
    items: List[str] = field(default_factory=list)

o1 = Order(order_id=1)
o2 = Order(order_id=2)
o1.items.append("Apple")

print(o1.items)   # ['Apple']
print(o2.items)   # []

# Try the mutable default mistake:
try:
    @dataclass
    class BadOrder:
        items: List[str] = []
except ValueError as e:
    print(f"ValueError: {e}")
Solution
from dataclasses import dataclass, field
from typing import List, Dict

@dataclass
class Order:
order_id: int
items: List[str] = field(default_factory=list)
metadata: Dict[str, str] = field(default_factory=dict)

o1 = Order(order_id=1)
o2 = Order(order_id=2)
o1.items.append("Apple")
o1.metadata["source"] = "web"

print(o1.items) # ['Apple']
print(o2.items) # [] — independent
print(o1.metadata) # {'source': 'web'}
print(o2.metadata) # {} — independent

# dataclasses raises ValueError to prevent the mutable default trap:
try:
@dataclass
class BadOrder:
items: List[str] = []
except ValueError as e:
print(f"ValueError: {e}")

Explanation: Mutable defaults (like [] or {}) are shared across all instances because Python evaluates default values once at class definition time. default_factory is called for each new instance, guaranteeing isolation. Dataclasses detect the mistake and raise ValueError immediately if you try to use a mutable default directly.

from dataclasses import dataclass, field
from typing import List

@dataclass
class Order:
  order_id: int
  items: List[str] = field(default_factory=list)

o1 = Order(order_id=1)
o2 = Order(order_id=2)
o1.items.append("Apple")

print(o1.items)
print(o2.items)
Expected Output
['Apple']\n[]
Hints

Hint 1: default_factory=list means each instance gets its own empty list.

Hint 2: Never use items: List[str] = [] — all instances would share the same list object.


#4Frozen Dataclass — ImmutabilityEasy
dataclassfrozenimmutability

Verify that frozen=True makes a dataclass immutable by attempting to modify an attribute and catching the resulting error.

Python
from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float

c = Coordinate(51.5, -0.12)
print(c)

try:
    c.lat = 0.0
except Exception as e:
    print(type(e).__name__, e)
Solution
from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float

c = Coordinate(51.5, -0.12)
print(c) # Coordinate(lat=51.5, lon=-0.12)
print(hash(c)) # frozen dataclasses are hashable

# Attempt mutation:
try:
c.lat = 0.0
except Exception as e:
print(type(e).__name__, e)

# Can be used as a dict key or set member:
visited = {c: "London"}
print(visited[Coordinate(51.5, -0.12)]) # London

# Use dataclasses.replace() to create modified copies:
from dataclasses import replace
c2 = replace(c, lat=40.71)
print(c2) # Coordinate(lat=40.71, lon=-0.12)

Explanation: frozen=True converts the dataclass into a value object. It generates __hash__ (making instances usable in sets and as dict keys) and overrides __setattr__ and __delattr__ to raise FrozenInstanceError. Use dataclasses.replace() to create modified copies — the immutable equivalent of obj.attr = new_value.

from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
  lat: float
  lon: float

c = Coordinate(51.5, -0.12)
print(c)

# TODO: try to modify c.lat and catch the FrozenInstanceError
try:
  c.lat = 0.0
except Exception as e:
  print(type(e).__name__, e)
Expected Output
Coordinate(lat=51.5, lon=-0.12)\nFrozenInstanceError cannot assign to field 'lat'
Hints

Hint 1: frozen=True generates __hash__ and prevents attribute assignment.

Hint 2: The exception type is dataclasses.FrozenInstanceError (a subclass of AttributeError).


Medium

#5__post_init__ ValidationMedium
dataclass__post_init__validation

Implement __post_init__ to validate that value lies within [min_val, max_val]. The validation should run automatically on every instantiation.

Python
from dataclasses import dataclass

@dataclass
class BoundedInt:
    value: int
    min_val: int
    max_val: int

    def __post_init__(self):
        if not (self.min_val <= self.value <= self.max_val):
            raise ValueError(
                f"{self.value} is not in range [{self.min_val}, {self.max_val}]"
            )

b = BoundedInt(value=5, min_val=1, max_val=10)
print(b)

try:
    bad = BoundedInt(value=15, min_val=1, max_val=10)
except ValueError as e:
    print(f"ValueError: {e}")
Solution
from dataclasses import dataclass

@dataclass
class BoundedInt:
value: int
min_val: int
max_val: int

def __post_init__(self) -> None:
if not (self.min_val <= self.value <= self.max_val):
raise ValueError(
f"{self.value} is not in range [{self.min_val}, {self.max_val}]"
)
# Optional: also validate min_val <= max_val
if self.min_val > self.max_val:
raise ValueError(
f"min_val ({self.min_val}) must be <= max_val ({self.max_val})"
)

b = BoundedInt(value=5, min_val=1, max_val=10)
print(b) # BoundedInt(value=5, min_val=1, max_val=10)

try:
BoundedInt(value=15, min_val=1, max_val=10)
except ValueError as e:
print(f"ValueError: {e}")

try:
BoundedInt(value=5, min_val=10, max_val=1)
except ValueError as e:
print(f"ValueError: {e}")

Explanation: __post_init__ is the canonical place for dataclass validation. It runs after the generated __init__ populates all fields, so you can safely access self.value, self.min_val, etc. This is the dataclass alternative to writing a custom __init__ — you keep code generation but add runtime constraints.

from dataclasses import dataclass

@dataclass
class BoundedInt:
  value: int
  min_val: int
  max_val: int

  def __post_init__(self):
      # TODO: raise ValueError if value is outside [min_val, max_val]
      pass

b = BoundedInt(value=5, min_val=1, max_val=10)
print(b)

try:
  bad = BoundedInt(value=15, min_val=1, max_val=10)
except ValueError as e:
  print(f"ValueError: {e}")
Expected Output
BoundedInt(value=5, min_val=1, max_val=10)\nValueError: 15 is not in range [1, 10]
Hints

Hint 1: __post_init__ is called automatically after __init__ completes.

Hint 2: Check if self.value < self.min_val or self.value > self.max_val.


#6InitVar — One-Time Init ArgumentMedium
dataclassInitVar__post_init__

Use InitVar to accept raw_password during construction without storing it. Store only the hashed value as password_hash.

Python
from dataclasses import dataclass, field, InitVar

@dataclass
class HashedPassword:
    username: str
    password_hash: str = field(init=False)
    raw_password: InitVar[str] = None

    def __post_init__(self, raw_password: str) -> None:
        self.password_hash = str(hash(raw_password))

user = HashedPassword(username="alice", raw_password="secret")
print(user.username)
print(user.password_hash != "secret")    # True
print(hasattr(user, "raw_password"))     # False
Solution
from dataclasses import dataclass, field, InitVar
import hashlib

@dataclass
class HashedPassword:
username: str
# Not stored as an instance attribute — init=False means
# the generated __init__ won't accept it as a parameter
password_hash: str = field(init=False, repr=False)
# InitVar: passed to __post_init__ only, never stored
raw_password: InitVar[str] = None

def __post_init__(self, raw_password: str) -> None:
if raw_password is None:
raise ValueError("raw_password is required")
# In production use bcrypt/argon2, not hashlib
self.password_hash = hashlib.sha256(
raw_password.encode()
).hexdigest()

user = HashedPassword(username="alice", raw_password="secret")
print(user.username) # alice
print(user.password_hash != "secret") # True
print(hasattr(user, "raw_password")) # False

# The password is not printed (repr=False):
print(user) # HashedPassword(username='alice')

Explanation: InitVar creates a constructor parameter that is threaded to __post_init__ and then discarded — it never becomes an instance attribute. Combined with field(init=False) for password_hash, this pattern lets you accept a raw value, transform it, and store only the derived result. The repr=False on password_hash prevents sensitive data from appearing in logs.

from dataclasses import dataclass, field
from dataclasses import InitVar

@dataclass
class HashedPassword:
  username: str
  password_hash: str = field(init=False)
  raw_password: InitVar[str] = None

  def __post_init__(self, raw_password: str):
      # TODO: set self.password_hash to a simple hash of raw_password
      # Use hash(raw_password) for simplicity
      pass

user = HashedPassword(username="alice", raw_password="secret")
print(user.username)
print(user.password_hash != "secret")   # True — it was hashed
print(hasattr(user, "raw_password"))     # False — not stored
Expected Output
alice\nTrue\nFalse
Hints

Hint 1: InitVar fields are passed to __post_init__ but NOT stored as instance attributes.

Hint 2: Set self.password_hash = str(hash(raw_password)) in __post_init__.


#7Dataclass OrderingMedium
dataclassordercomparison

Use order=True to make Version objects sortable. The comparison should use major, minor, then patch in that order.

Python
from dataclasses import dataclass

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

v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 3)

print(v1 < v2)
print(v1 == v3)
versions = [Version(2, 0, 0), Version(1, 9, 9), Version(1, 2, 3)]
print(sorted(versions))
Solution
from dataclasses import dataclass

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

v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 3)

print(v1 < v2) # True (1.2.3 < 1.3.0)
print(v1 == v3) # True (field-by-field equality)
print(v1 > v2) # False

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

latest = max(versions)
print(latest) # Version(major=2, minor=0, patch=0)

Explanation: order=True generates all six comparison methods based on a tuple of the fields in declaration order. This is equivalent to manually implementing __lt__, __le__, __gt__, __ge__ that compare (self.major, self.minor, self.patch) tuples. The field order in the class definition determines the sort priority.

from dataclasses import dataclass

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

v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 3)

print(v1 < v2)
print(v1 == v3)
versions = [Version(2, 0, 0), Version(1, 9, 9), Version(1, 2, 3)]
print(sorted(versions))
Expected Output
True\nTrue\n[Version(major=1, minor=2, patch=3), Version(major=1, minor=9, patch=9), Version(major=2, minor=0, patch=0)]
Hints

Hint 1: order=True generates __lt__, __le__, __gt__, __ge__ based on the tuple of all fields in declaration order.

Hint 2: Fields are compared left-to-right: major first, then minor, then patch.


#8ClassVar — Class-Level CounterMedium
dataclassClassVarclass-variables

Use ClassVar to track the total number of Widget instances created. The counter should increment in __post_init__ and be accessible via a class method.

Python
from dataclasses import dataclass
from typing import ClassVar

@dataclass
class Widget:
    _count: ClassVar[int] = 0
    name: str

    def __post_init__(self) -> None:
        Widget._count += 1

    @classmethod
    def total(cls) -> int:
        return cls._count

w1 = Widget("Button")
w2 = Widget("Label")
w3 = Widget("Input")

print(Widget.total())
Solution
from dataclasses import dataclass, fields
from typing import ClassVar

@dataclass
class Widget:
# ClassVar: excluded from __init__, __repr__, __eq__
_count: ClassVar[int] = 0
name: str
visible: bool = True

def __post_init__(self) -> None:
Widget._count += 1

@classmethod
def total(cls) -> int:
return cls._count

@classmethod
def reset(cls) -> None:
cls._count = 0

w1 = Widget("Button")
w2 = Widget("Label")
w3 = Widget("Input", visible=False)

print(Widget.total()) # 3
print(w1) # Widget(name='Button', visible=True)
# _count does not appear in repr — ClassVar is excluded

# Verify: ClassVar not in dataclass fields
print([f.name for f in fields(Widget)]) # ['name', 'visible']

Explanation: ClassVar[T] signals to @dataclass that this annotation is a class variable, not an instance field. The decorator excludes it from the generated __init__, __repr__, and __eq__. At runtime it behaves as a regular class attribute — shared across all instances and accessible via the class name.

from dataclasses import dataclass
from typing import ClassVar

@dataclass
class Widget:
  _count: ClassVar[int] = 0
  name: str

  def __post_init__(self):
      # TODO: increment _count on every new instance
      pass

  @classmethod
  def total(cls) -> int:
      return cls._count

w1 = Widget("Button")
w2 = Widget("Label")
w3 = Widget("Input")

print(Widget.total())
Expected Output
3
Hints

Hint 1: ClassVar fields are excluded from __init__ — they are not per-instance.

Hint 2: In __post_init__, do Widget._count += 1 (or type(self)._count += 1).


Hard

#9Dataclass Inheritance with ValidationHard
dataclassinheritance__post_init__validation

Implement Dog.__post_init__ to call the parent validation (negative age) and add its own (empty breed string). Both validation layers should work independently.

Python
from dataclasses import dataclass

@dataclass
class Animal:
    name: str
    age: int

    def __post_init__(self) -> None:
        if self.age < 0:
            raise ValueError(f"Age cannot be negative: {self.age}")

@dataclass
class Dog(Animal):
    breed: str = "Unknown"

    def __post_init__(self) -> None:
        super().__post_init__()   # run Animal validation
        if not self.breed:
            raise ValueError("breed cannot be empty")

d = Dog(name="Rex", age=3, breed="Labrador")
print(d)

try:
    Dog(name="Max", age=-1, breed="Poodle")
except ValueError as e:
    print(f"ValueError: {e}")

try:
    Dog(name="Spot", age=2, breed="")
except ValueError as e:
    print(f"ValueError: {e}")
Solution
from dataclasses import dataclass

@dataclass
class Animal:
name: str
age: int

def __post_init__(self) -> None:
if self.age < 0:
raise ValueError(f"Age cannot be negative: {self.age}")
if not self.name.strip():
raise ValueError("name cannot be blank")

@dataclass
class Dog(Animal):
breed: str = "Unknown"

def __post_init__(self) -> None:
super().__post_init__() # delegate Animal validations first
if not self.breed.strip():
raise ValueError("breed cannot be empty")

@dataclass
class GuideDog(Dog):
handler: str = ""

def __post_init__(self) -> None:
super().__post_init__() # runs Dog -> Animal validations
if not self.handler.strip():
raise ValueError("handler name required for guide dog")

d = Dog(name="Rex", age=3, breed="Labrador")
print(d) # Dog(name='Rex', age=3, breed='Labrador')

try:
Dog(name="Max", age=-1, breed="Poodle")
except ValueError as e:
print(f"ValueError: {e}") # Age cannot be negative: -1

try:
Dog(name="Spot", age=2, breed="")
except ValueError as e:
print(f"ValueError: {e}") # breed cannot be empty

try:
GuideDog(name="Buddy", age=4, breed="Lab", handler="")
except ValueError as e:
print(f"ValueError: {e}") # handler name required for guide dog

Explanation: super().__post_init__() chains validation up the dataclass hierarchy. Each class validates only what it owns — Animal checks age, Dog checks breed, GuideDog checks handler. This mirrors the Open/Closed Principle: adding a new subclass adds new validation without modifying the parent class.

from dataclasses import dataclass

@dataclass
class Animal:
  name: str
  age: int

  def __post_init__(self):
      if self.age < 0:
          raise ValueError(f"Age cannot be negative: {self.age}")

@dataclass
class Dog(Animal):
  breed: str = "Unknown"

  def __post_init__(self):
      # TODO: call parent __post_init__ AND add own validation
      # Raise ValueError if breed is an empty string
      pass

d = Dog(name="Rex", age=3, breed="Labrador")
print(d)

try:
  bad = Dog(name="Max", age=-1, breed="Poodle")
except ValueError as e:
  print(f"ValueError: {e}")

try:
  bad2 = Dog(name="Spot", age=2, breed="")
except ValueError as e:
  print(f"ValueError: {e}")
Expected Output
Dog(name='Rex', age=3, breed='Labrador')\nValueError: Age cannot be negative: -1\nValueError: breed cannot be empty
Hints

Hint 1: Call super().__post_init__() first to run parent validation.

Hint 2: Then add your own check: if not self.breed: raise ValueError(...).


#10Nested Frozen DataclassesHard
dataclassfrozennestedimmutability

Use dataclasses.replace() to create an immutable update — produce a new Person where Alice has moved to Manchester, without modifying the original.

Python
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Address:
    street: str
    city: str
    country: str = "UK"

@dataclass(frozen=True)
class Person:
    name: str
    address: Address

p = Person(name="Alice", address=Address("10 Downing St", "London"))
print(p)

new_address = replace(p.address, city="Manchester")
p2 = replace(p, address=new_address)
print(p2)
print(p.address.city)   # still London
Solution
from dataclasses import dataclass, replace, asdict

@dataclass(frozen=True)
class Address:
street: str
city: str
country: str = "UK"

@dataclass(frozen=True)
class Person:
name: str
address: Address

p = Person(
name="Alice",
address=Address("10 Downing St", "London")
)
print(p)

# Immutable update pattern: replace returns a new instance
new_address = replace(p.address, city="Manchester")
p2 = replace(p, address=new_address)

print(p2)
print(p.address.city) # London — original unchanged
print(p2.address.city) # Manchester

# asdict() for serialisation
print(asdict(p2))

# Frozen objects are hashable — can be used in sets
people = {p, p2}
print(len(people)) # 2 — different hashes

Explanation: dataclasses.replace() is the functional-update pattern for immutable dataclasses. It creates a new instance with all fields copied from the source, then applies only the overrides you provide. For nested frozen dataclasses you must create the inner replacement first (new Address), then wrap it in the outer replacement (new Person). The original object is never mutated.

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Address:
  street: str
  city: str
  country: str = "UK"

@dataclass(frozen=True)
class Person:
  name: str
  address: Address

p = Person(name="Alice", address=Address("10 Downing St", "London"))
print(p)

# TODO: create p2 where Alice moved to Manchester
# Use dataclasses.replace() — do not mutate p
p2 = None  # replace with correct call
print(p2)
print(p.address.city)   # still London
Expected Output
Person(name='Alice', address=Address(street='10 Downing St', city='London', country='UK'))\nPerson(name='Alice', address=Address(street='10 Downing St', city='Manchester', country='UK'))\nLondon
Hints

Hint 1: You need a new Address with city="Manchester", then replace(p, address=new_address).

Hint 2: replace() creates a shallow copy with specified fields overridden.


#11Dataclass as a Config System with asdict/astupleHard
dataclassasdictastuplefieldproduction-patterns

Build a nested config system using dataclasses. Use asdict() to produce a plain-dict serialisation for JSON export, verifying that nested dataclasses are converted recursively.

Python
from dataclasses import dataclass, field, asdict
from typing import List

@dataclass
class DatabaseConfig:
    host: str
    port: int
    name: str
    pool_size: int = 5
    ssl: bool = False

@dataclass
class AppConfig:
    app_name: str
    version: str
    databases: List[DatabaseConfig] = field(default_factory=list)
    feature_flags: dict = field(default_factory=dict)

    def add_database(self, db: DatabaseConfig) -> None:
        self.databases.append(db)

    def to_dict(self) -> dict:
        return asdict(self)

cfg = AppConfig(app_name="EngineersOfAI", version="1.0.0")
cfg.add_database(DatabaseConfig("localhost", 5432, "prod_db", ssl=True))
cfg.feature_flags["dark_mode"] = True

result = cfg.to_dict()
print(result["app_name"])
print(result["databases"][0]["host"])
print(result["feature_flags"]["dark_mode"])
Solution
import json
from dataclasses import dataclass, field, asdict, astuple
from typing import List, Optional

@dataclass
class DatabaseConfig:
host: str
port: int
name: str
pool_size: int = 5
ssl: bool = False
password: Optional[str] = field(default=None, repr=False)

@dataclass
class AppConfig:
app_name: str
version: str
databases: List[DatabaseConfig] = field(default_factory=list)
feature_flags: dict = field(default_factory=dict)

def add_database(self, db: DatabaseConfig) -> None:
self.databases.append(db)

def to_dict(self) -> dict:
# asdict() recursively converts nested dataclasses
return asdict(self)

def to_json(self) -> str:
return json.dumps(self.to_dict(), indent=2)

cfg = AppConfig(app_name="EngineersOfAI", version="1.0.0")
cfg.add_database(
DatabaseConfig("localhost", 5432, "prod_db", ssl=True)
)
cfg.add_database(
DatabaseConfig("replica.host", 5432, "prod_db_read")
)
cfg.feature_flags["dark_mode"] = True
cfg.feature_flags["beta_features"] = False

result = cfg.to_dict()
print(result["app_name"]) # EngineersOfAI
print(result["databases"][0]["host"]) # localhost
print(result["feature_flags"]["dark_mode"]) # True

# Nested dataclass converted to dict:
print(type(result["databases"][0])) # <class 'dict'>

# Full JSON export:
print(cfg.to_json())

Explanation: asdict() performs a deep conversion — nested dataclasses become nested dicts, lists of dataclasses become lists of dicts. This makes dataclasses a natural fit for configuration models that need to be serialised to JSON, YAML, or stored in databases. The repr=False on password keeps credentials out of logs, while asdict() still includes the field in the dict for actual serialisation when needed.

from dataclasses import dataclass, field, asdict, astuple
from typing import List

@dataclass
class DatabaseConfig:
  host: str
  port: int
  name: str
  pool_size: int = 5
  ssl: bool = False

@dataclass
class AppConfig:
  app_name: str
  version: str
  databases: List[DatabaseConfig] = field(default_factory=list)
  feature_flags: dict = field(default_factory=dict)

  def add_database(self, db: DatabaseConfig) -> None:
      self.databases.append(db)

  def to_dict(self) -> dict:
      return asdict(self)

cfg = AppConfig(app_name="EngineersOfAI", version="1.0.0")
cfg.add_database(DatabaseConfig("localhost", 5432, "prod_db", ssl=True))
cfg.feature_flags["dark_mode"] = True

result = cfg.to_dict()
print(result["app_name"])
print(result["databases"][0]["host"])
print(result["feature_flags"]["dark_mode"])
Expected Output
EngineersOfAI\nlocalhost\nTrue
Hints

Hint 1: asdict() recursively converts nested dataclasses to dicts.

Hint 2: The result["databases"][0] will be a plain dict, not a DatabaseConfig.

© 2026 EngineersOfAI. All rights reserved.