Python: Constructors __init__ & Instance Variables

Every object in your program has a birth moment, and __init__ is where it happens. Get that moment right and the whole class can trust its own data; get it wrong and the bug surfaces three files away. This guide covers the Python init constructor patterns that matter: defaults, validation, factory methods, and the mutable default trap.

“Beware of bugs in the above code; I have only proved it correct, not tried it.”

Donald Knuth, 1977 memo

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 17 minutes

Think of the Python __init__ constructor as the setup checklist for a new hire on their first day. Before they can do any real work, someone hands them a laptop, an email address, and an ID badge. That setup happens once, right at the start. The __init__ method does the same thing for an object: the moment you create it, Python runs __init__ to fill in the object’s starting details so it is ready to use.

Say a user named Rahul signs up on your site. When you write user = User("Rahul", "rahul@technoscripts.com"), Python builds a fresh, empty object and then calls __init__ on it. Inside that method you assign values to attributes, like self.name and self.email, and those become the object’s data. The first parameter is always self, which is just the new object itself. You do not pass self in by hand; Python wires it up for you.

You already know how to write a class and make objects from it. The trouble starts the moment real life shows up: some parameters should be optional, some values need checking before you trust them, and sometimes you want a few different ways to build the same kind of object. This post walks through every __init__ pattern you will reach for, from a plain constructor to factory methods that handle messier setup. One small warning lives in here too: the mutable default trap, a bug that catches almost every Python developer at least once.

Basic __init__: The Python Init Constructor

Your Code:obj = MyClass(“Rahul”, 28)Python Runtime callsMyClass.__new__(MyClass)__new__() allocates anempty object in memoryEmpty object created:no attributes yetPython Runtime calls__init__(self, “Rahul”, 28)__init__() fills the object:self.name = “Rahul”self.age = 28Object fully initialized:name=”Rahul”, age=28Python returns the object:obj now points tothe fully built objectPython __init__: How __new__ Allocates and __init__ Fills a New Object

The diagram traces Python’s two-step object creation. First __new__ sets aside memory and hands back a raw, empty object. Then __init__ receives that object as self and fills in its attributes. In day-to-day Python you only write __init__, because __new__ comes free from object. The split matters later, when you meet singletons, immutable types, or metaclasses. For now, keep your eyes on the __init__ part of the flow: it is like a blank ID card coming out of the printer, then someone writing your name and photo onto it before handing it over.

📄 basic_init.py: required parameters

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
        self.is_active = True  # Default attribute, not a parameter
        self.login_count = 0

    def login(self):
        self.login_count += 1
        return f"{self.name} logged in (total: {self.login_count})"

user = User("Rahul", "rahul@technoscripts.com")
print(user.login())
print(user.login())
print(f"Active: {user.is_active}")

▶ Output

Rahul logged in (total: 1)
Rahul logged in (total: 2)
Active: True

What happened here: __init__ takes self (the new object) plus whatever arguments you pass in. The line self.name = name copies the parameter onto the object as an attribute, so it sticks around after the method ends. Notice self.is_active = True: that one is not a parameter at all, just a sensible starting value every new user gets for free. Here is the habit worth forming early. If a method anywhere in the class is going to read an attribute, give that attribute a value in __init__. Then the object is fully formed the instant it exists, and you never trip over a missing attribute three methods later.

Default and Optional Parameters

Not every object needs every detail spelled out, and a good Python init constructor reflects that. A product always has a name and a price, but its category and discount can fall back to sensible defaults. Give a parameter a default value in __init__ and it becomes optional: callers can skip it, or pass their own value to override it. It is like a form where some fields are pre-filled and you only change the ones that differ.

📄 default_params.py: flexible initialization

class Product:
    def __init__(self, name, price, category="General", discount=0):
        self.name = name
        self.price = price
        self.category = category
        self.discount = discount

    def final_price(self):
        return self.price * (1 - self.discount / 100)

    def __repr__(self):
        return f"Product('{self.name}', {self.price}, '{self.category}')"

# All valid, defaults fill in what you skip
p1 = Product("Laptop", 85000)
p2 = Product("Keyboard", 2500, "Electronics")
p3 = Product("Book", 500, "Education", 10)

