Python Object Lifecycle: From Creation to Cleanup Explained

Python Object Lifecycle: From Creation to Cleanup Explained — cover image

Introduction

Consider this:

def create_user():
    user = {"name": "Ada"}
    return user
profile = create_user()

The dictionary does not vanish when the function is finished. The item is still referred to by profile even tho the local name user has vanished.

This is the key to understanding the Object Lifecycle. The Python life cycle describes what happens during program execution, but an object's lifetime depends on the references that continue to reach it. This article follows an object from creation and initialization to use, changing references, becoming unreachable, and cleanup, while also explaining how reference counting and Garbage Collection fit into that journey.

Python Program Lifecycle

Python Program Lifecycle

The Python program lifecycle describes the broader journey of a running Python program, from starting execution to completion.

At a high level, a Python program life cycle looks like this:

Program starts
      ↓
Python executes code
      ↓
Statements and functions run
      ↓
Objects are created and used
      ↓
Control flow continues
      ↓
Program finishes

During this program life cycle in Python, the program creates many objects, strings, lists, dictionaries, functions, class instances, and other values. These objects have their own lifecycles within the larger program lifecycle.

For example:

name = "Ada"
numbers = [10, 20, 30]
print(name)
print(numbers)

The life cycle of python program concerns the execution of these statements. The object lifecycle concerns what happens to "Ada" and the list as references to them are created, changed, or removed.

So, the life cycle of Python code and the lifecycle of a Python object are connected, but they are not the same concept.

Python Object Lifecycle vs Python Program Lifecycle

Python Object Lifecycle vs Python Program Lifecycle

The Python program lifecycle describes the journey of the running program, while the Object Lifecycle describes the journey of an individual object created during that execution.

Consider:

def create_user():
    user = {"name": "Ada"}
    return user
profile = create_user()

The function executes as part of the program lifecycle.

But the dictionary has its own lifecycle:

Create object
     ↓
Initialize object
     ↓
Use object
     ↓
References change
     ↓
Object becomes unreachable
     ↓
Cleanup

The local name user vanishes with the return of create_user(). The dictionary may still be accessed via the profile, tho.

This distinction is important because an object's lifetime is not determined simply by the scope in which it was created. Other references can keep it alive.

For example:

users = []
def create_user():
    user = {"name": "Ada"}
    users.append(user)
create_user()

The function has finished, but the dictionary remains reachable because the users list contains a reference to it.

In short:

Program lifecycle = what happens to the running program.
Object lifecycle = what happens to an individual object within that program.

What Is an Object Lifecycle?

An object lifecycle is the sequence of stages an object goes through from creation until its memory can eventually be reclaimed.

A useful model is:

Creation
   ↓
Initialization
   ↓
Use
   ↓
References change
   ↓
Unreachable state
   ↓
Cleanup

Object Creation vs Object Initialization

Object Creation

The process of getting the thing itself is called creation.

user = User("Ada")

Python creates a User object as part of evaluating this expression.

Object Initialization

Initialization establishes the object's initial state.

class User:
    def __init__(self, name):
        self.name = name
user = User("Ada")

The User instance is created and its initialization logic runs, setting its initial name attribute.

Conceptually:

Create object
     ↓
Initialize object
     ↓
Bind name to object

These stages should not be treated as identical.

This distinction becomes especially useful when learning Python's object model, including __new__() and __init__(). __new__() is involved in creating an instance, while __init__() initializes an already-created instance.

class User:
    def __new__(cls, name):
        print("Creating object")
        return super().__new__(cls)
    def __init__(self, name):
        print("Initializing object")
        self.name = name
user = User("Ada")

The output demonstrates the order:

Creating object
Initializing object

So the simplest mental model is:

Initialization gives the object its initial state; creation gives you the object.

Names Do Not Contain Objects

This is one of the most important ideas in understanding Python memory.

user = {"name": "Ada"}

A useful conceptual model is:

user ──→ dictionary ──→ "Ada"

The name user is not the dictionary. It refers to the dictionary.

Now:

profile = user

produces:

user    ──┐
                ├──→ dictionary
profile ──┘

No second dictionary was created.

So:

del user
print(profile)

still works:

{'name': 'Ada'}

One reference is eliminated when a user is removed. The dictionary may still be accessed via the profile.

How References Keep Objects Alive

As long as there is a live path to an item, it may be used.

items = []
other = items

Both names refer to the same list:

items ──┐
               ├──→ list
other ──┘

If you run:

del items

the list is still reachable through other.

Only after the remaining references disappear can the object become unreachable, assuming no other references exist.

This is why asking:

"Did I delete the variable?"

is not enough.

The better question is:

"What references still reach this object?"

The Use Stage of an Object

After creation and initialization, an object enters its normal use stage.

It can be:

  • Read
  • Mutated
  • Passed to functions
  • Returned from functions
  • Stored in containers
  • Referenced by closures
  • Attached to other objects

For example:

def add_item(target, value):
    target.append(value)

items = []
add_item(items, "Python")

During the function call:

items  ──→ list
target ──→ same list

When add_item() returns, the local reference target disappears. The list remains because items still refers to it.

How Function Calls Affect Object Lifetime

During a call, function parameters generate extra references.

def inspect(value):
    print(value)

data = [1, 2, 3]
inspect(data)

While inspect() executes:

data  ──→ list
value ──→ same list

The parameter value temporarily refers to the same object.

When the function returns, that local reference disappears.

But data still refers to the list:

data ──→ list

Now compare:

inspect([1, 2, 3])

The list is created and passed directly to the function. If no other reference is saved, it can become unreachable after the call.

Returning an Object Can Extend Its Lifetime

def build_user():
    user = {"name": "Ada"}
    return user

result = build_user()

Inside the function:

user ──→ dictionary

After the function returns:

result ──→ dictionary

The local name user disappears, but the dictionary survives.

Why? There is now another reference for the returned item.

Containers Can Extend Object Lifetimes

users = []

def add_user(name):
    user = {"name": name}
    users.append(user)

add_user("Ada")

Inside the function:

user ──→ dictionary

After:

users.append(user)
the relationship becomes:
users ──→ list ──→ dictionary

The user vanishes as a local name upon the function's return. Because it is referenced in the users list, the dictionary is still in existence. This may be done on purpose, but if a long-lived container continues to gather things, it may also result in RAM increase.

Rebinding Changes a Reference

data = [1, 2, 3]
data = [4, 5, 6]

The first list is no longer referenced by data.

Conceptually:

Before:
data ──→ [1, 2, 3]

After:
data ──→ [4, 5, 6]

If nothing else refers to [1, 2, 3], it can become eligible for cleanup.

But:

data = [1, 2, 3]
backup = data

data = [4, 5, 6]

gives:

data   ──→ [4, 5, 6]
backup ──→ [1, 2, 3]

The old list remains alive.

Therefore:

Rebinding does not automatically delete the prior object; rather, it modifies what a name refers to.

What Does del Actually Do?

del removes a binding.

items = [1, 2, 3]
del items

Afterward, items is no longer available.

But del does not mean "destroy this object immediately."

For example:

items = [1, 2, 3]
other = items

del items
print(other)

The list remains because other still refers to it.

A useful mental model is:

del name
   ↓
remove one reference
   ↓
are other references present?
   ↓
yes → object remains
no  → object may become unreachable

Cleanup can occur instantly in CPython if eliminating the reference causes the reference count of a non-cyclic object to reach zero. However, that is not the meaning of del per se; rather, it is a result of reference counting.

When Does an Object Become Unreachable?

An object becomes unreachable when there is no live path from the program's roots to that object.

def create():
    data = [1, 2, 3]

create()

After the function returns, the local name data disappears. If nothing else refers to the list, it becomes unreachable.

For a non-cyclic object in CPython, reference counting usually allows it to be cleaned up when its reference count reaches zero.

But cycles are different.

def create_cycle():
    data = []
    data.append(data)

create_cycle()

The list refers to itself:

list ──→ itself

After the function returns, the list may be unreachable from the program while still having a nonzero reference count.

That is why cyclic garbage collection is needed.

How Reference Counting Fits Into the Lifecycle

In CPython, reference counting keeps track of object references.

The lifecycle of a simple object might resemble this:

object exists
    ↓
references point to it
    ↓
one reference disappears
    ↓
more references disappear
    ↓
last reference disappears
    ↓
reference count reaches zero
    ↓
object can be deallocated

This is why many objects can be reclaimed promptly in CPython. However, reference counting alone cannot handle an isolated cycle:

A ──→ B
↑           │
└────┘

