Filter concepts by levelShowing all levels.

Python · Section 19

Memory Optimization

Level
advanced
Read
150 min
Concepts
10

Object overhead and references, mutable containers and generators for memory footprint, __slots__ at scale, shallow/deep copies, and the practical investigation toolkit — memory leaks, reference cycles, weak references, caching side effects, and memory profiling — for diagnosing a service whose memory keeps growing.

What is true here

  1. A Python "memory leak" almost always means something is still referenced when it should not be, not unreachable memory.
  2. A generator holds constant-size state, producing one item at a time; a list holds every item at once.
  3. __slots__ saves a modest amount per instance — worth adding specifically to classes instantiated in bulk.
  4. A deep copy duplicates every nested object and costs real, measurable memory; a shallow copy shares them and costs almost nothing.
  5. Investigating growth means comparing two tracemalloc snapshots to find which line is allocating, then confirming with gc.get_objects().

What you will be able to do

  • Measure real object and container sizes with sys.getsizeof, and explain why a = b never copies
  • Choose a generator over a list comprehension for a one-pass computation over large data
  • Decide when __slots__ is worth adding to a class, based on how many instances it will have
  • Choose between a shallow and a deep copy based on whether nested independence is actually needed
  • Diagnose a real memory leak pattern (unbounded cache, forgotten subscriber, uncollected cycle)
  • Use weakref.WeakValueDictionary to build a cache that does not artificially keep everything alive
  • Use tracemalloc snapshot comparison and gc.get_objects() to find and confirm the source of memory growth

Understanding memory

What objects actually cost, how containers and generators differ in memory footprint, and __slots__ as a deliberate savings technique at scale.

Object overhead and references, for memory investigation

standardintermediate

Every object costs real bytes beyond its logical content, and a variable is a reference, not a copy — investigating why memory keeps growing means knowing what still references each object, not just counting objects.

Think of it as

When memory keeps climbing, the question is never "how big is one object" — it is "why is this object still referenced, when I expected it to be freed by now." Overhead explains the baseline cost; references explain why something outlives its expected lifetime.

python
import sys
sys.getsizeof(obj)          # real size, including overhead

import gc
gc.get_referrers(obj)       # what is still referencing obj -- for investigating a leak

What we're doing: Trace a container's real memory size as it grows, the concrete first step in investigating a suspected leak.

growth_trace.pypython
import sys

cache = {}
sizes = []
for i in range(0, 5000, 1000):
    cache[i] = [0] * 100
    sizes.append(sys.getsizeof(cache))

print(sizes)
3
A dict standing in for an unbounded cache — nothing ever removes entries from it.
6
Sampling sys.getsizeof() at intervals is the same technique used to spot a real leak in a long-running process.
Output
[224, 224, 224, 224, 224]

Why this works: sys.getsizeof() on a dict only reports the size of the hash table itself, not the objects it references — this is exactly the trap that makes investigating real memory growth harder than one size check: the dict's own reported size barely moves even as it holds increasingly many large lists, which is why a real investigation needs tracemalloc's full accounting (see memory-profiling-in-practice), not sys.getsizeof() alone.

Remember: Every object has real overhead; an object survives as long as anything references it — ask what still references it.

See also: memory allocation and object overhead · names vs objects and references · memory leaks in practice

Mutable containers and large collections

coreintermediate

A list grows by over-allocating — resizing to more capacity than needed, so repeated appends stay fast — its real memory does not track length linearly. A million-item collection is a genuinely large, measurable memory commitment.

Think of it as

A list growing one append at a time is not renting exactly the shelf space needed each time — it is renting a slightly bigger shelf than needed, so the next few items do not require another move. That extra slack is real memory, held even when not all of it is used yet.

python
import sys

lst = []
for i in range(1000):
    lst.append(i)
    # sys.getsizeof(lst) grows in jumps, not one byte at a time

