Filter concepts by levelShowing all levels.

Python · Python Internals

Object model

Concepts
7

Every value in Python is an object with an identity, a type, and attributes. What makes two objects the same object versus merely equal, whether an object can change in place, and what it means for an object to be usable as a dict key.

Objects, names, and references

The foundational fact everything else in this section builds on, and what a name actually is underneath it.

Everything is an object

corebeginner

In Python, a number, a string, a function, a class, and a module are all objects — each has a type, an identity, and can carry attributes. There is no separate category of "primitive" value that behaves differently.

Think of it as

A warehouse where every single item, no matter how small, sits on its own labelled shelf with a tag saying what kind of thing it is. There is no pile of loose, tag-free items on the floor — even the number 5 has a shelf, a type, and an address.

python
type(5)          # <class 'int'>
type(print)      # <class 'builtin_function_or_method'>
type(int)        # <class 'type'> — a class is an object too
isinstance(5, object)   # True, for any value at all

What we're doing: Confirm that a number, a function, and a class itself all report a type and count as objects.

everything.pypython
def add(a, b):
    return a + b

values = [5, "x", print, int, add]
for v in values:
    print(type(v).__name__, isinstance(v, object))
1
add is a function — still an object, with its own type.
4
values mixes a number, a string, a built-in, a class, and a user-defined function.
5
isinstance(v, object) is True for every one of them — object sits at the root of every type.
Output
int True
str True
builtin_function_or_method True
type True
function True

Why this works: Python has no separate notion of a raw, un-typed value the way some languages treat integers or booleans as special. Every value — including int and add itself — is built by some class, and type() always answers with that class. That uniformity is why a function can be stored in a list, passed to another function, or given attributes: it is not a special case, just another object.

Assuming numbers are a special, non-object primitive

Wrong

python
x = 5
print(x.bit_length)   # forgetting it needs a call — this just shows the method itself

Better

python
x = 5
print(x.bit_length())   # 3 — int objects carry real methods, like any other object

What you see: The wrong version prints <built-in method bit_length of int object at 0x...> — a method object, not the answer — because x.bit_length is only a lookup, not a call.

Why: x is an int object, and int defines methods like any other class — bit_length() is one of them. Forgetting the () is the same mistake as forgetting it on any other object's method; numbers are not exempt from being objects with real, callable attributes.

One category, no exceptions

5, "x", a function, a class

every one of these is an object

type(x)

reports which class built it — always answers something

isinstance(x, object)

always True — object is the root of every type

  1. 5, "x", a function, a class — every one of these is an object
  2. type(x) — reports which class built it — always answers something
  3. isinstance(x, object) — always True — object is the root of every type

type() applied across Python's usual categories

type() applied across Python's usual categories
Valuetype(value)
5<class 'int'>
"x"<class 'str'>
print<class 'builtin_function_or_method'>
int<class 'type'>
def f(): pass<class 'function'>

Together

python
print(type(5))
print(type("x"))
print(type(print))
print(type(int))
print(isinstance(5, object))

Remember: type(x) answers for any value — a number, a function, or a class. isinstance(x, object) is always True. There is no non-object value in Python.

See also: names vs objects and references · object identity · function objects

Names vs objects, and references

corebeginner

A name like x is a label pointing at an object, not a container holding one. x = y makes x point at the same object y already points at — no copy is made. del x only removes the label; the object survives if another name still points at it.

Think of it as

Objects float in a room; names are sticky labels you attach to them. x = [1, 2, 3] sticks the label x onto a new list object. y = x sticks a second label, y, onto that exact same object — there is still only one list. del x peels the x label off; the object stays in the room as long as any label remains on it.

python
x = [1, 2]     # x references a new list object
y = x          # y references the SAME object — not a copy
del x          # removes the name x; the object survives via y
print(y)       # [1, 2] — unaffected by deleting x

What we're doing: Bind two names to one object, delete one name, and confirm the object outlives it because the other name still references it.

names.pypython
tags = ["core", "beta"]
also_tags = tags

print(tags is also_tags)
del tags

print(also_tags)
try:
    print(tags)
except NameError as e:
    print(e)
