Skip to main content

__init__ and Object Construction - Two-Phase Creation at Engineering Depth

Reading time: ~30 minutes | Level: Intermediate → Engineering

Before reading further, predict every output of this program:

class Config:
def __init__(self, tags=[]):
self.tags = tags

a = Config()
b = Config()

a.tags.append("prod")
print(a.tags) # ?
print(b.tags) # ?
print(a.tags is b.tags) # ?

Most developers expect ["prod"], [], False.

The actual output is ["prod"], ["prod"], True.

a and b are separate instances. Yet they share the same tags list. Mutating one mutates the other. The bug is not in the instance - it is in the method signature. The default [] is evaluated once, at function definition time, and then reused for every call that does not pass tags explicitly.

This is the mutable default argument trap. It exists in regular functions too, but in __init__ it is especially dangerous because it silently corrupts instance state across every object you create.

Understanding exactly when and how Python builds objects - before a single line of __init__ runs - is what this lesson covers.

What You Will Learn

  • The two-phase object construction model: __new__ then __init__
  • What __new__ does, what __init__ does, and why they are separate
  • When you must override __new__ (singletons, immutable subclasses, flyweight pattern)
  • How super().__init__() threads through inheritance chains
  • The mutable default argument trap in full detail and its canonical fix
  • The __post_init__ pattern (preview of dataclasses)
  • Factory patterns via @classmethod - the idiomatic alternate-constructor approach

Prerequisites

  • Lesson 01: Classes and Objects - understanding class vs instance namespace, __dict__, attribute resolution
  • Understanding that Python functions are first-class objects
  • Comfortable with basic class syntax and self

Part 1 - Two-Phase Object Construction

__new__ Allocates, __init__ Initialises

When you call Dog("Rex"), Python does not jump straight into __init__. It runs two methods in sequence:

# Python's internal logic when you call Dog("Rex")

# Phase 1 - allocation
instance = Dog.__new__(Dog, "Rex")

# Phase 2 - initialisation (only if __new__ returned an instance of Dog)
if isinstance(instance, Dog):
Dog.__init__(instance, "Rex")

__new__ is a static method (Python handles this automatically - you do not need @staticmethod) that creates and returns the raw instance object. __init__ receives that already-created object as self and populates its attributes.

class Dog:
def __new__(cls, name):
print(f"__new__ called - cls={cls.__name__!r}, name={name!r}")
instance = super().__new__(cls) # allocate memory for the object
print(f" allocated id={id(instance)}")
return instance

def __init__(self, name):
print(f"__init__ called - self id={id(self)}, name={name!r}")
self.name = name

d = Dog("Rex")
print(d.name)

Output:

__new__ called - cls='Dog', name='Rex'
allocated id=140234567890
__init__ called - self id=140234567890, name='Rex'
Rex

The id values match exactly. __new__ created the object; __init__ received the same object to configure. The object exists before __init__ touches it.

The Contract Between __new__ and __init__

Python calls __init__ on the result of __new__ only if __new__ returns an instance of cls. If it returns something else, __init__ is skipped entirely:

class Gated:
def __new__(cls, value):
if value < 0:
return None # not an instance of Gated
return super().__new__(cls)

def __init__(self, value):
print("__init__ running")
self.value = value

g1 = Gated(10) # __init__ runs - value is an instance of Gated
g2 = Gated(-1) # __init__ is SKIPPED - __new__ returned None

print(g1) # <__main__.Gated object at 0x...>
print(g2) # None

This contract is the foundation for singleton patterns, object pools, and immutable type subclassing - all covered below.

__new__ Receives the Same Arguments as __init__

When you call MyClass(a, b), Python passes a and b to both __new__ and __init__. If your __new__ does not accept those arguments, you get a TypeError. The safest pattern when you only need __new__ for allocation and do not care about arguments is *args, **kwargs:

class Base:
def __new__(cls, *args, **kwargs):
print(f"__new__ args={args}, kwargs={kwargs}")
return super().__new__(cls)

def __init__(self, x, y, label="default"):
self.x = x
self.y = y
self.label = label

obj = Base(1, y=2, label="mine")
# __new__ args=(1,), kwargs={'y': 2, 'label': 'mine'}

In practice: if you are not customising __new__, do not define it. The default object.__new__(cls) handles allocation correctly for almost every use case. Define __new__ only when you need to control what gets returned.