What we're doing: Trace a growing list's real size and confirm it jumps in steps (over-allocation), not smoothly with each append.

list_overallocation.pypython
import sys

lst = []
sizes = []
for i in range(20):
    lst.append(i)
    sizes.append(sys.getsizeof(lst))

print(sizes)
3
The list starts empty — its first size includes only the container's own baseline overhead.
5
Each append MIGHT trigger a resize — the actual size grows in occasional jumps, not one increment per item.
Output
[88, 88, 88, 88, 120, 120, 120, 120, 184, 184, 184, 184, 184, 184, 184, 184, 248, 248, 248, 248]

Why this works: The size stays flat at 88 bytes for the first 4 appends, then jumps to 120 for the next 4 — CPython over-allocated room for more items than were immediately needed, so several appends in a row fit without a further resize. This amortizes the cost of resizing across many appends, at the price of some unused, already-reserved memory.

Assuming a list's memory shrinks after removing items

Wrong

python
lst = list(range(1_000_000))
del lst[100_000:]   # removed 900,000 items...
import sys
print(sys.getsizeof(lst))   # ...but the list often keeps MOST of the reserved capacity

Better

python
lst = list(range(1_000_000))
kept = list(lst[:100_000])   # a NEW list, sized exactly for what's kept
del lst
import sys
print(sys.getsizeof(kept))   # genuinely smaller

What you see: Deleting most of a large list's items does not shrink its reported memory as much as expected — capacity reserved for growth is not automatically released just because it emptied out.

Why: A list's over-allocation strategy optimizes for future appends, not for shrinking — removing items does not aggressively give back reserved capacity. Building a fresh list sized exactly for what is actually kept is the reliable way to reclaim that memory.

Growing list vs. fixed tuple, same 5 items

list — over-allocates

  • +Resizes in jumps: 88, 120, 184, 248 bytes...
  • +Reserves slack for future appends
  • +Removing items rarely shrinks it back

tuple — fixed size

  • Never over-allocates — cannot grow
  • 88 bytes for 5 items vs. list's 104
  • Right choice for data that never changes
  • list — over-allocates
    • Resizes in jumps: 88, 120, 184, 248 bytes...
    • Reserves slack for future appends
    • Removing items rarely shrinks it back
  • tuple — fixed size
    • Never over-allocates — cannot grow
    • 88 bytes for 5 items vs. list's 104
    • Right choice for data that never changes

A list's real size does not grow linearly with length

A list's real size does not grow linearly with length
Lengthsys.getsizeof()
0-388 bytes
4-7120 bytes
8-15184 bytes
16-19248 bytes

Together

python
import sys
lst = []
for i in range(20):
    lst.append(i)
    print(len(lst), sys.getsizeof(lst))

Remember: A list over-allocates and grows in jumps, not linearly, and does not shrink much when items are removed.

See also: object overhead and references · slots for memory savings

Generators and iterators, for memory

standardintermediate

A list comprehension builds and holds every item in memory at once. A generator expression holds only enough state to produce the next item — the rest do not exist yet. For a one-pass computation, that difference is everything.

Think of it as

A list is the whole book, printed and on the shelf. A generator is a bookmark in a book written one page at a time, on demand — the pages already read (or not yet reached) never all exist in memory at once.

python
[x for x in range(1_000_000)]    # a real list -- all items exist now
(x for x in range(1_000_000))    # a generator -- constant-size, lazy

What we're doing: Show the specific, common fix — dropping list brackets for a one-pass reduction — and confirm the result is identical either way.

drop_the_brackets.pypython
def expensive_transform(x):
    return x * x

data = range(100_000)

total_via_list = sum([expensive_transform(x) for x in data])
total_via_gen = sum(expensive_transform(x) for x in data)

print(total_via_list == total_via_gen)
6
The list version builds a full 100,000-item intermediate list, just to be summed once and discarded immediately after.
7
Removing the [ ] brackets is the entire change — sum() only ever needed one transformed value at a time.
Output
True