1
tags references a new list object.
2
also_tags references the exact same object — no copy is made.
4
del tags removes only the name tags from the namespace.
6
also_tags still references the list — it was never affected by deleting the other name.
8
tags no longer exists as a name — looking it up raises NameError, not an error about the object.
Output
True
['core', 'beta']
name 'tags' is not defined

Why this works: tags is also_tags confirms both names reference the same object before anything is deleted. del tags removes the binding between the name tags and that object in the current namespace — it says nothing about the object itself. The list is still alive because also_tags references it, so Python's reference counting (see memory management) has no reason to free it. Only tags, the name, stops existing.

Believing del removes the object, not just the name

Wrong

python
cache = {"user": "loaded"}
active = cache
del cache
print(active["user"])   # assumed this would fail — it does not

Better

python
cache = {"user": "loaded"}
active = cache
del active
del cache   # only now is the object unreferenced and eligible for collection

What you see: del cache does not raise, and active["user"] still works — the "deleted" data is still fully accessible, which surprises anyone expecting del to destroy the object.

Why: del only unbinds a name from the current namespace. The dict object it pointed to is untouched as long as any other name (active, here) still references it. To make the object itself eligible for cleanup, every name referencing it has to be removed.

Two labels, one object

x = [1, 2]

one list object; name x references it

y = x

a second name, same object — no copy

del x

removes the label x — y keeps the object alive

  1. x = [1, 2] — one list object; name x references it
  2. y = x — a second name, same object — no copy
  3. del x — removes the label x — y keeps the object alive

What each operation does to names vs objects

What each operation does to names vs objects
CodeEffect
x = [1, 2]creates a list object, binds the name x to it
y = xbinds a SECOND name, y, to the same object — no new list
x.append(3)mutates the one shared object — visible through y too
x = [9]rebinds x to a NEW object — y still points at the old one
del xremoves the name x — the object survives because y still references it

Together

python
x = [1, 2]
y = x
x.append(3)
print(y)          # [1, 2, 3] — same object, seen through y
x = [9]
print(y)          # [1, 2, 3] — y is untouched, x now points elsewhere
del x
print(y)          # still [1, 2, 3] — deleting x never touched the object

Remember: A name references an object; it never contains one. del removes a name, not necessarily the object — it survives if another name references it.

See also: everything is an object · object identity · reference counting · mutable vs immutable · object overhead and references

Advertisement

Identity and equality

Two different questions a comparison can ask — whether two names point at the same object, or whether an object considers itself equal to another.

Object identity

standardintermediate

Every object has an identity — id(obj) — unique among currently-alive objects and constant for its whole lifetime. In CPython, id() happens to return the memory address, but only CPython promises that.

Think of it as

A museum accession number stamped on an item the moment it enters the collection. The number never changes while the item is on display, no two items on display share one, but once an item leaves (is destroyed), its number can be handed to something new — id() only promises uniqueness among objects alive right now.

python
id(obj)          # an int, unique among currently-alive objects
a is b            # shorthand for id(a) == id(b)
id(a) == id(b)    # the literal check "is" performs

What we're doing: Show that id() stays constant across an object's life, differs between two separately-built objects with equal values, and can be reused after one object is freed.

identity.pypython
values = [1, 2]
before = id(values)
values.append(3)
after = id(values)
print(before == after)

a = [1, 2, 3]
b = [1, 2, 3]
print(a is b)
print(id(a) == id(b))
print(a == b)
2
id(values) captured before any mutation.
3
append() mutates in place — the object itself never changes identity.
4
The identity captured after the mutation is checked against the one before.
8
Two separately-built lists with equal contents — different objects, so different identities.
Output
True
False
False
True

Why this works: values.append(3) mutates the list in place, so id(values) reads the same before and after — identity tracks the object, not its current contents. a and b are two separate calls that each build a new list, so even though they hold equal values, they get two different identities: a is b and id(a) == id(b) both report False, while a == b (a value comparison) reports True. Identity and equality answer different questions.

Comparing id() across two separate Python runs

Wrong

python
# run 1: print(id(x))   -> e.g. 140704...
# run 2: print(id(x))   -> a DIFFERENT number, same code
# assuming these numbers mean anything compared to each other

Better

python
# id() is only meaningful WITHIN one running process.
# To compare two objects for identity, use "is" inside the same run —
# never persist or compare raw id() values across runs.
a is b

