Skip to main content

Python __init__ Practice Problems & Exercises

Practice: __init__ and Object Construction — Two-Phase Creation at Engineering Depth

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

Easy

#1Predict the Output — Default Mutable ArgumentEasy
default argumentsmutable defaultsoutput prediction

Predict what each print statement outputs. This is the classic mutable-default-argument trap — it applies equally to __init__ parameters.

Python
def make_tag(text, attrs={}):
    attrs["text"] = text
    return attrs

a = make_tag("hello")
b = make_tag("world")
print(a)
print(b)
print(a is b)
Solution
{'text': 'world'}
{'text': 'world'}
True

Explanation: Python evaluates default argument values once at function-definition time. The {} is created once and reused. The first call sets attrs["text"] = "hello". The second call receives the same dict and overwrites the value with "world". Both a and b reference the identical object, so a is b is True and both print {'text': 'world'}. Fix: use attrs=None and set attrs = attrs or {} inside the function body.

def make_tag(text, attrs={}):
  attrs["text"] = text
  return attrs

a = make_tag("hello")
b = make_tag("world")
print(a)
print(b)
print(a is b)
Expected Output
{'text': 'world'}\n{'text': 'world'}\nTrue
Hints

Hint 1: Default argument values are evaluated once when the function is defined, not on each call.

Hint 2: Both calls share the same dict object. The second call overwrites the "text" key.

Hint 3: a is b is True because both names point to the same default dict.


#2Fix the __init__ — Mutable Default ParameterEasy
__init__mutable defaultsbug fix

The Config class has the classic mutable-default-argument bug. Fix __init__ so every instance gets its own independent settings dictionary.

Python
class Config:
    def __init__(self, settings=None):
        self.settings = settings if settings is not None else {}

    def set(self, key, value):
        self.settings[key] = value

c1 = Config()
c2 = Config()
c1.set("debug", True)
print(c2.settings)
Solution
class Config:
def __init__(self, settings=None):
self.settings = settings if settings is not None else {}

def set(self, key, value):
self.settings[key] = value

Explanation: The idiomatic fix is to use None as the default sentinel and create a fresh {} inside the function body. Each call now allocates a new dict and assigns it to self.settings. Callers who explicitly pass a dict can still do so (Config({"key": "val"})) — and that shared dict is their responsibility. The sentinel pattern is universally used in Python codebases wherever mutable defaults would otherwise cause bugs.

class Config:
  def __init__(self, settings={}):
      self.settings = settings

  def set(self, key, value):
      self.settings[key] = value

c1 = Config()
c2 = Config()
c1.set("debug", True)
# BUG: c2.settings should be empty but it is not
print(c2.settings)  # should print {}
Expected Output
{}
Hints

Hint 1: The shared default dict means c1 and c2 both point to the same object.

Hint 2: Change the default to None and assign self.settings = settings if settings is not None else {}.

Hint 3: Never use mutable objects ([], {}, set()) as default values in __init__ or any function.


#3Chained __init__ with super()Easy
super()__init__inheritance

Complete Dog.__init__ by calling the parent Animal.__init__ using super(). The dog always makes a "woof" sound.

Python
class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "woof")
        self.breed = breed

    def describe(self):
        return f"{self.name} ({self.breed}) says {self.sound}"

d = Dog("Rex", "Labrador")
print(d.describe())
Solution
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound

class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, "woof")
self.breed = breed

def describe(self):
return f"{self.name} ({self.breed}) says {self.sound}"

Explanation: super().__init__(name, "woof") delegates to Animal.__init__, which sets self.name and self.sound. Then Dog.__init__ adds self.breed. Without the super() call, self.name and self.sound would never be set, and describe would raise AttributeError. Always call super().__init__ in subclass constructors to ensure the parent class initialises its state correctly.

class Animal:
  def __init__(self, name, sound):
      self.name = name
      self.sound = sound

class Dog(Animal):
  def __init__(self, name, breed):
      # TODO: call Animal.__init__ with name and "woof"
      self.breed = breed

  def describe(self):
      return f"{self.name} ({self.breed}) says {self.sound}"