Why this works: Both produce the identical total — sum() consumes its argument one item at a time regardless of whether that argument is a list or a generator. The list version paid for a full intermediate list that existed only to be summed and thrown away; the generator version never built it.

Remember: For a one-pass reduction (sum, any, all), drop the list brackets to a generator — same result, no intermediate list.

See also: memory efficient processing · mutable containers and large collections

__slots__, at scale

coreintermediate

The per-instance memory __slots__ saves is small for one object, but multiplies across every instance a program creates — for a class instantiated thousands or millions of times, that per-instance saving becomes a real, measurable total.

Think of it as

Saving 250 bytes per instance sounds trivial for one object. Multiply it by a million rows loaded from a database, and it is 250 MB — the same arithmetic that makes a tiny per-request cost matter once traffic scales up.

python
class Row:
    __slots__ = ("id", "value")
    def __init__(self, id, value):
        self.id = id
        self.value = value

# worth adding when Row is instantiated in bulk (many thousands+)

What we're doing: Measure the real total memory difference between 100,000 regular instances and 100,000 __slots__ instances of the same shape.

slots_at_scale.pypython
import sys

class Row:
    def __init__(self, id, value):
        self.id = id
        self.value = value

class SlottedRow:
    __slots__ = ("id", "value")
    def __init__(self, id, value):
        self.id = id
        self.value = value

N = 100_000
regular = [Row(i, i * 2) for i in range(N)]
slotted = [SlottedRow(i, i * 2) for i in range(N)]

regular_total = sum(sys.getsizeof(r) + sys.getsizeof(r.__dict__) for r in regular)
slotted_total = sum(sys.getsizeof(s) for s in slotted)

print(f"regular: {regular_total / 1024 / 1024:.1f} MB")
print(f"slotted: {slotted_total / 1024 / 1024:.1f} MB")
print(f"saved: {(regular_total - slotted_total) / 1024 / 1024:.1f} MB")
9
__slots__ = ("id", "value") is the only structural difference from Row — same fields, same constructor logic.
19
Each regular instance's real cost includes its own object PLUS its separate __dict__ — both counted here for an honest total.
Output
regular: 13.0 MB
slotted: 4.6 MB
saved: 8.4 MB

Why this works: The regular class's 100,000 instances each carry their own __dict__ on top of the instance itself — that per-instance dict overhead is what drives the total to nearly 3x the slotted version's size. This saving scales linearly with instance count: the same class at 10 million rows would save roughly 840 MB, not 8.4.

A small per-instance saving, multiplied by 100,000

class Row (regular)

  • +Each instance carries its own __dict__
  • +100,000 instances: 13.0 MB, measured
  • +The __dict__ overhead exists even for 2 fields

class SlottedRow (__slots__)

  • Fixed-size slots — no per-instance __dict__
  • 100,000 instances: 4.6 MB, measured
  • Every subclass needs its own __slots__ too
  • class Row (regular)
    • Each instance carries its own __dict__
    • 100,000 instances: 13.0 MB, measured
    • The __dict__ overhead exists even for 2 fields
  • class SlottedRow (__slots__)
    • Fixed-size slots — no per-instance __dict__
    • 100,000 instances: 4.6 MB, measured
    • Every subclass needs its own __slots__ too

A subclass without its own __slots__ silently regains __dict__

Wrong

python
class Base:
    __slots__ = ("x",)

class Sub(Base):
    pass   # no __slots__ here -- silently gets a __dict__ back!

s = Sub()
s.y = "surprise"   # works -- the savings are GONE for every Sub instance

Better

python
class Base:
    __slots__ = ("x",)

class Sub(Base):
    __slots__ = ("y",)   # every subclass needs its OWN __slots__ too

s = Sub()
s.z = "still blocked"   # AttributeError -- savings preserved