What you see: No error — id() always returns a plausible-looking int — but the specific numbers carry no meaning once the process that produced them has exited.

Why: id()'s only formal guarantee is uniqueness among objects alive in the current process. CPython's implementation happens to reuse a freed object's memory address for a new object, and addresses naturally differ between runs due to memory layout, ASLR, and unrelated allocations. Treating a raw id() value as a stable identifier beyond one process is relying on an implementation detail the language does not promise.

What id() promises, and what it does not

What id() promises, and what it does not
ClaimTrue?
Unique among objects alive right nowyes — guaranteed by the language
Stable for one object across its whole lifetimeyes — never changes while the object exists
Equal to the memory addressonly in CPython — not part of the language spec
Never reused after an object is destroyedno — a freed id can be reassigned to a new object
Comparable across two separate program runsno — meaningless outside a single running process

Together

python
a = object()
first_id = id(a)
del a
b = object()
second_id = id(b)
first_id == second_id   # can be True — a's id was freed and may be reused

Remember: id(obj) is unique among live objects, stable for its lifetime — a freed id can be reused, and comparing ids across runs is meaningless.

See also: is vs equals · names vs objects and references · reference counting

Equality

standardintermediate

a == b calls a.__eq__(b) and trusts whatever it returns. A plain class inherits __eq__ from object, which falls back to identity — so two instances with equal fields are NOT equal until the class defines its own __eq__.

Think of it as

== is a question you hand to the left operand: "do you consider yourself equal to this?" object's default answer is "only if you are literally me" — the same thing is checks. Defining __eq__ on a class replaces that default answer with a real comparison of whatever fields actually matter.

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

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

What we're doing: Compare two instances of a plain class (no __eq__) against two instances of a class that defines one, using identical field values in both cases.

equality.pypython
class PlainPoint:
    def __init__(self, x, y):
        self.x, self.y = x, y


p1, p2 = PlainPoint(1, 2), PlainPoint(1, 2)
print(p1 == p2)


class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y


q1, q2 = Point(1, 2), Point(1, 2)
print(q1 == q2)
1
PlainPoint defines no __eq__, so it inherits object's identity-based default.
7
Two separately-built PlainPoint instances with equal fields are still NOT equal — the default __eq__ only checks identity.
13
Point defines __eq__, comparing x and y explicitly instead of falling back to identity.
20
Two separately-built Point instances with equal fields ARE equal, because __eq__ says so.
Output
False
True

Why this works: PlainPoint inherits __eq__ from object, whose default behaves exactly like is — two different instances are never equal, no matter what their attributes hold. Point overrides __eq__ to compare x and y directly, so a == b now answers a real question about the data instead of about identity. Nothing about class syntax makes equality automatic — it is opted into, one class at a time, by defining __eq__.

Expecting two instances with equal attributes to compare equal by default

Wrong

python
class Money:
    def __init__(self, amount):
        self.amount = amount

wallet_a = Money(50)
wallet_b = Money(50)
if wallet_a == wallet_b:
    print("same balance")
else:
    print("different")   # this branch runs — surprising

Better

python
class Money:
    def __init__(self, amount):
        self.amount = amount
    def __eq__(self, other):
        return isinstance(other, Money) and self.amount == other.amount

wallet_a = Money(50)
wallet_b = Money(50)
if wallet_a == wallet_b:
    print("same balance")   # this branch runs now

What you see: Two objects that look identical in every attribute compare unequal — no exception, just the wrong branch running silently.

Why: Without an __eq__ override, Money inherits object's default, which is identity-based — wallet_a and wallet_b are two different objects, so == reports False regardless of their amount fields. A class has to explicitly define what "equal" means for its own data; Python never infers it from the attributes present.

Where a == b gets its answer

Where a == b gets its answer
Class definesa == b behaves like
nothing (plain object subclass)a is b — identity, by default
__eq__ comparing fieldsTrue whenever the fields compare equal, regardless of identity
__eq__ returning NotImplementedPython retries with b.__eq__(a) before falling back to False
__eq__ but no __hash__still compares correctly, but hash(a) raises TypeError

Together

python
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

p1, p2 = Point(1, 2), Point(1, 2)
p1 == p2, p1 is p2   # (True, False)

