Back to Blog
Python

Python Object Initialization: __init__ and Beyond

Understand Python object initialization: how __new__ and __init__ work, when to use dataclasses and class methods, and how to keep constructors maintainable.

python__init____new__dataclassesobject-oriented programming
Diagram showing the sequence of object creation in Python, from __new__ to __init__.

When you write obj = MyClass(), Python does not simply run the code inside __init__. The object initialization process involves two distinct steps: memory allocation via __new__ and attribute setup via __init__. Understanding this separation is key to writing flexible and maintainable classes. This article explains how Python object initialization works, when to override __new__, how to use class methods and dataclasses, and what performance considerations matter in real code.

The Two-Step Initialization Process

In Python, creating an instance of a class calls __new__ first, then __init__. __new__ is a static method that receives the class and returns a new instance. By default, it allocates memory and returns an object of that class. __init__ then receives that instance and any arguments passed to the constructor, and sets up initial state. Most classes only define __init__, because __new__ rarely needs to be customized. The default __new__ is sufficient for normal object creation.

class Point: def __new__(cls, x, y): print("Allocating instance") return super().__new__(cls) def __init__(self, x, y): print("Initializing instance") self.x = x self.y = y p = Point(3, 4) # Output: # Allocating instance # Initializing instance

The separation matters when you need to control allocation, such as returning a cached instance or a different object entirely. If __new__ returns an instance of a different class, __init__ may not be called, depending on the type of the returned object. This nuance is important when designing custom creation logic.

Writing an Effective init Method

The __init__ method is where most initialization logic lives. It should be simple and focused on setting up instance attributes. Avoid performing heavy computation or I/O inside __init__ unless absolutely necessary, because it runs for every object creation. Keep validation logic in one place, but be careful not to make the constructor too rigid.

class Account: def __init__(self, account_id, balance=0): if balance < 0: raise ValueError("balance cannot be negative") self.account_id = account_id self.balance = balance

This keeps the object in a valid state from the moment it is created. If you need to support multiple ways to create an object, consider class methods instead of overloading __init__ with many optional parameters.

Overriding new for Special Cases

__new__ is rarely overridden, but it is useful for patterns like singletons or when you need to return an existing instance. For example, a singleton can be implemented by controlling instance creation in __new__.

class Singleton: _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance

When you override __new__, you usually call super().__new__(cls) to allocate a new instance. If you return an object that is not an instance of cls, __init__ will not be called automatically. This behavior is subtle but can be used intentionally to return a different type from a constructor call.

Using Class Methods as Alternative Constructors

Class methods are a clean way to provide alternative initialization paths without complicating __init__. They are especially useful when you want to parse input from different formats.

class Date: def __init__(self, year, month, day): self.year = year self.month = month self.day = day @classmethod def from_string(cls, date_str): year, month, day = map(int, date_str.split('-')) return cls(year, month, day)

Now you can create a Date from a string without polluting the primary constructor. This pattern keeps the constructor simple and moves parsing logic to a named factory method.

Dataclasses for Concise Initialization

Dataclasses, introduced in Python 3.7, reduce boilerplate for classes that primarily store data. They automatically generate __init__, __repr__, and an __eq__ method by default, with ordering methods available when you set order=True.

from dataclasses import dataclass @dataclass class Point: x: int y: int

This generates an __init__ that accepts x and y as positional arguments. Dataclasses also support default values, field ordering, and mutable defaults through field(default_factory=...). They are a good choice when you need a simple value object without writing repetitive code.

Immutable Objects and Initialization Constraints

For truly immutable objects, you need to prevent attribute assignment after creation. This can be done by overriding __setattr__ to reject later changes, but the initializer must still be able to set fields during creation. A common pattern is to use object.__setattr__ inside __init__ for that initial setup. Frozen dataclasses automate this behavior:

from dataclasses import dataclass @dataclass(frozen=True) class Point: x: int y: int

The frozen dataclass generates an __init__ that uses object.__setattr__ to set fields, making the instance immutable after creation. This is useful for values that should never change, like coordinates or configuration entries.

Performance and Runtime Cost of Initialization

For ordinary instances, every object creation invokes both __new__ and __init__, so the cost of initialization is paid for each instance. Keeping __init__ lean reduces overhead, especially in loops that create many objects. Dataclass-generated initializers are convenient, but if profiling shows object creation is a bottleneck, you can hand-write the initializer or use __slots__ to reduce memory usage.

class Point: __slots__ = ('x', 'y') def __init__(self, x, y): self.x = x self.y = y

Using __slots__ prevents the creation of a __dict__ for each instance, saving memory and improving attribute access speed. This is a practical optimization when you have many instances with a fixed set of attributes.

Common Pitfalls in Object Initialization

A frequent mistake is forgetting that __init__ is not a constructor in the strict sense; it is an initializer. If you override __new__ and return an object that is not an instance of the requested class, __init__ is not called, which can bypass the initialization you wrote. Another pitfall is relying on mutable default arguments in __init__, which are shared across instances.

class Bad: def __init__(self, items=[]): self.items = items

This causes all instances to share the same list. Use None as the default and create a new list inside the method.

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

Understanding these details ensures that your object initialization is predictable and maintainable. Whether you use __init__, dataclasses, or custom __new__, the key is to match the initialization strategy to the requirements of your class.

Python Object Initialization: __init__, __new__, and Dataclasses | RYUSLOG DEV