:::warning When to Override __new__ __new__ is needed in only three situations:

  1. Subclassing immutable types (int, str, tuple) - you must set the value inside __new__ because it is fixed before __init__ runs
  2. Singleton pattern - control whether a new instance is created at all
  3. Flyweight / object pool - return a cached instance instead of allocating a new one

For anything else, do not define __new__. Overriding it unnecessarily adds complexity and is a common source of bugs, especially in inheritance chains. :::

Part 2 - When You Actually Need __new__

Case 1 - Singleton Pattern

A singleton ensures that only one instance of a class ever exists:

class AppConfig:
_instance = None

def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __init__(self):
# Caution: __init__ runs on EVERY call to AppConfig(),
# even when returning the existing instance.
# Guard against re-initialisation:
if hasattr(self, "_ready"):
return
self._ready = True
self.debug = False
self.max_connections = 100

c1 = AppConfig()
c2 = AppConfig()

print(c1 is c2) # True - same object returned both times
c1.debug = True
print(c2.debug) # True - same object, same state

The _ready guard prevents __init__ from wiping the state on the second call. Without it, c1.debug = True followed by AppConfig() would reset debug to False.

Production note: in multithreaded applications, this naive singleton has a race condition between the None check and the assignment. Use a threading.Lock, or exploit the fact that Python modules are singletons by nature - module-level instances are initialised once by the import system.

Case 2 - Subclassing Immutable Types

Python's built-in immutable types (int, str, tuple, bytes, frozenset) are fully created inside __new__. By the time __init__ runs, their value is already fixed and cannot be changed. To customise the value of an immutable subclass, you must intercept __new__:

class AlwaysPositive(int):
def __new__(cls, value):
return super().__new__(cls, abs(value))
# abs(value) is the value stored in the int - cannot change it later

n = AlwaysPositive(-42)
print(n) # 42
print(type(n)) # <class '__main__.AlwaysPositive'>
print(n + 8) # 50 - arithmetic still works, int methods still work

The same pattern for str:

class SlugStr(str):
"""A string guaranteed to be lowercase with spaces replaced by hyphens."""
def __new__(cls, value):
slug = value.strip().lower().replace(" ", "-")
return super().__new__(cls, slug)

s = SlugStr(" Hello World ")
print(s) # hello-world
print(type(s)) # <class '__main__.SlugStr'>
print(len(s)) # 11
print(s.upper()) # HELLO-WORLD - str.upper() returns a plain str, not SlugStr

You cannot achieve this with __init__ alone. By the time __init__ runs, the str value is locked in.

Case 3 - Flyweight / Object Pool Pattern

class Color:
"""Flyweight - reuse existing instances for the same RGB triple."""
_pool: dict = {}

def __new__(cls, r: int, g: int, b: int):
key = (r, g, b)
if key not in cls._pool:
instance = super().__new__(cls)
cls._pool[key] = instance
return cls._pool[key]

def __init__(self, r: int, g: int, b: int):
if hasattr(self, "_ready"):
return # already initialised - do not overwrite
self._ready = True
self.r = r
self.g = g
self.b = b

def __repr__(self):
return f"Color(r={self.r}, g={self.g}, b={self.b})"

red1 = Color(255, 0, 0)
red2 = Color(255, 0, 0)
blue = Color(0, 0, 255)

print(red1 is red2) # True - reused from pool
print(red1 is blue) # False
print(len(Color._pool)) # 2

This pattern is used in rendering engines and symbol tables where the same conceptual value is referenced thousands of times. Rather than allocating a new object each time, the pool returns the existing one.

Part 3 - __init__ in Depth

What __init__ Is and Is Not

__init__ is not a constructor. It is an initialiser. The object already exists when __init__ runs - its identity (memory address) is already set. __init__ must return None. If it returns anything else, Python raises TypeError at construction time:

class Bad:
def __init__(self):
return 42 # TypeError at call time

obj = Bad()
# TypeError: __init__() should return None (not 'int')

__init__'s single responsibility is to populate the instance with its initial state - validating inputs and assigning to self.

A Well-Structured __init__

import re
from typing import Optional

class EmailAddress:
"""A validated, normalised email address.

Raises ValueError on construction if the format is invalid.
All addresses are stored lowercase and stripped of whitespace.
"""

_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

def __init__(self, address: str, label: Optional[str] = None):
# Step 1: normalise before validation
address = address.strip().lower()

# Step 2: validate - raise before assigning anything
if not self._PATTERN.match(address):
raise ValueError(f"Invalid email address: {address!r}")

# Step 3: assign (only reached if valid)
self.address = address
self.label = label or address # fallback label
self._domain = address.split("@")[1] # cached derived value