Remember: a == b calls a.__eq__(b). object's default __eq__ is identity-based — a class must define its own __eq__ before equal-looking instances compare equal.

See also: is vs equals · object identity · hashability · equality dunders

Advertisement

Mutability, hashability, and lifecycle

Whether an object can change in place, what that means for using it as a dict key, and the full path from creation to destruction.

Mutability and immutability

corebeginner

Mutability is a property of an object's TYPE, not of one specific value: a list can always change in place, a tuple never can. A tuple holding a list is still immutable itself — id(t) never changes — even though what it holds can.

Think of it as

A sealed box (immutable) versus an open shelf (mutable). Sealing the box only guarantees the box itself will not be swapped for another box — if the box holds an open shelf, that shelf can still be rearranged without breaking the seal on the box around it.

python
t = (1, [2, 3])
t[1].append(4)     # legal — mutates the list t[1] points to
t[0] = 99           # TypeError — the tuple itself cannot be reassigned

What we're doing: Confirm a tuple's own identity never changes, while a mutable object it holds can still be edited in place.

mutability.pypython
record = (1, ["core", "beta"])
before = id(record)

record[1].append("gamma")
after = id(record)

print(record)
print(before == after)

try:
    record[0] = 2
except TypeError as e:
    print(e)
1
record is a tuple — immutable itself — holding a list, which is not.
4
record[1].append mutates the list in place; it does not touch the tuple.
8
The tuple's own identity is unchanged — mutating its contents never rebuilds the tuple.
11
Assigning to record[0] is blocked outright — the tuple itself truly cannot change.
Output
(1, ['core', 'beta', 'gamma'])
True
'tuple' object does not support item assignment

Why this works: A tuple's immutability is a guarantee about the tuple object itself — which items it references never changes after creation. It says nothing about what those items can do on their own. record[1] is a reference to a list object, and lists remain mutable regardless of what container holds a reference to them. Only record[0] = 2 (rebinding a slot inside the tuple) is blocked; record[1].append(...) never touches the tuple's slots at all.

Treating a tuple as a deep guarantee of unchangeable data

Wrong

python
DEFAULT_CONFIG = ("v1", ["debug", "verbose"])   # looks locked down


def start(overrides=None):
    config = DEFAULT_CONFIG
    if overrides:
        config[1].append(overrides)   # mutates the SHARED list
    return config


start("trace")
print(DEFAULT_CONFIG)   # the "default" changed globally

Better

python
DEFAULT_CONFIG = ("v1", ("debug", "verbose"))   # tuple all the way down


def start(overrides=None):
    config = DEFAULT_CONFIG
    if overrides:
        config = (config[0], config[1] + (overrides,))   # builds a new tuple
    return config


start("trace")
print(DEFAULT_CONFIG)   # untouched

What you see: DEFAULT_CONFIG prints ("v1", ["debug", "verbose", "trace"]) — a module-level "default" was mutated by a single call meant to be scoped to one function.

Why: DEFAULT_CONFIG being a tuple only guarantees the tuple never points at a different list — it does nothing to protect what that list itself contains. config[1] is the exact same list object every time DEFAULT_CONFIG is read, so .append() mutates it for every future reader. Nesting only immutable types (a tuple of tuples, not a tuple of lists) is what actually makes a "constant" safe.

Immutable container, mutable contents

The tuple itself

  • +id(t) never changes
  • +t[0] = x always raises TypeError
  • +The tuple can never point at a different item

A mutable item inside it

  • t[1] is still the same list object
  • t[1].append(x) mutates that list in place
  • The tuple's contents (as printed) change even though it never "changed"
  • The tuple itself
    • id(t) never changes
    • t[0] = x always raises TypeError
    • The tuple can never point at a different item
  • A mutable item inside it
    • t[1] is still the same list object
    • t[1].append(x) mutates that list in place
    • The tuple's contents (as printed) change even though it never "changed"

Mutable and immutable, by Python's built-in types

Mutable and immutable, by Python's built-in types
TypeMutable?Consequence
int, float, bool, str, bytesnoevery "change" (x += 1) rebinds to a new object
tuple, frozensetnonever changes itself — but a tuple can hold a mutable item
list, bytearrayyesappend/sort/etc. mutate in place — id() stays the same
dict, setyesupdate/add/etc. mutate in place — id() stays the same