d = Dog("Rex", "Labrador")
print(d.describe())
Expected Output
Rex (Labrador) says woof
Hints

Hint 1: Call super().__init__(name, "woof") inside Dog.__init__ before or after setting self.breed.

Hint 2: super() returns a proxy for the parent class, so its __init__ sets self.name and self.sound.

Hint 3: After calling super().__init__, Dog.__init__ can set self.breed = breed.


#4Keyword-Only Parameters in __init__Easy
keyword-only__init__API design

Identify which constructor calls are valid and which raise TypeError. Then explain why keyword-only parameters improve API design.

Python
class Connection:
    def __init__(self, host, port, *, timeout=30, retries=3):
        self.host = host
        self.port = port
        self.timeout = timeout
        self.retries = retries

    def __str__(self):
        return f"{self.host}:{self.port} (timeout={self.timeout}, retries={self.retries})"

c1 = Connection("db.local", 5432)
c3 = Connection("db.local", 5432, timeout=60)
print(c1)
print(c3)

try:
    c2 = Connection("db.local", 5432, 60)  # positional for keyword-only param
except TypeError as e:
    print(f"c2 error: {e}")
Solution
class Connection:
def __init__(self, host, port, *, timeout=30, retries=3):
self.host = host
self.port = port
self.timeout = timeout
self.retries = retries

def __str__(self):
return f"{self.host}:{self.port} (timeout={self.timeout}, retries={self.retries})"

Explanation: The bare * in a parameter list marks all following parameters as keyword-only. timeout and retries cannot be passed positionally — callers must write timeout=60. This prevents common ordering mistakes (swapping timeout and retries) and makes call sites self-documenting. c1 uses defaults; c3 overrides only timeout; c2 raises TypeError: __init__() takes 3 positional arguments but 4 were given.

class Connection:
  def __init__(self, host, port, *, timeout=30, retries=3):
      self.host = host
      self.port = port
      self.timeout = timeout
      self.retries = retries

  def __str__(self):
      return f"{self.host}:{self.port} (timeout={self.timeout}, retries={self.retries})"

# Which of these calls will raise TypeError?
# Uncomment to test each one:
# c1 = Connection("db.local", 5432)
# c2 = Connection("db.local", 5432, 60)
# c3 = Connection("db.local", 5432, timeout=60)
# c4 = Connection("db.local", 5432, timeout=60, retries=5)

c1 = Connection("db.local", 5432)
c3 = Connection("db.local", 5432, timeout=60)
print(c1)
print(c3)
Expected Output
db.local:5432 (timeout=30, retries=3)\ndb.local:5432 (timeout=60, retries=3)
Hints

Hint 1: Parameters after the bare * are keyword-only — they must be passed by name.

Hint 2: Connection("db.local", 5432, 60) passes 60 as a positional argument for timeout, which is forbidden.

Hint 3: c1 and c3 are valid; c2 raises TypeError because keyword-only args cannot be passed positionally.


Medium

#5Validated __init__ with InvariantsMedium
validation__init__invariantserror handling

Add invariant validation to BankAccount.__init__ so that invalid arguments raise ValueError with clear messages.

Python
class BankAccount:
    def __init__(self, owner, balance=0):
        if not isinstance(owner, str) or not owner.strip():
            raise ValueError("owner must be a non-empty string")
        if not isinstance(balance, (int, float)) or balance < 0:
            raise ValueError("balance must be non-negative")
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount

    def __str__(self):
        return f"Account({self.owner!r}, balance={self.balance})"

acc = BankAccount("Alice", 100)
print(acc)
try:
    bad = BankAccount("", 100)
except ValueError as e:
    print(e)
try:
    bad2 = BankAccount("Bob", -50)
except ValueError as e:
    print(e)
Solution
class BankAccount:
def __init__(self, owner, balance=0):
if not isinstance(owner, str) or not owner.strip():
raise ValueError("owner must be a non-empty string")
if not isinstance(balance, (int, float)) or balance < 0:
raise ValueError("balance must be non-negative")
self.owner = owner
self.balance = balance