What you see: A class hierarchy carefully given __slots__ still shows measurable memory savings evaporate, because one subclass in the chain forgot its own declaration.

Why: A subclass with no __slots__ of its own gets a normal __dict__ by default — __slots__ is not inherited in the sense of "the subclass is also slotted," each class in the hierarchy must declare it, or the whole chain loses the optimization from that point down.

Per-instance savings, multiplied (measured, a 2-attribute class)

Per-instance savings, multiplied (measured, a 2-attribute class)
InstancesRegular class (with __dict__)__slots__ class
100,00013.0 MB4.6 MB
1,000,000 (linear extrapolation)~130 MB~46 MB

Together

python
class Row:
    def __init__(self, id, value):
        self.id = id
        self.value = value

class SlottedRow:
    __slots__ = ("id", "value")
    def __init__(self, id, value):
        self.id = id
        self.value = value

Remember: __slots__ saves a modest amount per instance — worth adding to classes instantiated in bulk, where it adds up.

See also: slots · dataclass slots

Shallow and deep copies, and their memory cost

standardintermediate

A shallow copy (copy.copy) builds a new outer container but reuses the same nested objects — cheap. A deep copy (copy.deepcopy) recursively duplicates everything nested — correct when independence is needed, but a real cost for large structures.

Think of it as

A shallow copy is a new folder holding the exact same documents. A deep copy is a new folder holding a photocopy of every document, all the way down — genuinely more paper (memory), spent on purpose to guarantee independence.

python
import copy
shallow = copy.copy(original)      # cheap -- nested objects shared
deep = copy.deepcopy(original)     # costs real memory -- nested objects duplicated

What we're doing: Measure the real memory cost of a deep copy versus a shallow copy of a large nested structure, using tracemalloc to see the actual allocation.

copy_memory_cost.pypython
import copy
import tracemalloc

original = {"rows": [{"id": i, "tags": ["a", "b"]} for i in range(50_000)]}

tracemalloc.start()
shallow = copy.copy(original)
current1, _ = tracemalloc.get_traced_memory()

deep = copy.deepcopy(original)
current2, _ = tracemalloc.get_traced_memory()

print("shallow shares rows:", original["rows"] is shallow["rows"])
print("deep shares rows:", original["rows"] is deep["rows"])
print(f"after shallow copy: {current1 / 1024:.1f} KB")
print(f"after also deep copy: {current2 / 1024:.1f} KB")
6
tracemalloc.start() begins tracking allocations right before the two copies, isolating their real cost from setup.
9
copy.copy only builds a new outer dict — the 50,000-item rows list is the SAME object, so this costs almost nothing.
Output
shallow shares rows: True
deep shares rows: False
after shallow copy: 0.2 KB
after also deep copy: 13715.8 KB

Why this works: The shallow copy costs almost nothing (0.2 KB) because it only builds a new outer dict, reusing the exact same 50,000-item rows list. The deep copy genuinely duplicates all 50,000 nested dicts and their tags lists — 13.7 MB of real, newly-allocated memory, confirmed directly with tracemalloc rather than assumed from the API names alone.

Remember: A shallow copy shares nested objects and costs almost nothing; a deep copy duplicates them and costs real memory.

See also: copying objects · object overhead and references

Advertisement

Investigating growth

The roadmap's own closing goal: being able to investigate a service whose memory usage continuously increases — leaks, cycles, caching side effects, and the profiling tools that turn a guess into a confirmed answer.

Memory leaks, in practice

coreintermediate

A Python "memory leak" almost never means unreachable memory — the garbage collector prevents that. It means something is still REFERENCED when it should not be: an unbounded cache, or a subscriber list nothing removes from.

Think of it as

A leak in C is a lost key — the room exists, but nothing can find the door anymore. A leak in Python is a room nobody remembered to stop paying rent on — perfectly reachable, perfectly valid, just never actually needed anymore, and nobody ever let go of the reference keeping it alive.