Together

python
t = (1, [2, 3])          # the tuple is immutable...
t[1].append(4)           # ...but the list inside it is not
print(t)                 # (1, [2, 3, 4]) — t's contents changed, its identity did not
try:
    t[0] = 99             # this IS blocked — the tuple itself never changes
except TypeError as e:
    print(e)

Remember: Mutability is a property of the TYPE, not the value. A tuple's own identity never changes — but a mutable object it holds can still be edited in place.

See also: mutable vs immutable · object identity · copying objects

Hashability (the default, identity-based hash)

standardintermediate

A plain class (no __eq__) is hashable by default, hashing by id(), not fields — two instances with identical attributes get DIFFERENT hashes. Defining __eq__ removes that default and requires a matching __hash__.

Think of it as

object's default __hash__ is a locker number based on which specific locker you were assigned — not what is inside it. Two lockers holding identical contents still get different numbers, because the numbering never looks inside. Defining __eq__ says "compare by contents instead," which is exactly why Python then disables the old locker-number hash until you supply one that agrees with the new definition of equal.

python
class Plain:
    pass
hash(Plain())          # works — id-based default hash

class WithEq:
    def __eq__(self, other): ...
hash(WithEq())          # TypeError — __eq__ disabled the default hash

What we're doing: Hash two plain instances with identical (empty) state, then define __eq__ on a second class and watch hashing break until __hash__ is restored.

default_hash.pypython
class Plain:
    pass

a, b = Plain(), Plain()
print(hash(a) == hash(b))
print(a == b)


class Tag:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return isinstance(other, Tag) and self.name == other.name

try:
    hash(Tag("core"))
except TypeError as e:
    print(e)
1
Plain defines no __eq__ — it keeps object's default hash, based on id().
5
a and b are different objects, so their id-based hashes differ, even though both have no state to compare.
6
a == b is False too — object's default __eq__ is also identity-based.
9
Tag defines __eq__, comparing name — this silently disables the inherited __hash__.
15
hash(Tag("core")) now raises, because Python set Tag.__hash__ to None the moment __eq__ was defined.
Output
False
False
unhashable type: 'Tag'

Why this works: object's default __hash__ derives from id(self), which is exactly why hash(a) == hash(b) is False here — a and b are different objects despite having identical (empty) state. That default is internally consistent with object's default __eq__ (also identity-based), so the required rule — equal objects must hash equal — holds automatically. The moment Tag defines its own __eq__ based on name instead of identity, that consistency would break unless __hash__ is redefined to match, so Python disables hashing entirely rather than leave a broken contract in place.

Expecting a plain class's default hash to reflect its attributes

Wrong

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

seen = set()
seen.add(Point(1, 2))
print(Point(1, 2) in seen)   # assumed True — it is False

Better

python
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
    def __hash__(self):
        return hash((self.x, self.y))

seen = set()
seen.add(Point(1, 2))
print(Point(1, 2) in seen)   # True — now hashes and compares by value

What you see: Point(1, 2) in seen is False even though a Point(1, 2) was already added — because the default hash and equality are both id-based, so a NEW Point(1, 2) is simply a different object.

Why: Without __eq__ and __hash__ defined, Point uses object's defaults — hashing and comparing by identity, not by x and y. A set can only recognize a value as "already seen" if an equal item hashes to the same bucket, and the default behaviour never considers two separately-built Points equal, no matter their coordinates.

A class's hash, before and after defining __eq__

A class's hash, before and after defining __eq__
Class defineshash(instance)Two equal-field instances
nothingbased on id() — every instance differshash differently, compare unequal (is-based)
__eq__ onlyTypeError: unhashable typecompare equal, but cannot be hashed at all
__eq__ and matching __hash__based on the same fields __eq__ comparescompare equal AND hash equal

Together

python
class Plain:
    pass

a, b = Plain(), Plain()
hash(a) == hash(b)   # False — id-based, and a, b are different objects

class WithEq:
    def __init__(self, n):
        self.n = n
    def __eq__(self, other):
        return self.n == other.n

hash(WithEq(1))   # TypeError: unhashable type: 'WithEq'

Remember: A plain class hashes by id() — equal-looking instances hash differently. Defining __eq__ disables that hash until a matching __hash__ is supplied.