Explanation: Validating in __init__ ensures the object is always in a valid state after construction — there is never a "partially constructed" instance with invalid data. Checking both type and value gives better error messages than letting a downstream operation fail mysteriously. The owner.strip() check rejects whitespace-only strings. Storing attributes only after all validations pass prevents partial state if a later check fails.

class BankAccount:
  def __init__(self, owner, balance=0):
      # TODO: validate owner is non-empty string
      # TODO: validate balance is non-negative number
      self.owner = owner
      self.balance = balance

  def deposit(self, amount):
      if amount <= 0:
          raise ValueError("Deposit amount must be positive")
      self.balance += amount

  def __str__(self):
      return f"Account({self.owner!r}, balance={self.balance})"

acc = BankAccount("Alice", 100)
print(acc)
try:
  bad = BankAccount("", 100)
except ValueError as e:
  print(e)
try:
  bad2 = BankAccount("Bob", -50)
except ValueError as e:
  print(e)
Expected Output
Account('Alice', balance=100)\nowner must be a non-empty string\nbalance must be non-negative
Hints

Hint 1: Check if owner is a string with isinstance(owner, str) and that it is truthy (not empty).

Hint 2: Check balance >= 0 and that it is a number with isinstance(balance, (int, float)).

Hint 3: Raise ValueError with a descriptive message for each invalid case.


#6__init__ vs __new__ — When __new__ Runs FirstMedium
__new____init__object creationoutput prediction

Trace the creation sequence for Special(5) and Special(-1), predicting every print line in order.

Python
class Logged:
    def __new__(cls, *args, **kwargs):
        print(f"__new__ called for {cls.__name__}")
        instance = super().__new__(cls)
        return instance

    def __init__(self, value):
        print(f"__init__ called with value={value}")
        self.value = value

class Special(Logged):
    def __new__(cls, value):
        if value < 0:
            print("Returning None — refusing negative value")
            return None
        return super().__new__(cls)

obj = Special(5)
print(type(obj))
bad = Special(-1)
print(bad)
Solution
__new__ called for Special
__init__ called with value=5
<class '__main__.Special'>
Returning None — refusing negative value
None

Explanation: Python's object creation protocol calls __new__ first. If it returns an instance of the expected class, __init__ is called on it. For Special(5): Special.__new__ delegates to Logged.__new__ (which prints and creates the instance), then __init__ initialises it. For Special(-1): Special.__new__ prints the refusal message and returns None. Because None is not an instance of Special, Python skips __init__. The variable bad holds None. This pattern is unusual; prefer raising an exception in __init__ over returning None from __new__ unless you genuinely need to prevent allocation.

class Logged:
  def __new__(cls, *args, **kwargs):
      print(f"__new__ called for {cls.__name__}")
      instance = super().__new__(cls)
      return instance

  def __init__(self, value):
      print(f"__init__ called with value={value}")
      self.value = value

class Special(Logged):
  def __new__(cls, value):
      if value < 0:
          print("Returning None — refusing negative value")
          return None
      return super().__new__(cls)

obj = Special(5)
print(type(obj))
bad = Special(-1)
print(bad)
Expected Output
__new__ called for Special\n__init__ called with value=5\n<class '__main__.Special'>\nReturning None — refusing negative value\nNone
Hints

Hint 1: __new__ runs before __init__. If __new__ returns None (or a non-instance), __init__ is NOT called.

Hint 2: When Special(-1) is created, __new__ returns None so __init__ is skipped entirely.

Hint 3: Python only calls __init__ if __new__ returns an instance of the class.


#7Multiple Constructors via classmethodsMedium
classmethodalternative constructorsfactory pattern

Implement two alternative constructors for Config as class methods: one from a plain dict and one from a JSON string.

Python
import json