for p in [p1, p2, p3]:
    print(f"{p.name}: Rs.{p.final_price():.0f} ({p.category})")

▶ Output

Laptop: Rs.85000 (General)
Keyboard: Rs.2500 (Electronics)
Book: Rs.450 (Education)

What happened here: Parameters with a default value, like category="General", are optional. The laptop skipped both category and discount, so it took the defaults. The keyboard set a category but kept the zero discount. The book set everything. One __init__, three different ways to call it. There is one rule to remember: parameters with defaults must come after the required ones in the signature, exactly like any other Python function. The constructor follows the same argument rules you already know.

The Mutable Default Trap

This is the one that catches everybody. It looks harmless: give tasks a default of an empty list, so a new task list starts empty. The catch is that the default list is created once, when Python first reads the def line, and then every object that skips that argument shares the exact same list. Picture a single shared whiteboard in an office that everyone is told is “their own.” Two colleagues, Vinay and Anvay, each believe the board belongs to them. Vinay writes a note, Anvay walks over, and Vinay’s note is sitting there on what Anvay thought was his blank board. Same trap, different room.

📄 mutable_default_trap.py: the bug that bites every Python developer

# WRONG: mutable default argument
class TaskListBad:
    def __init__(self, owner, tasks=[]):
        self.owner = owner
        self.tasks = tasks  # All instances share the SAME list!

t1 = TaskListBad("Vinay")
t2 = TaskListBad("Anvay")

t1.tasks.append("Write report")
print(f"Anvay's tasks: {t2.tasks}")  # Vinay's task appears here!

# CORRECT: use None and create the list inside __init__
class TaskListGood:
    def __init__(self, owner, tasks=None):
        self.owner = owner
        self.tasks = tasks if tasks is not None else []

t3 = TaskListGood("Vinay")
t4 = TaskListGood("Anvay")

t3.tasks.append("Write report")
print(f"Anvay's tasks: {t4.tasks}")  # Empty, as expected

▶ Output

Anvay's tasks: ['Write report']
Anvay's tasks: []

What happened here: Default arguments are built once, at the moment Python reads the def line, not fresh on every call. So a default of [] is a single list that every default-using object points at. Vinay appends to his list, and because Anvay’s object never got its own list, Anvay sees Vinay’s task too. The fix is the same every time: make the default None, then inside __init__ create a brand new list when nothing was passed. The good version gives each owner a separate whiteboard, so Anvay’s stays empty. Burn this pattern into memory; you will use it for lists, dicts, and sets for the rest of your Python life.

Validation in __init__

The init constructor is a great place to slam the door on bad data. Think of __init__ as the bouncer at the entrance: check the input the moment it arrives, and if it is wrong, refuse to let the object exist at all. A temperature below absolute zero is not a real reading, so a Temperature object should never hold one. Catch it here and you save yourself a confusing crash somewhere far away.

📄 validation_init.py: reject bad data at creation time

class Temperature:
    def __init__(self, celsius):
        if not isinstance(celsius, (int, float)):
            raise TypeError(f"Temperature must be a number, got {type(celsius).__name__}")
        if celsius < -273.15:
            raise ValueError(f"Temperature can't be below absolute zero (-273.15C), got {celsius}")
        self.celsius = celsius

    @property
    def fahrenheit(self):
        return self.celsius * 9/5 + 32

    def __repr__(self):
        return f"Temperature({self.celsius}C / {self.fahrenheit:.1f}F)"

t1 = Temperature(100)
print(t1)

t2 = Temperature(-40)
print(t2)

try:
    bad = Temperature(-300)
except ValueError as e:
    print(f"Error: {e}")

try:
    bad = Temperature("hot")
except TypeError as e:
    print(f"Error: {e}")

▶ Output

Temperature(100C / 212.0F)
Temperature(-40C / -40.0F)
Error: Temperature can't be below absolute zero (-273.15C), got -300
Error: Temperature must be a number, got str

What happened here: The two valid temperatures sailed through and printed fine. The -300 reading tripped the absolute-zero check and raised a ValueError, so that object was never built. The "hot" string failed the type check and raised a TypeError. That is the whole idea: validate up front and fail fast, with a message that says exactly what went wrong. Reject the bad value while you still know what it was, instead of letting it slip in and blow up three methods later with an error that points nowhere useful.

