Python - __init__ Method (Constructor)

Python's `__init__` method is often called a constructor, but technically it's an initializer. The actual object construction happens in `__new__`, which allocates memory and returns the instance. By...

Key Insights

  • The __init__ method is Python’s instance initializer that runs automatically when creating new objects, setting up initial state and attributes rather than allocating memory like true constructors in other languages
  • Instance variables defined in __init__ using self become attributes accessible throughout the object’s lifetime, while parameters without self remain local to the method
  • Common patterns include default parameters, validation logic, calling parent class initializers with super(), and factory methods that provide alternative object creation paths

Understanding init vs True Constructors

Python’s __init__ method is often called a constructor, but technically it’s an initializer. The actual object construction happens in __new__, which allocates memory and returns the instance. By the time __init__ runs, the object already exists—__init__ just sets it up.

class User:
    def __init__(self, username, email):
        print(f"__init__ called with {username}")
        self.username = username
        self.email = email
        self.is_active = True

# When you create an instance:
user = User("john_doe", "john@example.com")
# Output: __init__ called with john_doe

The self parameter represents the instance being initialized. Python passes it automatically—you never provide it when calling the class.

Basic Instance Variable Setup

The primary purpose of __init__ is establishing instance variables. These become attributes that persist with the object.

class DatabaseConnection:
    def __init__(self, host, port, database):
        self.host = host
        self.port = port
        self.database = database
        self.connection = None
        self.connected = False
    
    def connect(self):
        # Simulate connection
        self.connection = f"Connection to {self.host}:{self.port}/{self.database}"
        self.connected = True
        return self.connection

db = DatabaseConnection("localhost", 5432, "myapp")
print(db.host)  # localhost
print(db.connected)  # False
db.connect()
print(db.connected)  # True

Notice how connection and connected are initialized without parameters. This pattern establishes default state for attributes that get populated later.

Default Parameters and Optional Arguments

Default parameter values make __init__ more flexible, allowing various instantiation patterns.

class APIClient:
    def __init__(self, base_url, timeout=30, retry_count=3, verify_ssl=True):
        self.base_url = base_url
        self.timeout = timeout
        self.retry_count = retry_count
        self.verify_ssl = verify_ssl
        self.headers = {}
    
    def add_header(self, key, value):
        self.headers[key] = value

# Multiple ways to instantiate:
client1 = APIClient("https://api.example.com")
client2 = APIClient("https://api.example.com", timeout=60)
client3 = APIClient("https://api.example.com", retry_count=5, verify_ssl=False)

Using keyword arguments when calling makes code more readable, especially with multiple optional parameters.

Validation and Type Checking

__init__ is the ideal place for input validation, ensuring objects start in valid states.

class BankAccount:
    def __init__(self, account_number, initial_balance=0):
        if not isinstance(account_number, str) or len(account_number) != 10:
            raise ValueError("Account number must be 10-digit string")
        
        if initial_balance < 0:
            raise ValueError("Initial balance cannot be negative")
        
        self.account_number = account_number
        self.balance = initial_balance
        self.transactions = []
    
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount
        self.transactions.append(f"Deposit: +{amount}")

# Valid creation
account = BankAccount("1234567890", 1000)

# These raise ValueError:
# account = BankAccount("123", 1000)  # Too short
# account = BankAccount("1234567890", -500)  # Negative balance

Failing fast during initialization prevents invalid objects from existing in your system.

Working with Mutable Default Arguments

A common pitfall: using mutable defaults like lists or dictionaries directly in the parameter list.

# WRONG - Mutable default shared across instances
class ShoppingCart:
    def __init__(self, items=[]):  # Don't do this!
        self.items = items

cart1 = ShoppingCart()
cart1.items.append("apple")
cart2 = ShoppingCart()
print(cart2.items)  # ['apple'] - Unexpected!

# CORRECT - Use None and create new instance
class ShoppingCart:
    def __init__(self, items=None):
        self.items = items if items is not None else []

cart1 = ShoppingCart()
cart1.items.append("apple")
cart2 = ShoppingCart()
print(cart2.items)  # [] - Expected behavior