class Config:
    def __init__(self, data):
        self.data = data

    @classmethod
    def from_dict(cls, d):
        return cls(dict(d))

    @classmethod
    def from_json_string(cls, json_str):
        return cls(json.loads(json_str))

    def get(self, key, default=None):
        return self.data.get(key, default)

c1 = Config.from_dict({"host": "localhost", "port": 5432})
c2 = Config.from_json_string('{"debug": true, "level": "info"}')
print(c1.get("host"))
print(c2.get("level"))
Solution
import json

class Config:
def __init__(self, data):
self.data = data

@classmethod
def from_dict(cls, d):
return cls(dict(d))

@classmethod
def from_json_string(cls, json_str):
return cls(json.loads(json_str))

def get(self, key, default=None):
return self.data.get(key, default)

Explanation: Multiple @classmethod constructors are the Pythonic way to offer several construction paths without overloading a single __init__ with complex logic. json.loads parses the JSON string into a Python dict, which is then passed directly to __init__. Using dict(d) in from_dict creates a shallow copy so mutations to the original dict do not affect the Config instance. Using cls(...) keeps the pattern subclass-compatible — a hypothetical LoggedConfig(Config) would get the right type back from both factory methods.

import json

class Config:
  def __init__(self, data):
      self.data = data

  @classmethod
  def from_dict(cls, d):
      # TODO: return a Config whose data is a copy of d
      pass

  @classmethod
  def from_json_string(cls, json_str):
      # TODO: parse json_str and return a Config
      pass

  def get(self, key, default=None):
      return self.data.get(key, default)

c1 = Config.from_dict({"host": "localhost", "port": 5432})
c2 = Config.from_json_string('{"debug": true, "level": "info"}')
print(c1.get("host"))
print(c2.get("level"))
Expected Output
localhost\ninfo
Hints

Hint 1: from_dict should return cls(dict(d)) — using dict() creates a shallow copy.

Hint 2: from_json_string should call json.loads(json_str) and pass the result to cls(...).

Hint 3: Use cls(...) not Config(...) so subclasses of Config work correctly.


#8__init__ Delegation with *args and **kwargsMedium
*args**kwargs__init__cooperative inheritance

Trace through the cooperative __init__ chain in this multiple-inheritance scenario. Predict the output and understand why *args/**kwargs forwarding is required.

Python
import datetime

class TimestampMixin:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.created_at = datetime.datetime.now().isoformat()

class LogMixin:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.log = []

    def add_log(self, msg):
        self.log.append(msg)

class Service(TimestampMixin, LogMixin):
    def __init__(self, name):
        super().__init__()
        self.name = name

s = Service("payments")
s.add_log("started")
print(s.name)
print(len(s.log))
print(isinstance(s.created_at, str))
Solution
payments
1
True

Explanation: Python's MRO for Service is [Service, TimestampMixin, LogMixin, object]. When Service.__init__ calls super().__init__(), control flows to TimestampMixin.__init__, which calls super().__init__() again — this reaches LogMixin.__init__ (not object), because MRO is linear. LogMixin then calls super().__init__() which finally hits object.__init__. Each step adds its attributes to self. The *args/**kwargs forwarding ensures that arguments can be threaded through the chain without any mixin needing to know the full parameter list. After the chain, Service.__init__ sets self.name.

class TimestampMixin:
  def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      import datetime
      self.created_at = datetime.datetime.now().isoformat()

class LogMixin:
  def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.log = []

  def add_log(self, msg):
      self.log.append(msg)

class Service(TimestampMixin, LogMixin):
  def __init__(self, name):
      super().__init__()
      self.name = name

s = Service("payments")
s.add_log("started")
print(s.name)
print(len(s.log))
print(isinstance(s.created_at, str))
Expected Output
payments\n1\nTrue
Hints

Hint 1: Each mixin passes *args and **kwargs through via super().__init__(*args, **kwargs), allowing cooperative MRO chaining.

Hint 2: Python MRO for Service is: Service → TimestampMixin → LogMixin → object.

Hint 3: Every __init__ in the chain eventually reaches object.__init__(), which accepts no extra args.


Hard

#9__init_subclass__ — Auto-Registering SubclassesHard
__init_subclass__plugin patternclass registry

Implement __init_subclass__ to auto-register every subclass of Plugin into a class-level registry. The plugin name can be provided at class definition time via a keyword argument.

Python
class Plugin:
    _registry = {}

    def __init_subclass__(cls, plugin_name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        key = plugin_name if plugin_name is not None else cls.__name__
        Plugin._registry[key] = cls

    @classmethod
    def get(cls, name):
        return cls._registry.get(name)

class CSVPlugin(Plugin, plugin_name="csv"):
    def run(self):
        return "CSV processing"

class JSONPlugin(Plugin, plugin_name="json"):
    def run(self):
        return "JSON processing"

class AutoNamed(Plugin):
    def run(self):
        return "auto"

print(Plugin.get("csv")().run())
print(Plugin.get("json")().run())
print(Plugin.get("AutoNamed")().run())
Solution
class Plugin:
_registry = {}

def __init_subclass__(cls, plugin_name=None, **kwargs):
super().__init_subclass__(**kwargs)
key = plugin_name if plugin_name is not None else cls.__name__
Plugin._registry[key] = cls

@classmethod
def get(cls, name):
return cls._registry.get(name)

Explanation: __init_subclass__ is a class-creation hook called on the base class whenever a new subclass is defined — not when instances are created. Keywords passed at class definition time (plugin_name="csv") are forwarded as arguments to __init_subclass__. This is the standard "plugin registry" pattern that replaces metaclasses for many use cases. Passing **kwargs through to super().__init_subclass__ keeps the chain cooperative so multiple inheritance works correctly.

class Plugin:
  _registry = {}

  def __init_subclass__(cls, plugin_name=None, **kwargs):
      super().__init_subclass__(**kwargs)
      # TODO: register cls under plugin_name (or cls.__name__ if not provided)
      pass

  @classmethod
  def get(cls, name):
      return cls._registry.get(name)

class CSVPlugin(Plugin, plugin_name="csv"):
  def run(self):
      return "CSV processing"

class JSONPlugin(Plugin, plugin_name="json"):
  def run(self):
      return "JSON processing"

class AutoNamed(Plugin):
  def run(self):
      return "auto"

print(Plugin.get("csv")().run())
print(Plugin.get("json")().run())
print(Plugin.get("AutoNamed")().run())
Expected Output
CSV processing\nJSON processing\nauto
Hints

Hint 1: __init_subclass__ is called on the base class whenever a subclass is defined.

Hint 2: The plugin_name keyword argument is passed at class definition time: class Foo(Plugin, plugin_name="foo").

Hint 3: If plugin_name is None, fall back to cls.__name__ for the registry key.


#10Lazy Initialisation with __init__ and PropertiesHard
lazy initpropertiescachingperformance

Implement the connection property so that the expensive _open_connection call is deferred until first access and then cached for all subsequent accesses.

Python
import time

class ExpensiveResource:
    def __init__(self, config):
        self.config = config
        self._connection = None

    @property
    def connection(self):
        if self._connection is None:
            self._connection = self._open_connection()
        return self._connection

    def _open_connection(self):
        time.sleep(0.01)
        return f"connection({self.config['host']})"

    def query(self, sql):
        return f"{self.connection} -> {sql}"

r = ExpensiveResource({"host": "db.local"})
print(r._connection is None)
print(r.query("SELECT 1"))
print(r.query("SELECT 2"))
Solution
class ExpensiveResource:
def __init__(self, config):
self.config = config
self._connection = None

@property
def connection(self):
if self._connection is None:
self._connection = self._open_connection()
return self._connection

def _open_connection(self):
# expensive I/O
return f"connection({self.config['host']})"

def query(self, sql):
return f"{self.connection} -> {sql}"

Explanation: Lazy initialisation is a form of deferred construction. The real object (_connection) is not created in __init__ but on first property access. The None sentinel lets the property distinguish "not yet opened" from a valid connection. Once opened, the same object is returned on every subsequent access without repeating the expensive call. This pattern is pervasive in ORMs (SQLAlchemy sessions), database connection pools, and any system where object creation has significant cost.

import time

class ExpensiveResource:
  def __init__(self, config):
      self.config = config
      self._connection = None   # not yet opened

  @property
  def connection(self):
      # TODO: open connection lazily on first access; cache it
      pass

  def _open_connection(self):
      time.sleep(0.01)  # simulate I/O
      return f"connection({self.config['host']})"

  def query(self, sql):
      return f"{self.connection} -> {sql}"

r = ExpensiveResource({"host": "db.local"})
# connection not opened yet
print(r._connection is None)
# first access opens it
print(r.query("SELECT 1"))
# second access reuses cached connection
print(r.query("SELECT 2"))
Expected Output
True\nconnection(db.local) -> SELECT 1\nconnection(db.local) -> SELECT 2
Hints

Hint 1: In the property, check if self._connection is None. If so, call self._open_connection() and assign to self._connection.

Hint 2: Return self._connection at the end of the property. The cache means _open_connection is only called once.

Hint 3: This is the "lazy initialisation" pattern — defer expensive work until the value is actually needed.


#11Reconstructible Objects — __init__ and __reduce__Hard
__reduce__pickling__init__serialisation

Implement __reduce__ so that Matrix instances can be serialised with pickle and correctly reconstructed via __init__.

Python
import pickle

class Matrix:
    def __init__(self, rows):
        if not rows or not all(len(r) == len(rows[0]) for r in rows):
            raise ValueError("All rows must have equal length")
        self._rows = [list(r) for r in rows]
        self._shape = (len(rows), len(rows[0]))

    def __getitem__(self, idx):
        row, col = idx
        return self._rows[row][col]

    def __reduce__(self):
        return (self.__class__, (self._rows,))

    def __repr__(self):
        return f"Matrix({self._rows!r})"

m = Matrix([[1, 2], [3, 4]])
data = pickle.dumps(m)
m2 = pickle.loads(data)
print(m2[0, 0])
print(m2[1, 1])
print(repr(m2))
Solution
def __reduce__(self):
return (self.__class__, (self._rows,))

Explanation: Pickle needs to know how to serialise and deserialise objects. By default it tries __dict__-based serialisation, which fails if the class has __slots__, derived attributes, or private state. __reduce__ returns a (callable, args) tuple — pickle stores this tuple and, on load, calls callable(*args) to reconstruct the object. Returning (self.__class__, (self._rows,)) instructs pickle to call Matrix(self._rows) during unpickling, which goes through full validation in __init__. This ensures the reconstructed object is always valid and that all derived attributes (like _shape) are recomputed correctly.

import pickle

class Matrix:
  def __init__(self, rows):
      # rows is a list of lists
      if not rows or not all(len(r) == len(rows[0]) for r in rows):
          raise ValueError("All rows must have equal length")
      self._rows = [list(r) for r in rows]
      self._shape = (len(rows), len(rows[0]))

  def __getitem__(self, idx):
      row, col = idx
      return self._rows[row][col]

  def __reduce__(self):
      # TODO: return (callable, args) so pickle can reconstruct this object
      pass

  def __repr__(self):
      return f"Matrix({self._rows!r})"

m = Matrix([[1, 2], [3, 4]])
data = pickle.dumps(m)
m2 = pickle.loads(data)
print(m2[0, 0])
print(m2[1, 1])
print(repr(m2))
Expected Output
1\n4\nMatrix([[1, 2], [3, 4]])
Hints

Hint 1: __reduce__ should return a 2-tuple: (callable, args_tuple). Pickle calls callable(*args_tuple) to reconstruct.

Hint 2: Return (self.__class__, (self._rows,)) — this tells pickle to call Matrix(self._rows) on load.

Hint 3: Because Matrix.__init__ validates and copies rows, reconstruction via __init__ is safe and correct.

© 2026 EngineersOfAI. All rights reserved.