See also: hashability · equality · object identity · hash dunder

Object lifecycle

standardintermediate

An object is created by __new__ then __init__, lives as long as a reference to it exists, and is destroyed the moment its reference count hits zero — which calls __del__ if the class defines one.

Think of it as

A rental car: __new__ builds it, __init__ fits the seats and mirrors for this driver, every active booking is a reference keeping it on the road, and the moment the last booking ends the car is sent to be scrapped — __del__ is that scrapping step, if the fleet defines one.

python
class Resource:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"cleaning up {self.name}")

r = Resource("db-connection")
del r   # refcount reaches 0 here -> __del__ runs

What we're doing: Trace a single object through creation, an added reference, removing both references, and confirm __del__ only fires once the last reference is gone.

lifecycle.pypython
class Tracked:
    def __init__(self, name):
        self.name = name
        print(f"created {self.name}")

    def __del__(self):
        print(f"destroyed {self.name}")


obj = Tracked("session")
alias = obj
print("still alive, two references")

del obj
print("one reference removed, still alive")

del alias
print("this line never actually prints first — see output")
10
__new__ then __init__ run — the object is created, refcount becomes 1.
11
alias = obj adds a second reference — refcount becomes 2.
14
del obj removes one reference — refcount drops to 1. The object is still alive, so __del__ does not run yet.
17
del alias removes the last reference — refcount hits 0, and __del__ runs immediately, before the next print.
Output
created session
still alive, two references
one reference removed, still alive
destroyed session
this line never actually prints first — see output

Why this works: CPython destroys an object the instant its reference count reaches zero — not later, not on a schedule. del obj only removes ONE reference (the alias still holds one), so __del__ does not run yet. del alias removes the last remaining reference, dropping the count to zero right then, which is why 'destroyed session' prints immediately, ahead of the final print statement that follows it in the source.

Relying on __del__ to run at a predictable, timely moment

Wrong

python
class FileHandle:
    def __init__(self, path):
        self.file = open(path, "w")

    def __del__(self):
        self.file.close()   # "cleanup" relying on garbage collection timing

def write_log(path, message):
    handle = FileHandle(path)
    handle.file.write(message)
    # no explicit close — hoping __del__ handles it soon

Better

python
class FileHandle:
    def __init__(self, path):
        self.file = open(path, "w")

    def close(self):
        self.file.close()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

def write_log(path, message):
    with FileHandle(path) as handle:
        handle.file.write(message)
    # closed deterministically here, not whenever GC gets to it

What you see: The file can stay open far longer than expected — CPython usually frees an unreferenced object almost immediately via reference counting, but a reference cycle or a held reference elsewhere can delay it indefinitely, and other Python implementations make no such promise at all.

Why: __del__ runs when the reference count happens to hit zero, which depends on every other reference to the object also being gone — code elsewhere in a real program can easily hold on to a reference longer than expected. A context manager (__enter__/__exit__) or an explicit close() makes cleanup happen at a specific point in the code, not whenever the interpreter gets around to it.

An object's life, start to end

__new__

allocates a blank object of the right class

__init__

sets up its starting attributes

referenced

stays alive as long as its refcount is above zero

__del__

runs once the refcount hits zero, right before the memory is freed

  1. __new__ — allocates a blank object of the right class
  2. __init__ — sets up its starting attributes
  3. referenced — stays alive as long as its refcount is above zero
  4. __del__ — runs once the refcount hits zero, right before the memory is freed

An object's life, stage by stage

An object's life, stage by stage
StageWhat happens
Name(...)__new__ allocates the object; __init__ sets up its attributes
In usereference count rises and falls as names/containers reference it
Last reference gonereference count reaches zero
Destruction__del__ runs (if defined), then the memory is reclaimed

Together

python
class Tracked:
    def __init__(self, name):
        self.name = name
        print(f"created {name}")
    def __del__(self):
        print(f"destroyed {self.name}")

obj = Tracked("a")   # __new__, then __init__ -> "created a"
del obj                # refcount hits 0 -> __del__ -> "destroyed a"

Remember: An object lives while its refcount is above zero. __del__ runs at zero — not on a schedule, and not reliably for a cycle without gc.collect().

See also: object creation dunders · reference counting · garbage collection · cyclic references

Advertisement