A and B can keep references to each other even after all external references disappear.

Their reference counts therefore do not necessarily reach zero. The cyclic garbage collector handles this additional case.

Reference Cycles and Object Lifecycle

A simple example is:

class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

a = Node("a")
b = Node("b")

a.next = b
b.next = a

The graph is:

a ──→ b
↑           │
└────┘

Now:

a = None
b = None

The objects can become unreachable from the rest of the program, but they still reference each other.

That means the lifecycle has reached:

external references removed
        ↓
objects still reference each other
        ↓
cycle becomes unreachable
        ↓
cyclic garbage collection can detect it
        ↓
objects can be reclaimed

Containers Are Part of the Object Graph

Object lifecycle is rarely about one isolated object.

outer = []
inner = {"language": "Python"}

outer.append(inner)

The graph is:

outer ──→ list ──→ dict
inner ───────────→ dict

Now:

inner = None

The dictionary is still in use because it is still mentioned in the list.

Then:

outer.clear()

The dictionary reference is released by the list. The dictionary may qualify for cleanup if there are no more references. Containers can therefore function as lifetime managers.

clear() vs Rebinding

These two operations are different:

cache.clear()

and:

cache = {}

clear() modifies the existing dictionary:

same dictionary
      ↓
entries removed

Rebinding creates or selects another dictionary and changes what cache refers to:

cache ──→ new dictionary

So:

cache.clear() → mutate existing object

cache = {}    → rebind the name

Both can release references to contained objects, but they have different effects on object identity.

Mutation vs Rebinding

When dealing with mutable things, this distinction is very crucial.

Mutation

items = []

items.append("Python")
items.append("Memory")

The same list changes:

items ──→ []
items ──→ ["Python"]
items ──→ ["Python", "Memory"]

Rebinding

items = []
items = ["Python"]

The name items is changed to refer to another list.

Immutable Objects Have Lifecycles Too

Immutability does not mean an object has no lifecycle.

name = "Ada"
upper_name = name.upper()

Since strings cannot be changed, the original string remains unaltered.

Conceptually:

name       ──→ "Ada"
upper_name ──→ "ADA"

While the name relates to it, the original is still in existence.

Closures Can Extend Object Lifetimes

def make_counter():
    count = {"value": 0}

    def increment():
        count["value"] += 1
        return count["value"]

    return increment

counter = make_counter()

make_counter() has returned, but count remains alive.

Why?

Because increment() closes over it.

Globals and Caches Can Keep Objects Alive

A global object can remain reachable for much of the program's lifetime:

CONFIG = {
    "debug": False
}

Similarly, a global registry can retain objects:

REGISTERED_HANDLERS = []

def register(handler):
    REGISTERED_HANDLERS.append(handler)

Anything stored in REGISTERED_HANDLERS remains reachable through that list.

Caches behave similarly:

cache = {}

def get_user(user_id):
    if user_id not in cache:
        cache[user_id] = load_user(user_id)

    return cache[user_id]

A cache intentionally extends object lifetimes.

Hidden References Can Surprise You

The variable names in your code don't always make all of the references clear.

Objects can be retained through:

  • Lists
  • Dictionaries
  • Sets
  • Closures
  • Default argument values
  • Class attributes
  • Global variables
  • Tracebacks
  • Bound methods
  • Debuggers
  • Interactive history

For example:

callbacks = []

class Widget:
    def update(self):
        pass

    def __init__(self):
        callbacks.append(self.update)

The global list stores the bound method.

That bound method refers to the Widget instance:

callbacks
    ↓
bound method
    ↓
Widget instance

Memory Cleanup vs Resource Cleanup

Memory and external resources are different.

External resources include:

  • Files
  • Network sockets
  • Database connections
  • Locks
  • Temporary directories
  • Subprocesses

Consider:

file = open("notes.txt")
text = file.read()

The file object represents an operating-system resource in addition to being a Python object.

When determining when the file shuts down, you shouldn't rely on eventual object cleanup.

Instead:

with open("notes.txt") as file:
    text = file.read()

When the block ends, the context manager offers a predictable cleanup boundary.

Why Predictable Cleanup Is Provided by Context Managers

acquire resource
      ↓
enter context
      ↓
use resource
      ↓
exit context
      ↓
release resource

For example:

with open("notes.txt") as file:
    text = file.read()