python
import tracemalloc

tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
# ... run the suspected-leaking code ...
snapshot2 = tracemalloc.take_snapshot()

top_stats = snapshot2.compare_to(snapshot1, "lineno")
for stat in top_stats[:5]:
    print(stat)

What we're doing: Use two tracemalloc snapshots to find exactly which line is responsible for a real, growing memory leak.

find_the_leak.pypython
_cache = {}   # the leak: never evicts anything

def handle_request(request_id, payload):
    _cache[request_id] = payload   # grows forever
    return len(payload)

import tracemalloc
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()

for i in range(10_000):
    handle_request(i, [0] * 50)

snapshot2 = tracemalloc.take_snapshot()
top = snapshot2.compare_to(snapshot1, "lineno")
print(top[0])
1
_cache is the actual leak — a module-level dict with no maximum size and no eviction.
4
Every call adds one more entry, and nothing in this function ever removes one.
11
compare_to diffs the two snapshots, ranking by which line allocated the most memory between them.
Output
find_the_leak.py:12: size=4449 KiB (+4449 KiB), count=19925 (+19925), average=229 B

Why this works: compare_to points directly at the line building the payload lists passed into handle_request, as the source of 4.4 MB of NEW allocations between the two snapshots — nearly 20,000 new objects. This is the real investigative technique: not guessing which function looks suspicious, but asking the allocator directly which line is actually responsible, right down to the exact byte and object counts.

Assuming reference counting alone prevents all leaks

Wrong

python
# "Python has garbage collection, so it can't leak"
_subscribers = []

def subscribe(callback):
    _subscribers.append(callback)   # NEVER removed -- keeps callback's closure alive forever

Better

python
import weakref
_subscribers = weakref.WeakSet()   # entries vanish automatically once nothing else references them

def subscribe(callback):
    _subscribers.add(callback)

What you see: Memory grows steadily and never comes back down, in a language whose garbage collector is supposedly automatic.

Why: Python's garbage collector prevents unreachable memory from staying allocated — it does nothing for memory that is still reachable but no longer actually needed. A list that only ever grows is a perfectly valid, fully reachable structure, which is exactly why the collector cannot help: from its perspective, nothing is wrong.

Still referenced, not unreachable — that is why GC cannot help

_cache = {}

module-level, never evicts

handle_request()

_cache[request_id] = payload

4.4 MB, +19,925 objects

reachable, so GC leaves it alone

  1. _cache = {} — module-level, never evicts
  2. handle_request() — _cache[request_id] = payload
  3. 4.4 MB, +19,925 objects — reachable, so GC leaves it alone

Common real leak patterns

Common real leak patterns
PatternWhy it leaks
A module-level dict cache with no evictiongrows forever, nothing ever removes old entries
An event system that never unsubscribesthe publisher holds a reference to every past subscriber
A list appended to on every request, never clearedaccumulates one entry per request, forever
@lru_cache on an instance methodeach cached call keeps that instance alive permanently

Together

python
_request_log = []   # module-level, never cleared -- a real leak

def handle_request(request):
    _request_log.append(request)   # grows forever
    return process(request)

Remember: A Python "leak" means something is still referenced when it should not be — snapshot comparison finds which line.

See also: caching side effects · memory profiling in practice · cyclic references

Reference cycles and garbage collection, for memory investigation

standardintermediate

A reference cycle (objects referencing each other in a loop) defeats simple reference counting — CPython's separate cycle-collecting garbage collector is what actually frees these. Many uncollected cycles are a real contributor to growing memory.

Think of it as

Reference counting alone frees a room the instant its last occupant leaves. Two objects that only ever reference each other never individually reach zero — the cyclic collector is a separate inspector checking for exactly this "occupied, but unreachable from outside" pattern.

python
import gc

freed = gc.collect()   # force a collection, see how many objects it found
print(f"freed {freed} unreachable objects")
print("uncollectable:", gc.garbage)