Computed Attributes in __init__

Sometimes an attribute is not something the caller passes in; it is something you work out from what they did pass. You give a Person their birth year, and the object figures out their age. Doing that math once inside __init__ means every method afterward can just read self.age without recalculating it. It is like writing your age on the top of a form right after you fill in your date of birth, so you never have to count it again. Below, two friends named Prathamesh and Anvi hand over only their birth years, and the constructor fills in their ages.

📄 computed_attrs.py: attributes worked out from other attributes

from datetime import date

class Person:
    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year
        self.age = date.today().year - birth_year  # Computed

    def __repr__(self):
        return f"Person('{self.name}', age={self.age})"

prathamesh = Person("Prathamesh", 1998)
anvi = Person("Anvi", 2000)

print(prathamesh)
print(anvi)

▶ Output

Person('Prathamesh', age=28)
Person('Anvi', age=26)

What happened here: We passed in a name and a birth year, and __init__ stored both, then computed self.age from the current year. The numbers above are from a run in 2026, so 1998 gives 28 and 2000 gives 26. Run it next year and the ages bump up by one, because date.today() always reads the real date. That is the small catch with computing a value once: the age is frozen at the moment the object was created, not recalculated as time passes. For a quick snapshot that is fine. If you need an age that always stays current, compute it in a method or a @property instead, which the post on property decorators covers.

Factory Methods with @classmethod

Sometimes a single Python init constructor is not enough. Your data arrives in different shapes: a neat set of values today, a dash-separated string from a file tomorrow, a shortcut for the common case after that. Rather than cram all of it into __init__ with a pile of if checks, you add a @classmethod that prepares the data its own way and then calls the normal constructor. These are factory methods, and they read like named recipes: Employee.from_string(...) tells you exactly how that employee is being built.

Think of a coffee shop with one espresso machine but a labelled button for each drink. In the example below, three new employees join the company: Niranjan is hired directly with full details, Pravin’s record arrives as a dashed string from a file, and Viraj comes in through the intern shortcut.

📄 factory_methods.py: alternative constructors

class Employee:
    def __init__(self, name, age, department, salary):
        self.name = name
        self.age = age
        self.department = department
        self.salary = salary

    @classmethod
    def from_string(cls, data_string):
        """Create from 'name-age-dept-salary' format"""
        name, age, dept, salary = data_string.split("-")
        return cls(name, int(age), dept, int(salary))

    @classmethod
    def intern(cls, name, age, department):
        """Create intern with fixed salary"""
        return cls(name, age, department, salary=15000)

    def __repr__(self):
        return f"{self.name} ({self.department}) - Rs.{self.salary}"

# Three ways to create the same type of object
e1 = Employee("Niranjan", 27, "Engineering", 95000)
e2 = Employee.from_string("Pravin-30-Design-85000")
e3 = Employee.intern("Viraj", 24, "Marketing")

print(e1)
print(e2)
print(e3)

▶ Output

Niranjan (Engineering) - Rs.95000
Pravin (Design) - Rs.85000
Viraj (Marketing) - Rs.15000

What happened here: A @classmethod gets the class itself as its first argument, written as cls, instead of an instance. So calling cls(...) inside the method runs the regular __init__, exactly like writing Employee(...) would. from_string split the dashed text into pieces, turned the numbers into ints, and handed them to the constructor. intern filled in a fixed salary so you do not repeat it every time. All three objects came out of the same __init__, just reached by different doors. Using cls rather than hardcoding Employee also means subclasses get the right type back for free. The from_string style shows up constantly in real codebases that read rows from files or Application Programming Interfaces (APIs).

__new__ vs __init__: What Actually Happens

Here is the part people get tangled up on, so let us slow it down. Creating an object is really two steps, not one, and the init constructor is only the second. First __new__ builds an empty object and hands it back. Then __init__ takes that object and fills it in. Compare it to moving into a flat: __new__ hands you the keys to an empty room, and __init__ moves the furniture in. The little class below prints a line at each stage so you can watch the order with your own eyes.

📄 new_vs_init.py: the two-step creation process