Even if an exception occurs inside the block, the context manager gets an opportunity to perform the required cleanup.

Finalization and __del__

Finalization means running cleanup logic associated with an object as it is being finalized.

Python provides __del__ as one finalization hook:

class Example:
    def __del__(self):
        print("cleaning up")

However, __del__ is an advanced feature and should be used carefully.

You generally cannot control exactly when it runs. It can also run during interpreter shutdown, when objects it depends on may already be unavailable. Exceptions raised inside it are not propagated normally to the caller.

Why __del__ Is Not finally

finally, it has a direct connection to the program control flow:

resource = acquire()

try:
    use(resource)
finally:
    release(resource)

The cleanup occurs when execution leaves the try block.

__del__, by contrast, is connected to object finalization.

Object Resurrection

saved = None

class Lazarus:
    def __del__(self):
        global saved
        saved = self

obj = Lazarus()
obj = None

During finalization, self is assigned to saved.

The object becomes reachable again:

object becomes unreachable
        ↓
finalizer runs
        ↓
finalizer creates a new reference
        ↓
object becomes reachable again

What Happens During Deallocation?

Deallocation means the object's memory can be reclaimed by Python's memory system.

But there is an important distinction between object memory being released for reuse and the operating system immediately showing less process memory.

For example:

data = [0] * 1_000_000
data = None

The list might become inaccessible, and its storage might be made available for future use.

However, Python can retain memory in internal allocators for future allocations.

Object Lifecycle Is Really Graph Lifecycle

A real Python application rarely contains isolated objects.

app = {
    "routes": [],
    "config": {},
    "cache": {}
}

An entire object graph can remain available thanks to that one global item.

Weak References and Object Lifetime

Sometimes you want to refer to an object without keeping it alive. That is the purpose of a weak reference.

A Complete Object Lifecycle Example

users = []

def create_user(name):
    user = {"name": name}
    users.append(user)
    return user

profile = create_user("Ada")

The lifecycle can be followed clearly.

  1. Creation
  2. Initialization
  3. Local Reference
  4. Container Reference
  5. Return
  6. Local Reference Disappears
  7. One Reference Is Removed
  8. Container Releases It
  9. Cleanup

Common Mistakes

  • Thinking a Scope Owns an Object
  • Thinking del Calls a Destructor
  • Confusing Mutation With Rebinding
  • Assuming Local Objects Always Die When a Function Returns
  • Using __del__ for Important Resource Cleanup
  • Assuming Freed Objects Always Reduce Process Memory Immediately

Summary

The lifecycle of a Python object is best understood in terms of reachability and references. Python creates and initializes objects, names relate to them, and they can have their lifespan extended by containers, functions, closures, globals, and caches. Reference counting and cyclic garbage collection are two ways that CPython can recover an object that is no longer reachable. Use of __del__ should be done with caution since finalization is distinct from deterministic resource cleanup. Weak references aid in the design of associations that do not needlessly prolong object lives, while context managers and try/finally offer predictable resource cleanup.

Frequently Asked Questions

What is the lifecycle of a Python object?

In general, a Python object goes thru the following stages: creation, initialization, use, reference changes, becoming unreachable, finalization when necessary, and deallocation.

Does an object disappear when a variable is deleted?

No, a name binding is removed by del. If the item is reached by another reference, it stays alive.

Can an object endure the return of a function?

Indeed. It can be kept alive by a returned value, container, closure, global, cache, or another reference.

What happens when an object becomes unreachable?

When the reference count of a non-cyclic object in CPython is 0, it can usually be reclaimed. Cyclic garbage collection may be necessary for an unreachable cycle.

What is object finalization?

Finalization is the phase in which an object's cleanup actions may take place either prior to or during its destruction. One finalization approach is the __del__ function in Python.

Why should one use __del__ with caution?

Its timing is not a good foundation for predictable cleanup, and finalizers may be hard to understand due to interpreter shutdown or object dependencies.

What is object resurrection?

Resurrection occurs when an object being finalized becomes reachable again because its finalization code creates a new reference to it.

Why are weak references useful?

They allow code to refer to an object without keeping that object alive solely because that reference. They are useful in some caches, registries, and non-owning relationships.

Does object cleanup immediately return memory to the operating system?

Not always. Released memory may be saved by Python's internal allocator for use in subsequent allocations.