If you have written even a single Python class, you have almost certainly typed def __init__(self). It is one of the first things every Python developer learns. But here is the part many tutorials skip over: __init__ is not actually a constructor.
Understanding why that distinction matters — and what is really happening behind the scenes when you create an object — is the difference between memorizing syntax and actually comprehending Python's object model. This article takes you from the surface-level "it sets up your object" explanation all the way down to the internal machinery, the relevant PEPs that shaped its behavior, and the real-world patterns that experienced Python developers rely on every day.
The Basics: What __init__ Actually Does
At its core, __init__ is a special method — one of Python's many "dunder" (double underscore) methods — that gets called automatically after a new instance of a class has been created. Its job is to initialize that instance by setting up its attributes and preparing its initial state.
class NetworkScanner:
def __init__(self, target_ip, port_range=(1, 1024)):
self.target_ip = target_ip
self.port_range = port_range
self.open_ports = []
self.scan_complete = False
scanner = NetworkScanner("192.168.1.1", (1, 65535))
print(scanner.target_ip) # 192.168.1.1
print(scanner.scan_complete) # False
When you write scanner = NetworkScanner("192.168.1.1", (1, 65535)), Python does two things in sequence. First, it creates a raw, empty instance of NetworkScanner. Then it calls __init__ on that newly created instance, passing in the arguments you provided. The self parameter is that fresh instance, and everything you attach to self inside __init__ becomes an instance attribute — data that belongs to that specific object and no other.
__init__ does not create the object. It configures an object that already exists.
__init__ Is Not a Constructor
This is the single most common misconception in Python education, and it matters more than you might think.
Raymond Hettinger — a Python core developer responsible for features like collections.OrderedDict and much of the itertools module — made this distinction explicit in his PyCon 2013 presentation slides, "Python's Class Development Toolkit." The slides state plainly: "Init isn't a constructor. Its job is to initialize the instance variables."
So if __init__ is not the constructor, what is?
The Real Constructor: __new__
The actual constructor in Python is __new__. This is the method responsible for creating and returning a new instance. The Python documentation for the Data Model describes the relationship directly: __new__() and __init__() work together in constructing objects — __new__() to create it, and __init__() to customize it.
Here is how the full creation sequence works:
class DemoObject:
def __new__(cls, *args, **kwargs):
print(f"1. __new__ called — creating instance of {cls.__name__}")
instance = super().__new__(cls)
print(f"2. Instance created at {id(instance)}")
return instance
def __init__(self, value):
print(f"3. __init__ called — initializing instance at {id(self)}")
self.value = value
print(f"4. Initialization complete: self.value = {self.value}")
obj = DemoObject(42)
Output:
1. __new__ called — creating instance of DemoObject
2. Instance created at 140234567890
3. __init__ called — initializing instance at 140234567890
4. Initialization complete: self.value = 42
Notice the memory address is the same in steps 2 and 3. The object that __new__ creates is the same object that __init__ receives as self. The Python documentation reinforces this with an important constraint: no non-None value may be returned by __init__; doing so will cause a TypeError to be raised at runtime. This makes sense — __init__ is not supposed to return a new object. That is __new__'s job.
There is also a critical behavioral rule: if __new__ does not return an instance of the class, then __init__ will not be called at all. Python only invokes __init__ when __new__ returns an object of the expected type.
class Broken:
def __new__(cls):
print("__new__ called, but returning a string instead")
return "I'm not a Broken instance"
def __init__(self):
# This will NEVER execute
print("__init__ called")
result = Broken()
print(result) # "I'm not a Broken instance"
print(type(result)) # <class 'str'>
Why Does This Distinction Matter?
In everyday Python, you will almost never override __new__. The Python documentation itself states that __new__ is "intended mainly to allow subclasses of immutable types (like int, str, or tuple) to customize instance creation." It is also used in metaclass programming and certain creational patterns like the Singleton.
But understanding the __new__ / __init__ split helps you reason about Python correctly. When someone says "the constructor," you now know there are actually two steps. And when you encounter code that overrides __new__ — in an ORM, a metaclass, or a caching system — you will not be caught off guard.
The Role of self
Every __init__ method takes self as its first parameter. This is not optional, and it is not a keyword — it is a deeply ingrained convention that the Python community treats as law.
Guido van Rossum, Python's creator, addressed the reasoning behind explicit self multiple times throughout Python's history. In a 2020 interview published on the Dropbox blog, he described Python's design philosophy this way: "In Python, every symbol you type is essential." The explicit self parameter is a direct reflection of this principle. Rather than hiding the instance reference behind implicit magic (as this does in Java or C++), Python makes you name it, see it, and pass it. You always know exactly what object you are operating on.
class ThreatIntelFeed:
def __init__(self, feed_name, api_key, refresh_interval=3600):
self.feed_name = feed_name # instance attribute
self.api_key = api_key # instance attribute
self.refresh_interval = refresh_interval # instance attribute with default
self._cache = {} # private-by-convention attribute
self._last_refresh = None # initialized to None, set later
Everything assigned to self.something inside __init__ becomes part of that specific instance's namespace. The parameters feed_name, api_key, and refresh_interval are just local variables — they vanish after __init__ finishes. Only what you attach to self survives.
Related PEPs: How __init__ Shaped (and Was Shaped By) Python's Evolution
The __init__ method sits at the intersection of several major Python Enhancement Proposals that have refined how classes work over the years.
PEP 3107 and PEP 484: Type Annotations and __init__
PEP 3107 (authored by Collin Winter and Tony Lownds, accepted in 2006) introduced syntax for function annotations in Python 3.0, but intentionally left their semantics undefined. It was PEP 484 (authored by Guido van Rossum, Jukka Lehtosalo, and Lukasz Langa, created in 2014 and accepted in 2015 for Python 3.5) that gave annotations their most common purpose: type hints.
PEP 484 contains a specific note about __init__: it states that __init__ ought to have a return annotation of -> None, since the method must always return None. This explicit return annotation was chosen to keep __init__ consistent with the annotation rules for all other methods, rather than carving out a confusing special case.
class Vulnerability:
def __init__(self, cve_id: str, severity: float, description: str = "") -> None:
self.cve_id = cve_id
self.severity = severity
self.description = description
This is not just cosmetic. Static type checkers like mypy use these annotations to catch bugs at development time. If you accidentally tried to return a value from __init__, a properly configured type checker would flag it before your code ever runs.
PEP 557: Data Classes and the Auto-Generated __init__
Perhaps no PEP has had a bigger impact on how Python developers interact with __init__ than PEP 557, authored by Eric V. Smith, accepted in late 2017, and shipped as part of the standard library in Python 3.7 (released June 2018). Data Classes use a decorator to automatically generate __init__ (along with __repr__, __eq__, and other dunder methods) based on type-annotated class variables.
Eric V. Smith singled out the attrs project in PEP 557 as a genuine inspiration for Data Classes, crediting its design decisions with shaping the direction of the proposal. — PEP 557
Here is what Data Classes look like in practice:
from dataclasses import dataclass
@dataclass
class FirewallRule:
source_ip: str
destination_ip: str
port: int
protocol: str = "TCP"
action: str = "ALLOW"
The @dataclass decorator inspects those annotated fields and generates the equivalent of:
def __init__(self, source_ip: str, destination_ip: str, port: int,
protocol: str = "TCP", action: str = "ALLOW") -> None:
self.source_ip = source_ip
self.destination_ip = destination_ip
self.port = port
self.protocol = protocol
self.action = action
You never had to write that boilerplate. The decorator did it for you. But crucially, Data Classes do not replace __init__ — they generate it. If you define your own __init__ on a dataclass (or set init=False), the auto-generated version is skipped entirely. This is standard Python: explicit beats implicit.
Data Classes also introduced __post_init__, a hook that runs immediately after the generated __init__ finishes, giving you a place to add validation or computed attributes:
from dataclasses import dataclass, field
@dataclass
class CVEEntry:
cve_id: str
cvss_score: float
affected_products: list = field(default_factory=list)
def __post_init__(self):
if not self.cve_id.startswith("CVE-"):
raise ValueError(f"Invalid CVE ID format: {self.cve_id}")
if not 0.0 <= self.cvss_score <= 10.0:
raise ValueError(f"CVSS score must be between 0.0 and 10.0")
PEP 487: __init_subclass__ — The Subclassing Hook
PEP 487 (authored by Martin Teichmann, accepted in 2016 for Python 3.6) introduced __init_subclass__, a method that lets a parent class customize the creation of its subclasses without resorting to metaclasses. Despite sharing the __init_subclass__ name prefix with __init__, this method has a fundamentally different purpose. It does not initialize instances — it initializes subclasses themselves.
class PluginBase:
_registry = {}
def __init_subclass__(cls, plugin_name=None, **kwargs):
super().__init_subclass__(**kwargs)
if plugin_name:
PluginBase._registry[plugin_name] = cls
class SQLiScanner(PluginBase, plugin_name="sqli"):
def __init__(self, target_url):
self.target_url = target_url
class XSSScanner(PluginBase, plugin_name="xss"):
def __init__(self, target_url):
self.target_url = target_url
print(PluginBase._registry)
# {'sqli': <class 'SQLiScanner'>, 'xss': <class 'XSSScanner'>}
__init_subclass__ runs at class definition time, while __init__ runs at instance creation time. They serve completely different phases of the object lifecycle.
PEP 3119: Abstract Base Classes and __init__
PEP 3119 (authored by Guido van Rossum and Talin, accepted in 2007) introduced Abstract Base Classes to Python 3.0. The PEP made a deliberate design decision regarding __init__: no ABCs defined in the PEP override __init__, __new__, __str__, or __repr__. Defining a standard constructor signature would unnecessarily constrain custom container types.
This is significant. It means that when you subclass collections.abc.Mapping or collections.abc.Sequence, you are free to design your __init__ however you want. The ABC system constrains your interface (you must implement __getitem__, __len__, etc.), but it deliberately leaves construction unconstrained.
Common Mistakes and Antipatterns
The Mutable Default Argument Trap
This is probably the single most common __init__ bug in Python, and it catches experienced developers too:
# WRONG — the list is shared across ALL instances
class AlertQueue:
def __init__(self, alerts=[]):
self.alerts = alerts
q1 = AlertQueue()
q2 = AlertQueue()
q1.alerts.append("Port scan detected")
print(q2.alerts) # ["Port scan detected"] — q2 is contaminated!
The default [] is evaluated once when the function is defined, not each time it is called. Every instance that uses the default gets the same list object. The fix is a Python idiom you should internalize:
# CORRECT — use None as sentinel, create new list in the body
class AlertQueue:
def __init__(self, alerts=None):
self.alerts = alerts if alerts is not None else []
If you are using Data Classes, use field(default_factory=list) instead. It handles this correctly by calling list() fresh each time an instance is created.
Doing Too Much in __init__
Your __init__ should initialize state, not perform complex operations. Network calls, file I/O, heavy computation — these belong in separate methods. Jack Diederich, a Python core contributor, delivered a widely cited talk at PyCon 2012 titled "Stop Writing Classes," where he argued that a class with only two methods — one of which is __init__ — probably should have been written as a function instead. The spirit of this advice applies inside __init__ as well: keep it focused on setting up attributes. Let other methods do the work.
# AVOID — __init__ is doing too much
class ThreatReport:
def __init__(self, log_file_path):
self.log_file_path = log_file_path
with open(log_file_path) as f: # File I/O in __init__
self.raw_data = f.read()
self.parsed_entries = self._parse() # Heavy parsing in __init__
self.risk_score = self._calculate_risk() # Computation in __init__
# BETTER — __init__ sets up state, methods do work
class ThreatReport:
def __init__(self, log_file_path):
self.log_file_path = log_file_path
self.raw_data = None
self.parsed_entries = []
self.risk_score = 0.0
def load(self):
with open(self.log_file_path) as f:
self.raw_data = f.read()
return self
def analyze(self):
self.parsed_entries = self._parse()
self.risk_score = self._calculate_risk()
return self
Forgetting super().__init__() in Subclasses
When you subclass another class and define your own __init__, the parent's __init__ does not run automatically. You must call it explicitly. The Python documentation is clear on this point: if a base class has an __init__() method, the derived class's __init__() method, if any, must explicitly call it to ensure proper initialization of the base class part of the instance.
class BaseScanner:
def __init__(self, target):
self.target = target
self.results = []
class PortScanner(BaseScanner):
def __init__(self, target, port_range):
super().__init__(target) # Initialize the base class
self.port_range = port_range
Omitting the super().__init__() call means self.target and self.results never get set. You will hit an AttributeError the moment you try to access them.
Alternative Constructors with @classmethod
Sometimes you need multiple ways to create an object. Rather than cramming conditional logic into __init__, Python's convention is to use class methods as alternative constructors. Raymond Hettinger demonstrated this pattern prominently in his PyCon 2013 class toolkit talk, showing how the standard library itself uses this approach — datetime.now(), datetime.fromtimestamp(), and dict.fromkeys() are all alternative constructors.
import json
class SecurityConfig:
def __init__(self, rules, max_connections, timeout):
self.rules = rules
self.max_connections = max_connections
self.timeout = timeout
@classmethod
def from_json_file(cls, filepath):
with open(filepath) as f:
data = json.load(f)
return cls(
rules=data["rules"],
max_connections=data.get("max_connections", 100),
timeout=data.get("timeout", 30)
)
@classmethod
def default(cls):
return cls(rules=[], max_connections=50, timeout=60)
# Three ways to create the same type of object
config1 = SecurityConfig(["deny_all"], 200, 45)
config2 = SecurityConfig.from_json_file("config.json")
config3 = SecurityConfig.default()
The critical detail here is cls instead of hardcoding the class name. If someone subclasses SecurityConfig, the from_json_file method will correctly return an instance of the subclass, not the parent. This is exactly the kind of forward-thinking design that __init__ alone cannot provide.
__slots__ and __init__: Memory and Enforcement
One underexplored interaction in Python classes is between __init__ and __slots__. When you define __slots__ on a class, you are telling Python to allocate only those specific attribute names — no __dict__ is created for the instance. This can reduce per-instance memory by 40–50% for classes with many instances, which matters in threat event pipelines, log parsers, or any system creating thousands of objects per second.
The catch: __init__ must only assign to names declared in __slots__. Try to set an undeclared attribute and you get an AttributeError immediately — not silently, not at runtime somewhere else. This is actually a feature. It turns your class into a stricter schema.
class NetworkEvent:
__slots__ = ("source_ip", "dest_ip", "port", "protocol", "timestamp")
def __init__(self, source_ip: str, dest_ip: str, port: int,
protocol: str, timestamp: float) -> None:
self.source_ip = source_ip
self.dest_ip = dest_ip
self.port = port
self.protocol = protocol
self.timestamp = timestamp
# This will raise AttributeError — "severity" is not in __slots__
# event = NetworkEvent("10.0.0.1", "10.0.0.2", 443, "TCP", 1709123456.0)
# event.severity = "HIGH" # AttributeError: 'NetworkEvent' object has no attribute 'severity'
When using __slots__ with inheritance, every class in the chain must define its own __slots__. If a parent does not define __slots__, the subclass gets a __dict__ anyway, which defeats the purpose. Also, dataclasses support __slots__ via @dataclass(slots=True) since Python 3.10, letting you combine auto-generated __init__ with slot-based memory control.
There is also an important behavioral nuance when mixing __slots__ with __init__ and None-initialized attributes. Because slotted classes have no __dict__, unset slot attributes raise AttributeError rather than returning None. This means you cannot rely on the same "check if attribute exists" patterns you might use with regular instances. Always initialize every slot in __init__ — even if the value is None — to avoid confusing errors downstream.
MRO, Cooperative Inheritance, and super()
When the article covered forgetting super().__init__(), it only scratched the surface of why this matters in non-trivial class hierarchies. Python's Method Resolution Order (MRO) determines which version of a method gets called in a multi-inheritance chain. The MRO is computed using the C3 linearization algorithm, which guarantees a consistent, predictable order. The critical implication for __init__: if every class in a diamond inheritance chain calls super().__init__() correctly, each initializer in the hierarchy runs exactly once and in the right order. If any class breaks that chain by calling the parent directly instead of through super(), or by not calling the parent at all, some initializers may run multiple times or not at all.
class LoggableMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # forward to the next in MRO
self._log_entries = []
def log(self, message: str) -> None:
self._log_entries.append(message)
class TimestampedMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # forward to the next in MRO
from datetime import datetime
self._created_at = datetime.now()
class AuditedScanner(LoggableMixin, TimestampedMixin):
def __init__(self, target: str) -> None:
super().__init__() # triggers LoggableMixin -> TimestampedMixin -> object
self.target = target
scanner = AuditedScanner("10.0.0.1")
print(scanner._log_entries) # [] — set by LoggableMixin
print(scanner._created_at) # datetime set by TimestampedMixin
print(scanner.target) # 10.0.0.1 — set by AuditedScanner
Mixins should always use *args, **kwargs in their __init__ signatures and pass them along via super(). This keeps the initialization chain cooperative regardless of where in the MRO the mixin ends up. You can inspect the MRO at any time with ClassName.__mro__ or ClassName.mro().
This pattern is used throughout Python's standard library. The socketserver.ThreadingMixIn and socketserver.ForkingMixIn classes, for example, are designed to be combined with server classes via cooperative inheritance. Understanding how super().__init__() threads through the MRO is what separates developers who can reason about multi-inheritance from those who can only copy-paste it and hope for the best.
What About __init__ and Properties?
Another gap many tutorials skip: what happens when you set an attribute in __init__ that is also defined as a @property on the class? Python resolves attribute access through the descriptor protocol. A property is a descriptor. When you write self.severity = "HIGH" in __init__ and severity is a property, Python calls the property's setter — it does not bypass it.
class CVERecord:
def __init__(self, cve_id: str, cvss: float) -> None:
self.cve_id = cve_id
self.cvss = cvss # This calls the property setter below
@property
def cvss(self) -> float:
return self._cvss
@cvss.setter
def cvss(self, value: float) -> None:
if not 0.0 <= value <= 10.0:
raise ValueError(f"CVSS score {value} is out of range [0.0, 10.0]")
self._cvss = value
record = CVERecord("CVE-2024-1234", 9.8) # Works fine
bad = CVERecord("CVE-2024-9999", 11.0) # Raises ValueError immediately
This means you get input validation at construction time for free, without any extra validation code in __init__. It also means that if you are using dataclasses with __post_init__ and need per-attribute validation, you have two reasonable options: use @property descriptors as above, or validate in __post_init__. The property approach enforces the constraint every time the attribute is set (not just at construction), which is usually the better choice for invariants that should never be violated.
Testing Your __init__
A question that almost never gets addressed in introductory material: how do you properly test __init__? The answer reveals something about what __init__ actually is — a state configurator — and dictates the right testing strategy.
You do not test __init__ directly by calling it. You test the instance it produces. A well-designed test suite for a class asserts that after construction, the instance has exactly the attributes and values you expect, that invalid inputs raise the correct exceptions, and that alternative constructors produce correctly initialized objects.
import pytest
from datetime import datetime
class LoginAttempt:
def __init__(self, username: str, source_ip: str, success: bool,
timestamp=None) -> None:
if not username:
raise ValueError("username cannot be empty")
self.username = username
self.source_ip = source_ip
self.success = success
self.timestamp = timestamp or datetime.now()
@classmethod
def failed(cls, username: str, source_ip: str) -> "LoginAttempt":
return cls(username=username, source_ip=source_ip, success=False)
# Test: correct attribute initialization
def test_init_sets_attributes():
attempt = LoginAttempt("admin", "10.0.0.5", True)
assert attempt.username == "admin"
assert attempt.source_ip == "10.0.0.5"
assert attempt.success is True
assert isinstance(attempt.timestamp, datetime)
# Test: invalid input raises at construction time
def test_init_rejects_empty_username():
with pytest.raises(ValueError, match="username cannot be empty"):
LoginAttempt("", "10.0.0.5", False)
# Test: alternative constructor produces expected state
def test_failed_classmethod():
attempt = LoginAttempt.failed("root", "192.168.1.100")
assert attempt.success is False
assert attempt.username == "root"
When testing classes that do I/O in methods (not in __init__ — you kept those separate, right?), use unittest.mock.patch or pytest-mock to stub out the external calls. Your __init__ tests should always be fast, deterministic, and require no external resources. If your __init__ tests need mocking, that is a signal the constructor is doing too much.
One more testing angle worth mentioning: if you are using dataclasses, test __post_init__ validation by creating instances with invalid field values and confirming the expected exceptions bubble up. Since __post_init__ runs inside the generated __init__, the error surface is exactly the same — bad inputs fail at construction, not silently later.
Putting It All Together
Here is a more complete example that pulls together the concepts from this article — proper __init__ design, type hints, the None sentinel for mutable defaults, super() calls, and an alternative constructor:
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
class BaseAuditEntry:
def __init__(self, timestamp: Optional[datetime] = None) -> None:
self.timestamp = timestamp or datetime.now()
class LoginAttempt(BaseAuditEntry):
def __init__(self, username: str, source_ip: str, success: bool,
timestamp: Optional[datetime] = None) -> None:
super().__init__(timestamp)
self.username = username
self.source_ip = source_ip
self.success = success
@classmethod
def failed(cls, username: str, source_ip: str) -> "LoginAttempt":
return cls(username=username, source_ip=source_ip, success=False)
def __repr__(self) -> str:
status = "SUCCESS" if self.success else "FAILED"
return (f"LoginAttempt({self.username!r}, {self.source_ip!r}, "
f"{status}, {self.timestamp})")
# Usage
attempt = LoginAttempt.failed("admin", "10.0.0.5")
print(attempt)
# LoginAttempt('admin', '10.0.0.5', FAILED, 2026-03-01 12:00:00.000000)
Key Takeaways
__init__is an initializer, not a constructor. The actual object creation happens in__new__.__init__receives the already-created instance to configure it.- Use
Noneas a sentinel for mutable default arguments. Never use a list, dict, or set as a default value directly in the signature. - Always call
super().__init__()in subclasses. The parent's initialization does not run automatically — you must invoke it explicitly. In cooperative multi-inheritance, use*args, **kwargsto keep the MRO chain intact. - Keep
__init__focused on attribute assignment. Network calls, file I/O, and heavy computation belong in separate methods. - Use
@classmethodfor alternative construction patterns. This keeps your primary__init__clean and makes subclassing work correctly. - Annotate with
-> Nonefor type checker compatibility. PEP 484 specifies this explicitly, and tools likemypyenforce it. - Use
__slots__when instance count and memory matter. It enforces attribute discipline and can cut per-instance overhead significantly. - Properties set in
__init__go through the descriptor protocol. If you define a@propertysetter, assignment in__init__triggers it — use this to enforce invariants at construction. - Test instances, not the method. Assert the state of the constructed object: correct attributes, correct values, correct exceptions for bad inputs.
Understanding __init__ is really about understanding Python's object model. Once you see it not as "the thing that makes my object" but as "the thing that configures my object after it already exists," a whole layer of Python's design — from immutable types to metaclasses to dataclasses to the descriptor protocol — starts to make a lot more sense. And once you understand how the MRO shapes initialization chains, how __slots__ changes what __init__ can touch, and how properties silently intercept assignments, you have a mental model that will carry you through every Python class you ever read or write.