@property
def domain(self) -> str:
return self._domain

def __repr__(self) -> str:
return f"EmailAddress({self.address!r}, label={self.label!r})"

# Successful construction
e = EmailAddress(" [email protected] ", label="Work")
print(e.address) # [email protected]
print(e.domain) # example.com
print(repr(e)) # EmailAddress('[email protected]', label='Work')

# Failed construction - no partial object is left
try:
bad = EmailAddress("not-an-email")
except ValueError as exc:
print(exc) # Invalid email address: 'not-an-email'

Three-step pattern for __init__:

  1. Normalise inputs
  2. Validate (raise on failure before touching self)
  3. Assign to self

If validation fails mid-construction, the exception propagates and the caller never gets a reference to the object. There is no partial state to clean up.

super().__init__() - Threading Through Inheritance Chains

When you inherit from another class, that class's __init__ must also run. Skipping it leaves the parent's state uninitialised:

class Animal:
def __init__(self, name: str, sound: str):
print(f" Animal.__init__({name!r})")
self.name = name
self.sound = sound
self.alive = True

class Dog(Animal):
def __init__(self, name: str, breed: str):
print(f" Dog.__init__({name!r})")
super().__init__(name, sound="Woof") # Animal runs here
self.breed = breed

class GuideDog(Dog):
def __init__(self, name: str, breed: str, owner: str):
print(f" GuideDog.__init__({name!r})")
super().__init__(name, breed) # Dog (then Animal) runs here
self.owner = owner

rex = GuideDog("Rex", "Labrador", "Alice")
print(rex.name, rex.sound, rex.breed, rex.owner, rex.alive)

Output:

GuideDog.__init__('Rex')
Dog.__init__('Rex')
Animal.__init__('Rex')
Rex Woof Labrador Alice True

super() follows the Method Resolution Order (MRO), not simply "the direct parent". In multiple inheritance this matters critically - super() ensures each class in the MRO chain is called once and in the right order. Always use super().__init__() rather than ParentClass.__init__(self, ...).

Cooperative super().__init__() with Mixins

The mixin pattern requires super().__init__() to be cooperative - each class must pass remaining keyword arguments up the chain:

from datetime import datetime, timezone

class TimestampMixin:
def __init__(self, **kwargs):
super().__init__(**kwargs) # pass remaining kwargs upward
self.created_at = datetime.now(timezone.utc)
self.updated_at = self.created_at

class AuditMixin:
def __init__(self, created_by: str = "system", **kwargs):
super().__init__(**kwargs)
self.created_by = created_by

class Record:
def __init__(self, name: str, value: float):
self.name = name
self.value = value

class AuditedRecord(TimestampMixin, AuditMixin, Record):
pass

# MRO: AuditedRecord -> TimestampMixin -> AuditMixin -> Record -> object
r = AuditedRecord(name="temperature", value=98.6, created_by="sensor_01")

print(r.name) # temperature
print(r.value) # 98.6
print(r.created_by) # sensor_01
print(r.created_at) # datetime(...)

The **kwargs pattern ensures that each mixin extracts its own arguments and passes the rest forward. Without cooperative super().__init__(), Record.__init__ would never run and name/value would be missing. This pattern is the backbone of Django's class-based views.

Part 4 - The Mutable Default Argument Trap

Why the Trap Exists

Python evaluates default argument values at function definition time, not at call time. The default object is created once and stored in the function object's __defaults__ tuple:

def add_tag(name, tags=[]):
tags.append(name)
return tags

# Inspect the default
print(add_tag.__defaults__) # ([],)
print(id(add_tag.__defaults__[0])) # e.g. 4398765432

result1 = add_tag("prod")
print(result1) # ['prod']
print(id(add_tag.__defaults__[0])) # same id - same list object, now mutated

result2 = add_tag("staging")
print(result2) # ['prod', 'staging'] - accumulated!

The list stored in __defaults__ is mutated on every call. You are always appending to the same object.

:::danger Mutable Default Arguments in __init__ - A Silent State Corruption Bug This is one of the most frequently seen bugs in Python OOP. The default [] is not created fresh for each instance - it is a single list object shared across every call.

# WRONG - all instances that use the default share the exact same list
class Config:
def __init__(self, tags=[]):
self.tags = tags

a = Config()
b = Config()

a.tags.append("prod")
print(b.tags) # ["prod"] - b is contaminated by a's mutation!
print(Config.__init__.__defaults__) # (['prod'],) - the default itself is mutated