Python evaluates default arguments once at function definition, not each call. Mutable defaults get shared across all instances.

Inheritance and super()

When subclassing, use super() to call the parent’s __init__, ensuring proper initialization chain.

class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.odometer = 0

class ElectricVehicle(Vehicle):
    def __init__(self, make, model, year, battery_capacity):
        super().__init__(make, model, year)
        self.battery_capacity = battery_capacity
        self.charge_level = 100
    
    def get_range(self):
        return self.battery_capacity * (self.charge_level / 100) * 3.5

ev = ElectricVehicle("Tesla", "Model 3", 2024, 75)
print(ev.make)  # Tesla
print(ev.battery_capacity)  # 75
print(ev.get_range())  # 262.5

The super() call ensures Vehicle.__init__ runs, setting up make, model, year, and odometer before the subclass adds its own attributes.

Complex Initialization Logic

Sometimes initialization requires computation or setup beyond simple assignment.

import hashlib
from datetime import datetime

class Session:
    def __init__(self, user_id, ip_address):
        self.user_id = user_id
        self.ip_address = ip_address
        self.created_at = datetime.now()
        self.session_id = self._generate_session_id()
        self.data = {}
    
    def _generate_session_id(self):
        raw = f"{self.user_id}:{self.ip_address}:{self.created_at.timestamp()}"
        return hashlib.sha256(raw.encode()).hexdigest()

session = Session(12345, "192.168.1.1")
print(session.session_id)  # Unique hash based on user, IP, and timestamp

Helper methods called from __init__ keep initialization logic organized and testable.

Factory Methods as Alternatives

Class methods can provide alternative constructors for different initialization patterns.

class Configuration:
    def __init__(self, settings):
        self.settings = settings
    
    @classmethod
    def from_file(cls, filepath):
        import json
        with open(filepath, 'r') as f:
            settings = json.load(f)
        return cls(settings)
    
    @classmethod
    def from_env(cls):
        import os
        settings = {
            'debug': os.getenv('DEBUG', 'false').lower() == 'true',
            'port': int(os.getenv('PORT', '8000')),
            'host': os.getenv('HOST', 'localhost')
        }
        return cls(settings)
    
    @classmethod
    def defaults(cls):
        return cls({'debug': False, 'port': 8000, 'host': 'localhost'})

# Multiple ways to create Configuration objects:
config1 = Configuration({'debug': True, 'port': 3000})
config2 = Configuration.from_file('config.json')
config3 = Configuration.from_env()
config4 = Configuration.defaults()

Factory methods make instantiation more semantic and handle different data sources cleanly.

Private Attributes and Name Mangling

Python supports name mangling for attributes you want to discourage external access to.

class SecureData:
    def __init__(self, public_id, secret_key):
        self.public_id = public_id
        self.__secret_key = secret_key  # Name mangling
        self._internal_cache = {}  # Convention: private
    
    def verify(self, key):
        return key == self.__secret_key

data = SecureData("USER123", "super_secret")
print(data.public_id)  # USER123
# print(data.__secret_key)  # AttributeError
print(data._SecureData__secret_key)  # super_secret (mangled name, but accessible)

Double underscore prefix triggers name mangling, making attributes harder to accidentally access. Single underscore is just convention indicating “internal use.”

Performance Considerations

Keep __init__ lightweight when possible. Expensive operations might be better as lazy properties.

class DataProcessor:
    def __init__(self, filepath):
        self.filepath = filepath
        self._data = None  # Lazy load
    
    @property
    def data(self):
        if self._data is None:
            self._data = self._load_data()
        return self._data
    
    def _load_data(self):
        # Expensive operation only runs when needed
        with open(self.filepath, 'r') as f:
            return [line.strip() for line in f]

# Object created instantly, data loaded on first access
processor = DataProcessor('large_file.txt')
# ... do other setup ...
first_line = processor.data[0]  # Now the file loads

This pattern defers expensive initialization until the data is actually needed, improving instantiation performance.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.