What we're doing: Create a real reference cycle, confirm reference counting alone cannot free it, and confirm gc.collect() does.

cycle_and_gc.pypython
import gc
import weakref

class Node:
    def __init__(self, name):
        self.name = name
        self.other = None

a, b = Node("a"), Node("b")
a.other, b.other = b, a   # a cycle: a references b, b references a

a_ref = weakref.ref(a)
del a, b
print("alive before gc.collect():", a_ref() is not None)
gc.collect()
print("alive after gc.collect():", a_ref() is not None)
9
a.other = b and b.other = a is the cycle — each object is kept alive by the other, with no way to reach zero references alone.
12
Deleting both names removes the only OUTSIDE references — but the two objects still reference each other.
Output
alive before gc.collect(): True
alive after gc.collect(): False

Why this works: After del a, b, nothing outside the cycle references either object — but reference counting alone never reaches zero for either, since they still reference each other. gc.collect() is what actually detects and breaks this specific pattern, confirmed here by a weak reference that would otherwise keep reporting the object as alive.

Remember: A reference cycle defeats simple reference counting — gc.collect() is what actually frees it.

See also: cyclic references · garbage collection · memory leaks in practice

Weak references, as a memory optimization

standardintermediate

A cache holding normal (strong) references keeps every cached object alive forever, even after nothing else needs it — often not intended. weakref.WeakValueDictionary holds weak references instead: an entry disappears once nothing else references its value.

Think of it as

A normal cache is a landlord who never lets a tenant leave once they move in, regardless of whether they still want to live there. A WeakValueDictionary is a landlord who only keeps a tenant as long as the tenant themselves still wants to stay — the moment nothing else needs the object, the cache entry vanishes too, automatically.

python
import weakref

cache = weakref.WeakValueDictionary()
cache["key"] = some_object   # a WEAK reference -- does not keep some_object alive
# once nothing else references some_object, it is freed AND removed from cache

What we're doing: Show a WeakValueDictionary entry disappearing automatically once nothing else references its value, then show a plain dict cache holding the same object alive indefinitely instead.

weak_cache.pypython
import weakref
import gc

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

# weak cache: entry vanishes once nothing else references the value
weak_cache = weakref.WeakValueDictionary()
r = Resource("shared")
weak_cache["r"] = r
del r
gc.collect()
print("in weak_cache after del:", "r" in weak_cache)

# plain dict cache: the cache ITSELF is a strong reference -- keeps it alive
strong_cache = {}
r2 = Resource("shared")
strong_cache["r"] = r2
del r2
gc.collect()
print("in strong_cache after del:", "r" in strong_cache)
9
weak_cache holds a weak reference — it does not count toward the object's reference count at all.
12
After del r, no strong reference to the Resource remains anywhere — the object is freed, and weak_cache's entry vanishes with it.
19
strong_cache is an ordinary dict — assigning into it creates a normal, strong reference.
Output
in weak_cache after del: False
in strong_cache after del: True

Why this works: With no OTHER strong reference competing, deleting r leaves nothing keeping the first Resource alive — weak_cache never counted as a keeper, so the entry disappears the moment the object is freed. The second Resource survives specifically because strong_cache itself is a strong reference, keeping it alive even after r2 is deleted — the exact behavior a weak cache exists to avoid.

Remember: A plain dict cache holds strong references, keeping entries alive forever; WeakValueDictionary lets them disappear.

See also: weak references · caching side effects

Caching side effects

coreintermediate

A cache is a deliberate memory-for-speed tradeoff — but an unbounded cache grows forever, becoming the exact memory problem this section is about. @lru_cache(maxsize=None) is a common, easy-to-miss source of unbounded growth.

Think of it as

A cache with no size limit is a pantry with an open-ended grocery budget and no expiration dates — it keeps everything, forever, "just in case," until the pantry itself becomes the problem the kitchen was trying to solve.