This bites you hardest in production when instances are created far apart in time or in different call paths - making the corruption very hard to trace. :::

In __init__ This Corrupts Instance State

# WRONG - all instances that use the default share the exact same list
class Config:
def __init__(self, tags=[]):
self.tags = tags # self.tags IS the shared default list

a = Config()
b = Config()

print(a.tags is b.tags) # True - same object

a.tags.append("prod")
print(b.tags) # ['prod'] - b is contaminated
print(Config.__init__.__defaults__) # (['prod'],) - the default is mutated

The Canonical Fix - Use None as a Sentinel

# RIGHT - None as sentinel, create fresh container in the body
class Config:
def __init__(self, tags=None):
self.tags = list(tags) if tags is not None else []

a = Config()
b = Config()

print(a.tags is b.tags) # False - each gets their own empty list

a.tags.append("prod")
print(b.tags) # [] - unaffected

:::tip Use None as Sentinel for Any Mutable Default The canonical pattern for any mutable default - list, dict, or set - is to use None as a sentinel and create the container inside the method body.

class Config:
def __init__(self, tags=None, options=None, filters=None):
self.tags = list(tags) if tags is not None else []
self.options = dict(options) if options is not None else {}
self.filters = set(filters) if filters is not None else set()

The list(tags) copy also protects against the caller's list being mutated through self.tags - always copy mutable inputs unless you explicitly want a shared reference. :::

The list(tags) copy is also important when the caller passes a list. Without the copy, mutating self.tags would mutate the caller's list:

caller_tags = ["alpha", "beta"]

# Without copy:
c = Config.__new__(Config)
Config.__init__(c, caller_tags)
# If we did self.tags = tags (no copy), then:
c.tags.append("gamma")
print(caller_tags) # ['alpha', 'beta', 'gamma'] - caller's data corrupted

# With list(tags):
d = Config(caller_tags)
d.tags.append("gamma")
print(caller_tags) # ['alpha', 'beta'] - caller's data safe

Applies to All Mutable Types

# WRONG - dict default
class Router:
def __init__(self, routes={}):
self.routes = routes

# RIGHT
class Router:
def __init__(self, routes=None):
self.routes = dict(routes) if routes is not None else {}

# WRONG - set default
class TagSet:
def __init__(self, tags=set()):
self.tags = tags

# RIGHT
class TagSet:
def __init__(self, tags=None):
self.tags = set(tags) if tags is not None else set()

Immutable types are safe as defaults: int, str, tuple, frozenset, None, bool. They cannot be mutated in place, so sharing a default instance is harmless.

Part 5 - __post_init__ Preview

Python 3.7's @dataclass auto-generates __init__ from field annotations. For validation or derived-field logic that must run after the generated __init__, use __post_init__:

from dataclasses import dataclass, field
from typing import List

@dataclass
class Order:
product: str
quantity: int
unit_price: float
# field(default_factory=list) solves the mutable default problem
# a fresh list is created for each instance
tags: List[str] = field(default_factory=list)
# init=False means this field is not a constructor parameter
total: float = field(init=False)

def __post_init__(self):
# runs after the auto-generated __init__ sets all fields
if self.quantity <= 0:
raise ValueError(f"quantity must be positive, got {self.quantity}")
if self.unit_price < 0:
raise ValueError(f"unit_price cannot be negative, got {self.unit_price}")
# derived field - computed from other fields
self.total = round(self.quantity * self.unit_price, 2)

o1 = Order("Widget", 3, 9.99)
print(o1.total) # 29.97
print(o1.tags) # []

o2 = Order("Gadget", 2, 4.99, tags=["sale", "clearance"])
print(o2.total) # 9.98
print(o2.tags) # ['sale', 'clearance']

# Validation fires
try:
o3 = Order("Bad", 0, 5.0)
except ValueError as e:
print(e) # quantity must be positive, got 0

field(default_factory=list) is the dataclass-native equivalent of the None sentinel pattern - the factory callable is invoked once per instance. This is covered in full in the Dataclasses lesson. The key insight here: two-phase thinking still applies - the decorator's __init__ runs first, then __post_init__ for your custom logic.

Part 6 - Factory Patterns via @classmethod

Why __init__ Cannot Be Overloaded

Python does not support method overloading. A class has exactly one __init__. Writing multiple definitions does not create overloads - the last definition silently replaces all earlier ones:

class Date:
def __init__(self, year, month, day): ...
def __init__(self, iso_string): ... # replaces the first
def __init__(self, timestamp): ... # replaces the second

