Skip to content

Classes, Instances & __init__ Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on classes vs instances, __init__ vs __new__, the self parameter, instance vs class attributes, __repr__ vs __str__, and the object creation flow.

Read the in-depth guidePython Classes and Instances Explained — __init__ vs __new__, self, and Attributes(opens in new tab)
15 of 15

A class is a blueprint — it defines the attributes and methods that objects of that type will have. An instance is a concrete object built from that blueprint, with its own state. You write the class once and create many instances from it.

class Dog:                 # the blueprint
    def __init__(self, name):
        self.name = name   # per-instance state

rex = Dog("Rex")           # an instance
fido = Dog("Fido")         # a separate instance
rex.name                   # 'Rex'
fido.name                  # 'Fido' — independent state
type(rex)                  # <class '__main__.Dog'>

The class itself is also an object (of type type). Each instance carries its own data but shares the class's methods. Think of the class as the cookie cutter and the instances as the cookies.

__new__ creates and returns the new object; __init__ initializes that already-created object. __new__ runs first and is a static method that receives the class; __init__ runs second and receives the instance (self) it should configure. __init__ must return None.

class Widget:
    def __new__(cls, *args):
        print("__new__ — allocating")
        return super().__new__(cls)   # returns the instance
    def __init__(self, size):
        print("__init__ — configuring")
        self.size = size              # sets state on self

w = Widget(10)   # prints __new__ then __init__

You rarely override __new__ — it's mainly for immutable types (subclassing int/str/tuple), singletons, or metaclass tricks. For everyday classes, just use __init__.

self is the instance the method was called on — it's how a method accesses that object's attributes and other methods. It isn't a keyword; it's just the conventional name of the first parameter. Python passes the instance automatically when you call obj.method().

class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1        # self refers to this instance

c = Counter()
c.increment()                  # Python passes c as self
Counter.increment(c)           # exactly equivalent — self is explicit here

So c.increment() is sugar for Counter.increment(c). The explicitness is deliberate — Python makes the instance visible rather than hiding it like this in other languages.

A class attribute is defined in the class body and shared by every instance; an instance attribute is set on self (usually in __init__) and is unique per object. Attribute lookup checks the instance first, then falls back to the class.

class Dog:
    species = "Canis familiaris"   # class attribute — shared
    def __init__(self, name):
        self.name = name           # instance attribute — per-object

a, b = Dog("Rex"), Dog("Fido")
a.species                # 'Canis familiaris' (from the class)
a.name, b.name           # 'Rex', 'Fido' (independent)
a.species = "wolf"       # creates an instance attr that SHADOWS the class one
b.species                # still 'Canis familiaris'

Watch the classic trap: a mutable class attribute (like []) is shared and will leak state between instances — initialize mutable state in __init__.

__repr__ is the unambiguous, developer-facing representation — ideally something that could recreate the object — and is what you see in the REPL and in containers. __str__ is the readable, user-facing string used by print() and str(). If __str__ is missing, Python falls back to __repr__.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"   # for developers
    def __str__(self):
        return f"({self.x}, {self.y})"            # for users

p = Point(1, 2)
print(p)     # (1, 2)        — __str__
repr(p)      # 'Point(x=1, y=2)'  — __repr__
[p]          # [Point(x=1, y=2)]  — containers use __repr__

Rule of thumb: always define __repr__; add __str__ only when you need a distinct friendly form.

Calling ClassName(args) invokes the class's metaclass __call__, which orchestrates two steps: it calls __new__(cls, args) to allocate the object, then — if __new__ returned an instance of cls — calls __init__(instance, args) to initialize it, and finally returns the instance.

class Demo:
    def __new__(cls, *a):
        print("1. __new__")
        return super().__new__(cls)
    def __init__(self, *a):
        print("2. __init__")

d = Demo()       # prints: 1. __new__  then  2. __init__
# 3. d is now bound to the fully initialized instance

Key subtlety: if __new__ returns an object that is not an instance of the class, __init__ is skipped entirely. For normal classes you never see this machinery — you just call the class and get back a ready object.

By default each instance keeps its attributes in a per-object dictionary, __dict__. Setting self.x = 1 writes into that dict; this is why you can add attributes dynamically at runtime.

class P:
    def __init__(self):
        self.x = 1
p = P()
p.__dict__            # {'x': 1}
p.y = 2               # dynamically add an attribute
p.__dict__            # {'x': 1, 'y': 2}
vars(p)               # same as p.__dict__