class Traced:
    def __new__(cls, *args, **kwargs):
        print(f"  __new__: Allocating memory for {cls.__name__}")
        instance = super().__new__(cls)
        print(f"  __new__: Instance created (id: {id(instance)})")
        return instance

    def __init__(self, name):
        print(f"  __init__: Setting name to '{name}'")
        self.name = name
        print(f"  __init__: Initialization complete")

print("Creating object...")
obj = Traced("Rahul")
print(f"Result: {obj.name}")

▶ Output

Creating object...
  __new__: Allocating memory for Traced
  __new__: Instance created (id: 2935871473040)
  __init__: Setting name to 'Rahul'
  __init__: Initialization complete
Result: Rahul

What happened here: Read the output top to bottom and you can see the order plainly. __new__ runs first and builds the empty object (step 1). Then __init__ runs and sets self.name (step 2). Only after both finish does obj.name hold “Rahul”. The big number is the object’s id(), roughly its address in memory; yours will be a different number on every run and every machine, which is completely normal. The takeaway: nearly all the time you write __init__ only and never touch __new__. You reach for __new__ in rare cases like singletons, immutable types, or metaclass tricks. Skip it and Python quietly uses object.__new__ for you, which is exactly what you want.

Common Mistakes

Mistake 1: Returning a value from __init__

📄 mistake_return.py

class Bad:
    def __init__(self, x):
        self.x = x
        # return self.x  # TypeError: __init__() should return None, not 'int'

class Good:
    def __init__(self, x):
        self.x = x
        # __init__ returns None on its own, which is correct

Strictly speaking, __init__ is an initializer, not the thing that creates the object; __new__ already did that. __init__ just sets up attributes on an object that already exists, so it has nothing to return. Try to return any value other than None and Python raises TypeError: __init__() should return None. Leaving out a return entirely is exactly right, because a function with no return hands back None on its own.

Mistake 2: Not setting all attributes in __init__

📄 mistake_missing_attr.py

# BAD: attribute created in a method, not in __init__
class UserBad:
    def __init__(self, name):
        self.name = name

    def load_profile(self):
        self.bio = "Some bio"  # Created here, not in __init__

# user.bio raises AttributeError if load_profile() was never called

# GOOD: every attribute set in __init__
class UserGood:
    def __init__(self, name):
        self.name = name
        self.bio = None  # Exists from the start

    def load_profile(self):
        self.bio = "Some bio"

In the bad version, bio only springs into existence if someone happens to call load_profile() first. Read user.bio before that and Python raises AttributeError, because the attribute genuinely does not exist yet. The good version sets self.bio = None right in __init__, so the attribute is always there from the very first moment, even when it is empty. The fix costs one line and removes a whole class of “sometimes it works” bugs.

Best Practices

  • DO set every attribute in __init__, even if it starts as None
  • DO validate parameters early and raise descriptive errors
  • DO use None as default for mutable arguments (lists, dicts, sets)
  • DO use @classmethod for alternative constructors instead of overloading __init__
  • DON’T return values from __init__
  • DON’T do heavy I/O (file reads, API calls) in __init__; use a separate method or a factory instead

Conclusion

The Python init constructor, __init__, is where an object comes to life. Set every instance attribute here, even the ones that start as None. Check your inputs early and fail loudly when they are wrong. Use None as the default for any list, dict, or set, so each object gets its own copy instead of sharing one. When you need more than one way to build an object, write a @classmethod factory rather than stuffing extra logic into __init__. And __new__ is there for the rare singleton or immutable type, so you can happily set it aside for now.

Now that classes and constructors feel comfortable, the next step is the three kinds of methods. The class methods tutorial walks through instance methods, class methods, and static methods: what each one receives, when to use it, and why the difference actually matters in real code. And if you want to jump around or see everything this series covers, browse the full Python + AI/ML tutorial series home.

Practice Exercises

  1. Exercise 1: Write a BankAccount class. The __init__ takes an owner name and an optional starting balance that defaults to 0. Set every attribute in the constructor, then create two accounts to confirm they hold separate balances.
  2. Exercise 2: Add validation to that BankAccount: reject a non-numeric balance with TypeError and a negative starting balance with ValueError. Wrap a couple of bad calls in try/except and print the error messages.
  3. Exercise 3: Give the class a @classmethod factory named from_csv_row that takes a string like "Rahul,5000", splits it, converts the balance to a number, and returns a built account. This is the factory pattern from this post combined with the validation you added in Exercise 2.