# Only this third __init__ exists

The idiomatic Python solution: @classmethod factory methods. These are alternate constructors - each accepts different input forms, parses or converts them, and calls the primary __init__.

The @classmethod Factory Pattern

from datetime import datetime, timezone

class Date:
def __init__(self, year: int, month: int, day: int):
if not (1 <= month <= 12):
raise ValueError(f"month must be 1–12, got {month}")
if not (1 <= day <= 31):
raise ValueError(f"day must be 1–31, got {day}")
self.year = year
self.month = month
self.day = day

@classmethod
def from_iso(cls, iso_str: str) -> "Date":
"""Construct from ISO-8601 string: '2025-07-04'."""
try:
parts = iso_str.split("-")
return cls(int(parts[0]), int(parts[1]), int(parts[2]))
except (IndexError, ValueError):
raise ValueError(f"Expected YYYY-MM-DD, got {iso_str!r}")

@classmethod
def from_datetime(cls, dt: datetime) -> "Date":
"""Construct from a datetime object."""
return cls(dt.year, dt.month, dt.day)

@classmethod
def today(cls) -> "Date":
"""Construct for today's UTC date."""
now = datetime.now(timezone.utc)
return cls(now.year, now.month, now.day)

@classmethod
def from_ordinal(cls, n: int) -> "Date":
"""Construct from Python's date ordinal (days since year 1, Jan 1)."""
from datetime import date
d = date.fromordinal(n)
return cls(d.year, d.month, d.day)

def __repr__(self) -> str:
return f"Date({self.year}, {self.month}, {self.day})"

def __str__(self) -> str:
return f"{self.year:04d}-{self.month:02d}-{self.day:02d}"

d1 = Date(2025, 7, 4)
d2 = Date.from_iso("2025-07-04")
d3 = Date.from_datetime(datetime(2025, 7, 4, 12, 0, 0))
d4 = Date.today()

print(d1) # 2025-07-04
print(d1 == d2) # (requires __eq__ - covered in Lesson 03)

The Critical Detail: Use cls, Not the Class Name

The cls parameter is what makes factory classmethods work correctly with subclassing:

class USDate(Date):
def __str__(self):
return f"{self.month:02d}/{self.day:02d}/{self.year:04d}"

# cls will be USDate, not Date
us = USDate.from_iso("2025-07-04")
print(us) # 07/04/2025 - USDate's __str__
print(type(us)) # <class '__main__.USDate'>

If from_iso had written return Date(...) instead of return cls(...), calling USDate.from_iso() would silently return a Date object - wrong type, wrong behaviour, no error.

This is the same reason __new__ receives cls rather than working with the class name directly.

Real-World Factory Pattern: Multiple Data Sources

import csv
import io
import json
from typing import List

class Measurement:
def __init__(self, name: str, value: float, unit: str = ""):
self.name = name
self.value = float(value)
self.unit = unit

@classmethod
def from_dict(cls, data: dict) -> "Measurement":
return cls(
name=str(data["name"]),
value=float(data["value"]),
unit=str(data.get("unit", ""))
)

@classmethod
def from_json(cls, json_str: str) -> "Measurement":
data = json.loads(json_str)
return cls.from_dict(data)

@classmethod
def from_csv_row(cls, row: list) -> "Measurement":
if len(row) < 2:
raise ValueError(f"Need at least 2 columns, got: {row}")
unit = row[2].strip() if len(row) > 2 else ""
return cls(row[0].strip(), float(row[1].strip()), unit)

@classmethod
def batch_from_csv(cls, csv_text: str) -> List["Measurement"]:
reader = csv.reader(io.StringIO(csv_text.strip()))
return [cls.from_csv_row(row) for row in reader if row]

def __repr__(self):
return f"Measurement({self.name!r}, {self.value}, {self.unit!r})"

# All three produce proper Measurement instances
m1 = Measurement("temp", 98.6, "°F")
m2 = Measurement.from_json('{"name": "pressure", "value": 101.3, "unit": "kPa"}')
m3 = Measurement.from_dict({"name": "humidity", "value": "65", "unit": "%"})

csv_data = """
wind_speed,12,km/h
visibility,10,km
uv_index,7,
"""
batch = Measurement.batch_from_csv(csv_data)

print(m1) # Measurement('temp', 98.6, '°F')
print(m2) # Measurement('pressure', 101.3, 'kPa')
print(batch) # [Measurement('wind_speed', 12.0, 'km/h'), ...]