python
from functools import lru_cache

@lru_cache(maxsize=1000)   # bounded -- evicts least-recently-used once full
def compute(x):
    ...

What we're doing: Measure the real memory difference between an unbounded and a bounded lru_cache called with many distinct arguments.

unbounded_cache.pypython
from functools import lru_cache
import sys

@lru_cache(maxsize=None)
def unbounded(x):
    return [x] * 10

@lru_cache(maxsize=100)
def bounded(x):
    return [x] * 10

for i in range(5000):
    unbounded(i)
    bounded(i)

print("unbounded cache entries:", unbounded.cache_info().currsize)
print("bounded cache entries:", bounded.cache_info().currsize)
3
maxsize=None means no limit — every one of the 5,000 distinct calls gets its own permanent cache entry.
8
maxsize=100 caps the cache — once full, each new entry evicts the least-recently-used one.
Output
unbounded cache entries: 5000
bounded cache entries: 100

Why this works: Both functions were called with the same 5,000 distinct arguments, but unbounded kept every single result — its cache grew to exactly 5,000 entries and would keep growing with more distinct calls. bounded stayed capped at exactly 100, evicting older entries as new ones arrived — the real, measured difference between a cache that helps and one that becomes a leak.

Caching on a high-cardinality key like a request ID

Wrong

python
@lru_cache(maxsize=None)
def process_request(request_id, payload):   # request_id is basically unique EVERY call
    return expensive_computation(payload)
# cache grows by ~1 entry per request, forever -- no cache hit ever reuses an entry

Better

python
@lru_cache(maxsize=None)
def expensive_computation_cached(payload_key):   # cache on something that REPEATS
    return expensive_computation(payload_key)
# request_id itself is never part of the cache key

What you see: A cache grows steadily with traffic and never seems to produce a cache hit — the "optimization" is pure memory cost with none of the intended speed benefit.

Why: Caching only helps when the SAME arguments are seen again — a key with high cardinality (nearly unique every call, like a request ID or timestamp) means the cache never actually reuses an entry, so it just accumulates one-time-use results forever.

Unbounded vs. bounded — same 5,000 distinct calls

5,000 distinct arguments

@lru_cache(maxsize=None)

no eviction, ever

@lru_cache(maxsize=100)

evicts least-recently-used

cache_info().currsize = 5000

grows forever

cache_info().currsize = 100

capped, stays bounded

  • 5,000 distinct arguments
    • leads to @lru_cache(maxsize=None)
    • leads to @lru_cache(maxsize=100)
  • @lru_cache(maxsize=None) — no eviction, ever
    • on error, leads to cache_info().currsize = 5000
  • @lru_cache(maxsize=100) — evicts least-recently-used
    • leads to cache_info().currsize = 100
  • cache_info().currsize = 5000 — grows forever
  • cache_info().currsize = 100 — capped, stays bounded

Bounded vs. unbounded caching

Bounded vs. unbounded caching
CacheGrows how
@lru_cache(maxsize=None)forever — one entry per distinct argument set, ever
@lru_cache(maxsize=1000)up to 1000 entries, then evicts the least-recently-used
A plain dict used as a cacheforever, exactly like maxsize=None, unless code removes entries manually

Together

python
from functools import lru_cache

@lru_cache(maxsize=None)     # UNBOUNDED -- one entry per user_id, forever
def get_user_display_name(user_id):
    return fetch_from_db(user_id)

Remember: @lru_cache(maxsize=None) grows forever — bound it, or key it on something that actually repeats.

See also: caching and lazy evaluation · memory leaks in practice · weak references recap

Memory profiling, in practice

coreintermediate

Investigating a service whose memory keeps growing means finding WHICH objects are accumulating and WHY they are still referenced — snapshot comparison and object-count tracking are the concrete tools, not one measurement.

Think of it as