Rule of thumb: instance attributes live in __dict__ — flexible, but it costs memory; use __slots__ to remove it when you have many small objects.

A @classmethod receives the class as its first argument (cls) — ideal for alternative constructors. A @staticmethod receives nothing automatic — it's a plain function grouped under the class for namespacing.

class Date:
    def __init__(self, y, m, d):
        self.y, self.m, self.d = y, m, d
    @classmethod
    def from_string(cls, s):           # alt constructor
        return cls(*map(int, s.split("-")))
    @staticmethod
    def is_leap(year):                  # utility, no self/cls
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

Date.from_string("2026-06-19")
Date.is_leap(2024)                      # True

Rule of thumb: use classmethod when you need cls (factories, subclass-aware); staticmethod for related helpers that touch neither instance nor class.

These built-ins access attributes by name string at runtime: getattr(obj, "x") reads (with an optional default), setattr(obj, "x", v) writes, hasattr tests existence. Useful for dynamic/config-driven code.

class C: pass
c = C()
setattr(c, "speed", 5)        # c.speed = 5
getattr(c, "speed")           # 5
getattr(c, "missing", 0)      # 0 — default avoids AttributeError
hasattr(c, "speed")           # True
delattr(c, "speed")           # remove it

Rule of thumb: reach for getattr/setattr when the attribute name is computed or data-driven; otherwise use plain dot access.

No — __init__ must return None. Returning anything else raises TypeError. Its job is to mutate self in place, not to produce the object (that's __new__'s role).

class Bad:
    def __init__(self):
        return 42        # TypeError: __init__ should return None

class Good:
    def __init__(self, x):
        self.x = x       # configure self, return nothing

Rule of thumb: __init__ sets up state on self and implicitly returns None; if you need to control what object comes back, override __new__.

Defining __eq__ sets __hash__ to None, making instances unhashable (can't go in sets/dict keys) unless you also define __hash__. Python does this because equal objects must have equal hashes.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)
    def __hash__(self):
        return hash((self.x, self.y))   # restore hashability, consistent with __eq__

{Point(1, 2)}        # works only because __hash__ is defined

Rule of thumb: if you implement __eq__ and need the object hashable, implement a consistent __hash__ over the same fields — or use @dataclass(frozen=True).

A leading double underscore (no trailing) triggers name mangling: inside class C, self.__x becomes self._C__x. It's not true privacy but avoids accidental clashes in subclasses.

class Base:
    def __init__(self):
        self.__secret = 1        # stored as _Base__secret
b = Base()
b.__secret                       # AttributeError
b._Base__secret                  # 1 — accessible if you know the mangled name

Rule of thumb: use single _name for "internal, please don't touch"; reserve __name mangling for attributes you must protect from subclass name collisions.

Accessing a method through an instance gives a bound method — the instance is pre-bound as self. Accessing it through the class gives the plain function, so you must pass the instance explicitly.

class C:
    def greet(self): return "hi"

c = C()
c.greet            # <bound method C.greet of <C object>>
c.greet()          # "hi" — self is c automatically
C.greet            # <function C.greet> — plain function
C.greet(c)         # "hi" — pass self manually
m = c.greet; m()   # "hi" — bound method remembers c

Rule of thumb: instance.method captures the instance (a bound method you can store and call later); Class.method is just the function.

A class is an instance of its metaclass (normally type). So classes are first-class objects: you can assign them to variables, pass them to functions, store them in lists, and even create them at runtime with type(name, bases, dict).

class A: pass
type(A)            # <class 'type'> — A is an instance of type
isinstance(A, object)   # True

registry = {"a": A}      # store classes like any value
Dynamic = type("Dynamic", (), {"x": 1})   # build a class at runtime
Dynamic().x              # 1

Rule of thumb: classes are objects produced by type; treating them as values enables factories, registries, and metaclass-based frameworks.

__del__ is a finalizer called when an object is about to be garbage-collected — not a deterministic destructor. Its timing is unpredictable (especially with reference cycles), exceptions in it are ignored, and it may not run at all at interpreter exit.

class Resource:
    def __del__(self):
        print("cleanup")     # runs whenever GC decides — maybe never

r = Resource(); del r        # may or may not print immediately

Rule of thumb: don't rely on __del__ for cleanup — use context managers (__enter__/__exit__) or explicit close(); reach for __del__ only as a last-resort safety net.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel