Skillversitas

The __init__ Method

__init__ is the initializer - the method that runs automatically right after an object is created, giving it its starting state. It is the first method you will write in every non-trivial class, and it deserves its own chapter because of what it is not: it does not create the object (that is __new__), it only sets it up. Confusing the two, or treating __init__ as a place for validation that should live in a property, are the two design mistakes this chapter prevents.

Signature and State

class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner = owner
        self.balance = balance
        self.created = "today"      # state the caller does not supply

    def describe(self) -> str:
        return f"account for {self.owner}: {self.balance:.2f}"

acc = BankAccount("Ada")
print(acc.describe())     # account for Ada: 0.00
print(acc.balance)        # default parameter did the work
print(acc.created)        # set inside __init__, not passed in

The first parameter is self (the new instance - the next chapter covers why), and everything after it is the constructor's arguments. Default parameters make optional state free: balance=0.0 means BankAccount("Ada") and BankAccount("Ada", 100) are both valid. A convention worth adopting: derive state the caller should not guess (timestamps, counters, ids) inside __init__ rather than asking for it.

Validation: Where It Belongs

class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0):
        if not owner:
            raise ValueError("owner is required")
        if balance < 0:
            raise ValueError("balance cannot be negative")
        self.owner = owner
        self._balance = balance      # convention: _ = internal

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._balance += amount

acc = BankAccount("Ada")
acc.deposit(50)

try:
    BankAccount("Ada", -5)
except ValueError as e:
    print(e)    # balance cannot be negative

Validate at the boundary where bad data enters: __init__ for constructor arguments, each method for its own arguments. Failing fast with a ValueError (and a message that names the problem) beats storing a bad balance and failing three calls later in a place that looks innocent. The rule of thumb: __init__ guarantees the invariants at birth, and every method that can break an invariant re-checks its inputs. For attributes that need validation on every set (not just at birth), the answer is a property, not a check in __init__ - the properties chapter makes that precise.

__init__ vs __new__

class Cached:
    _instances = {}

    def __new__(cls, key):
        # __new__ actually creates (or returns) the object
        if key not in cls._instances:
            cls._instances[key] = super().__new__(cls)
        return cls._instances[key]

    def __init__(self, key):
        self.key = key

a = Cached("x")
b = Cached("x")
print(a is b)    # True - same object returned from __new__

__new__ is the static method that allocates and returns the instance; __init__ then receives that instance as self and initializes it. You only override __new__ for object-identity policies - singletons, immutable-type subclassing (you cannot subclass int's init behavior), caching by key. For 99% of classes, __init__ is the whole story, and the distinction exists so that "create" and "initialize" can be separated when a design truly needs it.

Pitfalls

  • Returning a value from __init__ is ignored (except None) - if you want a different object back, that is __new__'s job.
  • Forgetting self as the first parameter is an immediate TypeError; the error message names the missing argument, which is the hint.
  • Heavy work in __init__ (file reads, network) slows every construction; consider a classmethod factory or a lazy __getattr__ for expensive derived state.
  • Mutating shared defaults - def __init__(self, tags=[]): self.tags = tags shares one list across instances; use None and build inside.

Summary

Write def __init__(self, ...) to set the instance's starting state: required data as parameters, optional data as defaults, derived data (timestamps, ids) computed inside. Validate constructor arguments immediately with ValueError so invariants hold from birth, and remember that per-set validation belongs in a property. __init__ initializes; __new__ creates - override the latter only for identity policies like singletons. Avoid the four pitfalls (return values, missing self, heavy work, mutable default arguments), and the initializer is the reliable foundation every later chapter's method and property builds on.