A single memory measurement is a photograph of the pantry today — full, but you cannot tell what changed. Comparing two snapshots, taken minutes or hours apart, is watching what was actually added between the photos — the technique that turns "memory is high" into "these specific 50,000 objects are new, and they came from this line."

python
import gc

# count live instances of a suspected class
suspects = [obj for obj in gc.get_objects() if isinstance(obj, SuspectedClass)]
print(f"{len(suspects)} live instances")

What we're doing: Use gc.get_objects() to confirm a specific class is accumulating instances, the concrete final step in a real investigation.

confirm_the_leak.pypython
import gc

class Connection:
    def __init__(self, id):
        self.id = id

_pool = []   # never releases connections -- the leak

def open_connection(id):
    conn = Connection(id)
    _pool.append(conn)
    return conn

for i in range(1000):
    open_connection(i)

live_connections = [obj for obj in gc.get_objects() if isinstance(obj, Connection)]
print("live Connection instances:", len(live_connections))
7
_pool is the actual leak — connections are appended but never removed, ever.
17
Filtering gc.get_objects() by type is the concrete way to confirm a suspected class really is accumulating, not just guessed at.
Output
live Connection instances: 1000

Why this works: gc.get_objects() returns every object the garbage collector currently tracks — filtering it by isinstance(obj, Connection) gives an exact, real count of how many are alive right now, confirming _pool really is the reason all 1,000 connections are still referenced instead of being freed after use.

The investigative workflow, in order

Confirm growth

process RSS over time, or tracemalloc.get_traced_memory()

Baseline snapshot

tracemalloc.take_snapshot() before the suspected code runs

Let it happen

real traffic, or a reproducing test — not immediately back to back

Compare snapshots

snapshot2.compare_to(snapshot1, "lineno") — ranks by line

Confirm the count

gc.get_objects() filtered by the suspected type

  1. Confirm growth — process RSS over time, or tracemalloc.get_traced_memory()
  2. Baseline snapshot — tracemalloc.take_snapshot() before the suspected code runs
  3. Let it happen — real traffic, or a reproducing test — not immediately back to back
  4. Compare snapshots — snapshot2.compare_to(snapshot1, "lineno") — ranks by line
  5. Confirm the count — gc.get_objects() filtered by the suspected type

Taking a snapshot before calling tracemalloc.start()

Wrong

python
import tracemalloc
snap = tracemalloc.take_snapshot()   # forgot to start tracing first
# RuntimeError: the tracemalloc module must be tracing memory allocations

Better

python
import tracemalloc
tracemalloc.start()          # start tracing FIRST
snap1 = tracemalloc.take_snapshot()
# ... suspected leaking code ...
snap2 = tracemalloc.take_snapshot()

What you see: take_snapshot() raises RuntimeError immediately — a clear failure, at least, but it stops a quick investigation cold if the fix isn't obvious.

Why: tracemalloc only tracks allocations while actively tracing — start() must run before ANY allocation you want visible in a later snapshot, including before the first baseline snapshot itself.

The investigative workflow, in order

The investigative workflow, in order
StepTool
1. Confirm memory is actually growingprocess RSS over time (OS-level), or tracemalloc.get_traced_memory()
2. Take a baseline snapshottracemalloc.take_snapshot()
3. Let the suspected leak happen(real traffic, or a reproducing test)
4. Take a second snapshot, comparesnapshot2.compare_to(snapshot1, "lineno")
5. Confirm the specific object countgc.get_objects() filtered by the suspected type

Together

python
import tracemalloc
tracemalloc.start()
s1 = tracemalloc.take_snapshot()
# ... suspected leaking code runs ...
s2 = tracemalloc.take_snapshot()
for stat in s2.compare_to(s1, "lineno")[:5]:
    print(stat)

Remember: Compare two tracemalloc snapshots to find which line is allocating, then confirm with gc.get_objects().

See also: tracemalloc and memory profilers · memory leaks in practice

Advertisement