This pattern mirrors exactly how datetime.fromisoformat(), datetime.fromtimestamp(), pathlib.Path.home(), and dict.fromkeys() work in the standard library.

Part 7 - Inspecting Construction at Runtime

When debugging, Python exposes construction mechanics through the function and object model:

class Config:
def __init__(self, name: str, tags=None, max_size: int = 100):
self.name = name
self.tags = list(tags) if tags is not None else []
self.max_size = max_size

# Inspect default arguments
print(Config.__init__.__defaults__)
# (None, 100)

# Inspect parameter names
code = Config.__init__.__code__
argcount = code.co_argcount
varnames = code.co_varnames[:argcount]
print(varnames)
# ('self', 'name', 'tags', 'max_size')

# Inspect instance state after construction
c = Config("prod", tags=["web", "api"])
print(c.__dict__)
# {'name': 'prod', 'tags': ['web', 'api'], 'max_size': 100}

# Check what class built this instance
print(type(c)) # <class '__main__.Config'>
print(c.__class__.__name__) # Config

In production debugging, obj.__dict__ tells you exactly what state was set. If you see an unexpected key, trace back to the __init__ call. If you see unexpected absence of a key, the __init__ either was not called or raised an exception before reaching that assignment.

Common Mistakes

Mistake 1 - Mutable Default Argument

# Wrong - tags list is shared across all instances that use the default
class Task:
def __init__(self, name, dependencies=[]):
self.dependencies = dependencies

# Right
class Task:
def __init__(self, name, dependencies=None):
self.dependencies = list(dependencies) if dependencies is not None else []

Mistake 2 - Returning a Value from __init__

# Wrong - TypeError at construction time
class Validator:
def __init__(self, value):
if value < 0:
return False # TypeError: __init__() should return None

# Right - raise to signal invalid state
class Validator:
def __init__(self, value):
if value < 0:
raise ValueError(f"value must be non-negative, got {value}")
self.value = value

Mistake 3 - Forgetting super().__init__() in Inheritance

# Wrong - Animal.__init__ never runs, .name and .alive are missing
class Animal:
def __init__(self, name):
self.name = name
self.alive = True

class Dog(Animal):
def __init__(self, name, breed):
self.breed = breed
# MISSING: super().__init__(name)

d = Dog("Rex", "Lab")
print(d.breed) # Lab
print(d.name) # AttributeError: 'Dog' object has no attribute 'name'

# Right
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # Animal.__init__ sets .name and .alive
self.breed = breed

Mistake 4 - Hard-Coding the Class Name in a Classmethod

# Wrong - USDate.from_iso() returns a Date object, not a USDate
class Date:
@classmethod
def from_iso(cls, s):
y, m, d = s.split("-")
return Date(int(y), int(m), int(d)) # hard-coded

# Right - use cls so subclasses get the correct type
class Date:
@classmethod
def from_iso(cls, s):
y, m, d = s.split("-")
return cls(int(y), int(m), int(d))

Mistake 5 - Heavy I/O in __init__

# Problematic - network call blocks construction, makes unit testing painful
class UserProfile:
def __init__(self, user_id):
self.user_id = user_id
self.data = fetch_from_database(user_id) # blocking call in constructor

# Better - separate construction from data loading
class UserProfile:
def __init__(self, user_id):
self.user_id = user_id
self.data = None

@classmethod
def load(cls, user_id) -> "UserProfile":
"""Named factory that makes the I/O call explicit."""
profile = cls(user_id)
profile.data = fetch_from_database(user_id)
return profile

def refresh(self) -> None:
self.data = fetch_from_database(self.user_id)

The factory pattern makes I/O boundaries visible at the call site, which is critical for testability - you can construct a UserProfile in tests without hitting the database.

Engineering Checklist

Before moving to the next lesson, verify you can answer these without looking:

  1. In what order do __new__ and __init__ run? What does each one do?
  2. Under what condition does Python skip calling __init__ after __new__?
  3. Why must you override __new__ (not __init__) to customise the value of an int or str subclass?
  4. What is stored in a function's __defaults__ tuple, and when is it evaluated?
  5. Why does def __init__(self, items=[]) create a shared-state bug?
  6. What is the canonical fix for the mutable default argument trap?
  7. What does super().__init__() do, and what happens if you omit it?
  8. Why must @classmethod factory methods use cls(...) instead of the literal class name?
  9. What does __init__ return, and what happens if you return anything other than None?

Quick Reference

# Two-phase construction - rarely override __new__
class MyClass:
def __new__(cls, *args, **kwargs):
instance = super().__new__(cls)
return instance

def __init__(self, name, tags=None):
self.name = name
self.tags = list(tags) if tags is not None else [] # mutable default fix

# Inheritance - always call super().__init__()
class Child(Parent):
def __init__(self, x, y):
super().__init__(x) # parent initialises first
self.y = y

# Factory classmethod - alternate constructors
class Record:
def __init__(self, name: str, value: float):
self.name = name
self.value = value

@classmethod
def from_string(cls, s: str) -> "Record":
name, _, value = s.partition("=")
return cls(name.strip(), float(value.strip())) # cls, not Record

r = Record.from_string("temperature = 98.6")
print(r.name, r.value) # temperature 98.6

# Subclassing immutable types - must use __new__
class PositiveInt(int):
def __new__(cls, value):
if value <= 0:
raise ValueError(f"must be positive, got {value}")
return super().__new__(cls, value)

Graded Practice Challenges

Level 1 - Predict the Output

Question 1: What does this print?

class Config:
def __init__(self, tags=[]):
self.tags = tags

a = Config()
b = Config()
a.tags.append("prod")

print(a.tags)
print(b.tags)
print(a.tags is b.tags)
Show Answer

Output:

['prod']
['prod']
True

The default [] is a single list object created once at function definition time. Both a.tags and b.tags are bound to that same list. Appending through a.tags mutates the shared object, which is visible through b.tags.

Question 2: What does this print?

class Dog:
def __new__(cls, name):
print(f"new: {name}")
return super().__new__(cls)

def __init__(self, name):
print(f"init: {name}")
self.name = name

d = Dog("Rex")
Show Answer

Output:

new: Rex
init: Rex

__new__ always runs first (Phase 1: allocation), then __init__ (Phase 2: initialisation). Both receive the same arguments that were passed to Dog(...).

Question 3: What does this print?

class Singleton:
_instance = None

def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

a = Singleton()
b = Singleton()
print(a is b)
print(id(a) == id(b))
Show Answer

Output:

True
True

The singleton pattern intercepts __new__ to return the same cached instance on every call. Both a and b are the exact same object in memory, so both is and id() comparison confirm identity.

Question 4: What does this print?

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

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

d = Dog("Rex", "Labrador")
print(d.breed)
print(hasattr(d, "name"))
Show Answer

Output:

Labrador
False

Dog.__init__ never calls super().__init__(name), so Animal.__init__ never runs. The name and alive attributes are never set on d. hasattr(d, "name") returns False because the attribute simply does not exist.

Question 5: What does this print?

class AlwaysPositive(int):
def __new__(cls, value):
return super().__new__(cls, abs(value))

n = AlwaysPositive(-42)
print(n)
print(type(n))
print(n + 8)
Show Answer

Output:

42
<class '__main__.AlwaysPositive'>
50

int is immutable - its value is set inside __new__ and cannot be changed afterward. By passing abs(value) to super().__new__(), we store 42 as the integer value. The result is still an AlwaysPositive instance (a subclass of int), so arithmetic works normally.

Level 2 - Debug Challenge

Find and fix all bugs:

class Task:
def __init__(self, name, tags=[], priority=1):
self.name = name
self.tags = tags
self.priority = priority
return self.name # signal that construction succeeded

@classmethod
def from_string(cls, s):
parts = s.split(":")
name = parts[0]
priority = int(parts[1])
return Task(name, priority=priority) # returns Task, not cls

class UrgentTask(Task):
def __init__(self, name, tags=[], deadline=None):
self.deadline = deadline
# missing parent init call

t1 = Task("deploy")
t2 = Task("test")
t1.tags.append("prod")

print(t2.tags) # expected: []
Show Solution

Bugs found:

  1. tags=[] - mutable default; all instances share the same list
  2. return self.name - __init__ must return None; this raises TypeError at construction time
  3. return Task(name, priority=priority) in from_string - hard-codes Task instead of cls; breaks subclass factories
  4. UrgentTask.__init__ never calls super().__init__() - Task.__init__ never runs, so name, tags, priority are never set

Fixed version:

class Task:
def __init__(self, name, tags=None, priority=1):
self.name = name
self.tags = list(tags) if tags is not None else []
self.priority = priority
# No return statement - __init__ must return None

@classmethod
def from_string(cls, s):
parts = s.split(":")
name = parts[0]
priority = int(parts[1])
return cls(name, priority=priority) # use cls, not Task

class UrgentTask(Task):
def __init__(self, name, tags=None, deadline=None):
super().__init__(name, tags=tags) # call parent first
self.deadline = deadline

t1 = Task("deploy")
t2 = Task("test")
t1.tags.append("prod")

print(t2.tags) # [] - unaffected, each instance has its own list

Level 3 - Design Challenge

Design a Connection class that:

  1. Uses a factory classmethod from_url(url) to parse a connection string like "postgres://user:pass@host:5432/dbname"
  2. Validates in __init__ that the port is between 1 and 65535 (raise ValueError otherwise)
  3. Avoids the mutable default argument trap for an optional options dict parameter
  4. Supports subclassing: PostgresConnection.from_url(url) must return a PostgresConnection, not a Connection
Show Reference Solution
from urllib.parse import urlparse

class Connection:
def __init__(
self,
host: str,
port: int,
user: str,
password: str,
database: str,
options: dict = None,
):
# Validate port range before any assignment
if not (1 <= port <= 65535):
raise ValueError(f"port must be 1-65535, got {port}")

self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
# Mutable default fix: copy the caller's dict, or create fresh one
self.options = dict(options) if options is not None else {}

@classmethod
def from_url(cls, url: str) -> "Connection":
"""Parse a connection URL and return an instance of cls.

Supports: scheme://user:password@host:port/database
Example: postgres://admin:[email protected]:5432/mydb
"""
parsed = urlparse(url)
return cls(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username or "",
password=parsed.password or "",
database=parsed.path.lstrip("/"),
)

def __repr__(self) -> str:
return (
f"{type(self).__name__}("
f"host={self.host!r}, port={self.port}, "
f"database={self.database!r})"
)


class PostgresConnection(Connection):
"""A PostgreSQL-specific connection with extra capabilities."""

def __init__(self, *args, schema: str = "public", **kwargs):
super().__init__(*args, **kwargs)
self.schema = schema

def __repr__(self) -> str:
return (
f"PostgresConnection("
f"host={self.host!r}, port={self.port}, "
f"database={self.database!r}, schema={self.schema!r})"
)


# Demonstrate all requirements
url = "postgres://admin:[email protected]:5432/mydb"

c1 = Connection.from_url(url)
print(c1) # Connection(host='db.example.com', port=5432, database='mydb')
print(type(c1)) # <class '__main__.Connection'>

# Requirement 4: from_url on subclass returns subclass type
pg = PostgresConnection.from_url(url)
print(pg) # PostgresConnection(host='db.example.com', port=5432, ...)
print(type(pg)) # <class '__main__.PostgresConnection'>

# Requirement 2: invalid port raises ValueError
try:
bad = Connection("host", 99999, "user", "pass", "db")
except ValueError as e:
print(e) # port must be 1-65535, got 99999

# Requirement 3: independent options dicts
c2 = Connection("localhost", 5432, "u", "p", "db", options={"timeout": 30})
c3 = Connection("localhost", 5432, "u", "p", "db")
c2.options["timeout"] = 60
print(c3.options) # {} - unaffected

Key Takeaways

  • Python object construction is two-phase: __new__ allocates the bare instance, __init__ populates its attributes
  • __init__ must return None - it is an initialiser, not a constructor
  • Override __new__ only for: subclassing immutable types, singleton patterns, or flyweight/pool patterns
  • Mutable default arguments ([], {}, set()) are evaluated once at function definition time - they are shared across all calls that omit the argument
  • The canonical fix: use None as the sentinel value and create fresh containers inside the method body
  • Always call super().__init__() in subclasses - skipping it leaves parent state uninitialised
  • @classmethod factory methods are the idiomatic solution to Python's single-__init__ constraint - use cls(...), never the hardcoded class name
  • field(default_factory=list) in dataclasses is the equivalent of the None sentinel pattern

What's Next

Lesson 03 covers dunder methods - Python's entire protocol system. You have already seen __init__ and __new__. The protocol extends to comparison operators (__eq__, __lt__, __hash__), arithmetic (__add__, __radd__, __iadd__), container protocols (__len__, __getitem__, __iter__, __contains__), context managers (__enter__, __exit__), callable objects (__call__), and attribute access hooks (__getattr__, __setattr__).

Understanding dunders means understanding how Python's syntax maps to method calls - which is how you write objects that behave like built-in types, integrate naturally with with statements, sort with sorted(), iterate with for, and power every production framework you will work with.

© 2026 EngineersOfAI. All rights reserved.