Frequently Asked Questions

What is __init__ in Python?

__init__ is the Python init constructor, the initializer method that runs automatically when you create an object. It sets up instance attributes on the newly created object. It’s often called the ‘constructor’ but technically __new__ creates the object and __init__ initializes it.

What is the difference between __init__ and __new__ in Python?

__new__ creates and returns the empty object (allocates memory). __init__ receives that object as self and sets its attributes. You almost never need to override __new__; it is used only for singletons, immutable types, and metaclasses.

Why can’t I use a list as a default parameter in __init__?

Default arguments are evaluated once at function definition time, not at each call. A default [] creates one list shared by all instances. Use None as default and create a new list inside __init__: self.items = items if items is not None else [].

Can __init__ return a value?

No. __init__ must return None (implicitly). Returning anything else raises TypeError. __init__ initializes an object that already exists; it does not create or return objects.

What is a factory method in Python?

A factory method is a @classmethod that creates instances through alternative logic. For example, Employee.from_string('Rahul-28-Engineering-95000') parses a string and calls the regular constructor. It provides named, readable ways to create objects from different data formats.

Should I validate parameters in __init__?

Yes. Validate early, fail fast. If someone passes an invalid email or negative age, raise ValueError or TypeError in __init__ before the object exists. It’s much harder to debug bad data that surfaces three methods later.

Interview Questions on Python Constructors

Try each one aloud first. The phrasing you produce under mild pressure is what interviews measure.

Q: Every object of your class seems to share state: one user adds a task and a completely different user sees it in their list. What do you check first?

Two usual suspects. First, look for a mutable default argument like def __init__(self, tasks=[]): that list is created once at definition time and shared by every instance that relies on the default. Second, check whether the attribute was defined at class level (tasks = [] directly under class) instead of inside __init__, because class attributes are shared too. The fix in both cases is the same: default to None and create a fresh list per object inside __init__.

Q: Your __init__ makes a database call to load the user’s profile, and now unit tests are slow and flaky. How would you restructure it?

Get the I/O out of the constructor. Keep __init__ limited to assigning attributes, then either load the profile lazily in a separate method like load_profile(), or provide a @classmethod factory such as User.from_database(user_id) that does the fetch and then calls the plain constructor. Tests can then build a User directly with fake data and never touch the database. As a bonus, objects become cheap to create, which matters when you build thousands of them.

Q: Python has no method overloading, so how do you offer multiple ways to construct the same class?

Combine default parameter values with @classmethod factory methods. Defaults handle the “same data, some pieces optional” case in a single __init__. For genuinely different input shapes, write named factories like Employee.from_string(...) or Date.fromtimestamp(...) that convert their input and call cls(...). This is more readable than one overloaded constructor because the method name documents where the data comes from.

Q: A subclass defines its own __init__ but forgets to call super().__init__(). What actually goes wrong?

The parent’s __init__ simply never runs, so every attribute the parent would have set does not exist on the object. Nothing fails at creation time; the crash comes later as an AttributeError when a parent method reads an attribute it expected to find. The rule: if a subclass overrides __init__, it should call super().__init__(...) first and then add its own attributes.

Q: When would you genuinely need to override __new__ instead of __init__?

When the object’s value must be fixed at creation, or when you need to control whether a new object is created at all. Subclassing an immutable type like str or tuple requires __new__, because by the time __init__ runs the value is already frozen. The singleton pattern is the other classic case: __new__ can return an existing instance instead of allocating a new one. Everything else belongs in __init__.

Q: Is __init__ called when an object is copied or unpickled?

No. Both copy.copy() and pickle.load() rebuild the object by creating it through __new__-level machinery and restoring its __dict__ directly, skipping __init__ entirely. This surprises people who put side effects in the constructor, like registering the object somewhere or opening a connection, because those side effects never happen for copies. It is one more reason to keep __init__ a plain attribute-setting method.

Further reading: the official Python documentation is the authoritative source on this.

Previous: Python: OOP Concepts, Classes, Objects, Why OOP

Next: Python: Instance Methods, Class Methods, Static Methods

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *