The init Constructor in Python

The previous article introduced __init__() alongside self as the basics of defining a class — this one goes deeper specifically into python init constructor behavior: the precise relationship between __init__ and __new__, default values, the mutable-default trap applied to constructors, and how super().__init__() fits into inheritance.

Introduction: What init Does

__init__() is the method Python automatically calls immediately after a new object is created, and it's where you typically set up that object's starting attributes.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

alex = Person("Alex", 30)
print(alex.name, alex.age)   # Alex 30
"Constructor" vs. the more precise term

You'll commonly hear __init__ casually called "the constructor" — and for everyday purposes, that shorthand is harmless and universally understood. But it's worth knowing the more precise term: __init__ is technically the initializer. By the time __init__ runs, the object already exists — its job is to set up (initialize) that already-existing object's starting state, not to bring it into existence in the first place. Section 3 covers exactly which method actually handles creation.

Default vs. Parameterized Constructors

A "default" constructor

If a class's __init__ takes no arguments beyond self, every object gets set up identically, with fixed starting values:

class Counter:
    def __init__(self):
        self.count = 0   # every Counter starts at exactly 0, no way to customize this at creation

c1 = Counter()
c2 = Counter()
print(c1.count, c2.count)   # 0 0
A "parameterized" constructor

More commonly, you'll want __init__ to accept arguments, letting each object start with different data:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

alex = Person("Alex", 30)
sam = Person("Sam", 25)

This is the far more typical pattern in real code — a class whose objects genuinely differ from one another in their starting data.

If you don't define init at all

If a class doesn't define __init__ whatsoever, Python still provides an implicit, empty default behind the scenes, allowing you to create a bare object with no custom setup:

class Empty:
    pass

e = Empty()
print(e)   # <__main__.Empty object at 0x...> — a valid object, just with no custom attributes set

This is rarely useful on its own, but it's worth knowing that "no __init__ defined" doesn't cause any error — it just means object creation skips any custom initialization step entirely.

init vs. new: Who Actually Creates the Object?

The real two-step process

When you write Person("Alex", 30), two separate steps actually happen behind the scenes, in this order:

  1. __new__ allocates and returns a new, empty instance of the class.

  2. __init__ then receives that already-created instance (as self) and populates it with starting data.

class Person:
    def __new__(cls, *args, **kwargs):
        print("Step 1: __new__ creates the instance")
        instance = super().__new__(cls)
        return instance

    def __init__(self, name, age):
        print("Step 2: __init__ sets up the instance")
        self.name = name
        self.age = age

alex = Person("Alex", 30)
# Step 1: __new__ creates the instance
# Step 2: __init__ sets up the instance

This confirms the order directly: __new__ genuinely runs first, and only once it has successfully returned an actual instance does __init__ get called on that instance.

Why self proves the object already exists

This is the clearest evidence that __init__ isn't doing the actual creation: it receives self as its very first argument — a reference to an object that must already exist in order to be passed in at all. You can't hand someone a reference to something that doesn't exist yet. Consistent with this, __init__ never returns anything meaningful — if you try to return a value from it, Python raises an error, since __init__ is only ever expected to modify the object it was given, not to produce or return a new one (covered further in Section 5).

When new actually matters

For the overwhelming majority of classes you'll write, you'll never need to touch __new__ at all — __init__ covers everything you need. __new__ becomes genuinely relevant in a handful of more advanced, specialized situations:

  • Subclassing immutable built-in types (like str, int, or tuple) — since immutable objects can't be modified after creation, any customization has to happen at the __new__ stage, before the object is finalized.

  • Implementing the singleton pattern — ensuring a class only ever has exactly one instance, which requires intercepting object creation itself, not just its subsequent setup.

  • Metaclasses — a genuinely advanced topic controlling how classes themselves (not just instances) get created, well beyond the scope of this article.

Outside of these specific cases, you can safely treat __init__ as "the constructor" in everyday practice, and never think about __new__ again.

Working with init in Practice

Default parameter values

Just like regular functions (covered in the earlier function arguments article), __init__ parameters can have default values, making them optional at creation time:

class Dog:
    def __init__(self, name, breed="Mixed", age=1):
        self.name = name
        self.breed = breed
        self.age = age

rex = Dog("Rex")
print(rex.breed, rex.age)   # Mixed 1

buddy = Dog("Buddy", "Labrador", 3)
print(buddy.breed, buddy.age)   # Labrador 3

rex only supplied a name, letting breed and age fall back to their defaults; buddy explicitly overrode both.

The mutable-default-argument pitfall, applied to constructors

This is the exact same trap covered in the earlier function arguments and classes articles, worth restating specifically in the constructor context, since it's an extremely common place for it to show up:

class ShoppingCart:
    def __init__(self, items=[]):   # DANGER — a mutable default argument
        self.items = items

cart1 = ShoppingCart()
cart2 = ShoppingCart()

cart1.items.append("apple")
print(cart2.items)   # ['apple'] — cart2 is unexpectedly affected!

Because the default list [] is created exactly once, at the moment __init__ is defined (not each time it's called), every ShoppingCart created without explicitly passing items ends up sharing that exact same list object. The fix, exactly as covered before, is defaulting to None and creating a fresh list inside the method body:

class ShoppingCart:
    def __init__(self, items=None):
        self.items = items if items is not None else []

cart1 = ShoppingCart()
cart2 = ShoppingCart()

cart1.items.append("apple")
print(cart2.items)   # [] — correctly independent now
super().init() in a subclass

When one class inherits from another (covered in full in a later article on inheritance), the subclass's __init__ typically needs to call the parent class's __init__ first, to make sure inherited attributes get properly set up before the subclass adds its own:

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

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)   # call Animal's __init__ first, to set self.name
        self.breed = breed        # then add Dog's own attribute

rex = Dog("Rex", "Labrador")
print(rex.name, rex.breed)   # Rex Labrador

super().__init__(name) calls Animal's __init__ method, passing self (implicitly) and name along to it — ensuring self.name gets set exactly the way Animal defines it, before Dog's own __init__ continues on to set self.breed.

Common Mistakes and Best Practices

Forgetting to call super().init()

If a subclass defines its own __init__ but forgets to call super().__init__(), any attributes the parent class was supposed to set up simply never get set — not through an obvious error, but through a silently missing attribute that only surfaces later, whenever something tries to actually use it:

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

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

rex = Dog("Rex", "Labrador")
print(rex.breed)   # Labrador — fine
print(rex.name)
# AttributeError: 'Dog' object has no attribute 'name'

This is a genuinely common mistake once you start working with inheritance, and it's worth making a habit of calling super().__init__(...) as the very first line of any subclass's __init__, unless you have a specific, deliberate reason not to.

Accidentally returning a value from init

__init__ must implicitly return None — Python enforces this directly, raising an error if you try to return any other value from it:

class Person:
    def __init__(self, name):
        self.name = name
        return "done"   # not allowed

alex = Person("Alex")
# TypeError: __init__() should return None, not 'str'

This reinforces the point made in Section 3: __init__'s job is purely to modify the object it was given, not to produce or hand back a separate value of any kind.

Best-practice guidance: keep init lightweight

As a general rule, keep __init__ focused specifically on setting attributes and doing genuinely lightweight setup work. If a class needs more involved initialization logic — validating complex input, performing an expensive calculation, or offering several different ways to construct an object depending on what data is available — that heavier logic generally belongs in dedicated methods, or in alternative constructors implemented as @classmethods (briefly introduced in the earlier decorators article, and covered further in a later article on class design):

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    @classmethod
    def margherita(cls):
        # a dedicated, named alternative way to construct a Pizza,
        # rather than cramming multiple construction paths into __init__ itself
        return cls(["tomato", "mozzarella"])

pizza = Pizza.margherita()
print(pizza.toppings)   # ['tomato', 'mozzarella']

Keeping __init__ itself simple and predictable — set attributes, maybe validate a value or two, and little else — makes objects easier to create correctly, and easier to reason about when something goes wrong.