Python Memory + Performance (Beginner → Interview)

Python: id(), del, gc, object size, and execution time

This lesson connects four confusing topics into one clear mental model: objects, references, memory, and timing.

Identityid() → identifies objects
MemoryFreed when references drop to 0
TimingMeasure with perf_counter/timeit
🧪 Try the code here (Programmer’s Picnic Python Editor)
Paste any snippet from this post into the editor and run. If Blogger blocks the iframe, open in a new tab.

If the editor doesn’t load on Blogger: open it in a new tab (button above). Some sites send headers that block embedding.

1) id() — what it is and what it isn’t

In Python, a variable is not a “box holding a value”. It is a name (label) pointing to an object. The function id(obj) returns a unique identity number for that object during its lifetime.

Think of it like this: object = house, id() = house address, variable = nameplate on the gate. You can change the nameplate without changing the house.
Demo: object identity
x = 10
print("x =", x)
print("id(x) =", id(x))

If two names point to the same object, they will share the same id. This is why is checks identity:

Demo: same object vs same value
a = [1, 2, 3]
b = a
c = [1, 2, 3]

print(a is b)  # True  (same object)
print(a == c)  # True  (same value)
print(a is c)  # False (different objects)
Quick question 💬
Why does a == c return True but a is c return False?

2) Why you cannot get “value from id” reliably

Many learners ask: “If I know an object’s id, can I get the variable value?” In normal Python, the answer is no, because Python does not keep a built-in reverse map like:

id → variable name → value

Also, the question becomes ambiguous because one object can have many names:

Ambiguity: one object, many names
a = 100
b = 100

print("id(a) =", id(a))
print("id(b) =", id(b))
print("a is b:", a is b)

If a and b point to the same object, then “return the value of the variable” is not meaningful. Which variable name should Python return? There may be multiple.

Correct approach (if you truly need lookup): create a registry yourself.

Safe method: your own registry
registry = {}

obj = {"topic": "Python", "time": "7 PM"}
registry[id(obj)] = obj

print(registry[id(obj)])

3) Freeing memory: del, reassigning, and reference counts

Python mainly frees memory by reference counting. When an object has zero references left, it becomes eligible for cleanup. The keyword del removes a name (reference), not “the object in memory”.

Rule: Memory is freed when no references point to the object.
del removes the name
x = [1, 2, 3]
print("id(x) =", id(x))
del x
# x is gone now (NameError if you print x)

But if another name points to the same object, it will remain alive:

Not freed if another reference exists
a = [1, 2, 3]
b = a
del a
print(b)  # still works, object still alive via b

Reassigning also drops a reference:

Reassigning drops the old object
big = [i for i in range(1_000_00)]
big = None  # old list may be freed if no other refs exist
Reality check: Even if Python frees objects internally, the process memory shown by Task Manager may not drop immediately. That’s normal due to Python’s allocator and OS behavior.

4) Garbage collection: when gc matters (cycles)

Reference counting cannot free cyclic references (objects referring to each other). That’s why Python also has a garbage collector (GC) to detect cycles.

Cycle example
a = []
a.append(a)   # a refers to itself (cycle)
print("cycle created")

You can request GC to run (rarely needed):

gc.collect()
import gc
collected = gc.collect()
print("Collected objects:", collected)
When to use gc.collect()? Mostly for debugging or memory-critical batch jobs. For typical programs, let Python manage it.

5) Measuring memory: getsizeof (shallow) vs deep size

Python objects have overhead. A list is a container that holds references to items. So sys.getsizeof() gives the size of the container object (shallow size), not the full memory of everything inside.

Shallow size
import sys

lst = [1, 2, 3]
print(sys.getsizeof(lst), "bytes")

To estimate total memory (deep size), you can recursively add sizes:

Deep size (recursive)
import sys

def deep_getsizeof(obj, seen=None):
    if seen is None:
        seen = set()
    obj_id = id(obj)
    if obj_id in seen:
        return 0
    seen.add(obj_id)

    size = sys.getsizeof(obj)

    if isinstance(obj, dict):
        size += sum(deep_getsizeof(k, seen) + deep_getsizeof(v, seen)
                    for k, v in obj.items())
    elif isinstance(obj, (list, tuple, set)):
        size += sum(deep_getsizeof(i, seen) for i in obj)

    return size

data = [1, 2, [3, 4], {"a": 10, "b": [11, 12]}]
print("Deep size:", deep_getsizeof(data), "bytes")
Quick question 💬
Why can two lists with different numbers have similar getsizeof values?

6) Measuring time: per-statement timing, benchmarking, profiling

Timing is tricky because computers do background work and short statements run extremely fast. That’s why we prefer time.perf_counter() (high precision) and timeit (repeat + average).

6.1 High-precision timing with perf_counter

Block timing with perf_counter
import time

start = time.perf_counter()
x = sum(range(1_000_000))
end = time.perf_counter()

print("Time:", end - start, "seconds")

6.2 Line-by-line timing (great for teaching)

Per-step timings
import time

t0 = time.perf_counter()
a = [i*i for i in range(200_000)]
t1 = time.perf_counter()

b = sum(a)
t2 = time.perf_counter()

c = max(a)
t3 = time.perf_counter()

print("build list:", t1 - t0)
print("sum:", t2 - t1)
print("max:", t3 - t2)

6.3 Benchmarking with timeit (most reliable)

timeit benchmark
import timeit

t = timeit.timeit("sum(range(1000))", number=10000)
print("Total time:", t)

6.4 Profiling with cProfile (for bigger programs)

cProfile
import cProfile
cProfile.run("sum(range(1_000_000))")

🎯 Interview-Ready Summary

Topic Best one-liner
id() Identity of an object during its lifetime; not a variable address you can reverse-map.
del Removes a name/reference; object is freed only when no references remain.
gc Collects cyclic garbage; manual gc.collect() is rarely needed.
Size sys.getsizeof() is shallow; deep size requires traversal.
Timing Use perf_counter for timing and timeit for benchmarks.
Final check 💬
If you do del x, is memory guaranteed to drop immediately in Task Manager? Why/why not?

✅ Quick Cheatsheet

Identity / Equality

is vs == vs id()
a is b   # same object?
a == b   # same value?
id(a)    # object identity

Freeing references

Drop references
del x
x = None

Garbage collection

gc.collect()
import gc
gc.collect()

Object size

Shallow size
import sys
sys.getsizeof(obj)

Timing

perf_counter
import time
t0 = time.perf_counter()
# code...
t1 = time.perf_counter()
print(t1 - t0)

Benchmarking

timeit
import timeit
timeit.timeit("sum(range(1000))", number=10000)

🧠 Mini-Assignments

  • Write a function that prints shallow size and deep size of any object.
  • Create a cyclic list and observe behavior with and without gc.collect().
  • Compare timing of for loop vs list-comprehension for the same task.
  • Explain why del x does not always reduce OS memory immediately.