Filter concepts by levelShowing all levels.

Python · Memory Optimization

Memory Optimization

Investigating why a service's memory keeps growing — measured with sys.getsizeof, tracemalloc, and gc, not guessed at

  • __slots__ at scalea modest per-instance saving, multiplied across many thousands
  • Generators over listsconstant-size state vs. holding every item at once
  • tracemalloc snapshotscompare two points in time to find WHICH line is leaking
  • Weak referencesa cache entry that disappears once nothing else needs it

1.Object overhead and references, for memory investigation

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.

Key features

  • sys.getsizeof(obj) reports real bytes, including overhead — good for spotting one large object
  • An object is freed only once nothing references it — a lingering reference is a common growth cause
  • a = b never copies — both names reference the same object, so mutating either affects both
  • gc.get_referrers(obj) answers "what is still holding this object" during an investigation
  • A steadily growing sys.getsizeof() on one container signals an unbounded cache or leak

Command

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

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

2.Mutable containers and large collections

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.

Key features

  • list.append() occasionally triggers a resize that over-allocates room for future appends
  • sys.getsizeof(a_list) reports the CONTAINER's size only — not the objects it references
  • A list of 1,000,000 ints costs more than that count suggests — one pointer per item too
  • tuple is slightly more memory-efficient than list for fixed data — it never over-allocates
  • A dict/set also over-allocates its hash table, for the same amortized-growth reason

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
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

Command

python
import sys

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

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

3.Generators and iterators, for memory

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.

Key features

  • A generator expression is constant-size, regardless of how many items it will produce
  • A list comprehension allocates space for every item immediately, all at once
  • Any function using yield is a generator function — calling it returns a generator, unrun
  • sum(), any(), all() only need one item at a time — a generator argument is enough
  • A generator can only be consumed once — there is no rewinding it, unlike a list

Command

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

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

4.__slots__, at scale

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.

Key features

  • A regular instance carries a __dict__ (hundreds of bytes) even for one or two attributes
  • A __slots__ instance has no __dict__ — its attributes live in fixed-size slots instead
  • The saving PER INSTANCE is modest; the saving across many thousands of instances is not
  • __slots__ is most worth adding to classes instantiated in bulk — rows, events, graph nodes
  • It is rarely worth adding to a class with a handful of long-lived instances — savings never accumulate

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
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

Command

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+)

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

5.Shallow and deep copies, and their memory cost

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.

Key features

  • copy.copy(x) creates a new outer object; nested mutable objects are still shared, not duplicated
  • copy.deepcopy(x) recursively duplicates every nested object — genuinely more memory used
  • A deep copy of a large nested structure can cost as much memory as the original, doubling usage
  • Reach for deepcopy only when independence is actually required — a shallow copy is cheaper otherwise
  • A flat structure (no nested mutables) makes shallow and deep copy cost the same — nothing to duplicate

Command

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

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

6.Memory leaks, in practice

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.

Key features

  • The most common real leak: an unbounded, module-level cache or list that only ever grows
  • A registered callback or subscriber never unregistered keeps its target alive indefinitely
  • Steadily growing process RSS, with no drop, is the symptom that starts an investigation
  • tracemalloc.take_snapshot(), compared across two points, shows which allocations grew
  • The fix is almost always an eviction policy, a max size, or an explicit unsubscribe step

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
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

Command

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)

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

7.Reference cycles and garbage collection, for memory investigation

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.

Key features

  • gc.collect() forces a collection now, returns how many objects it freed — a useful signal
  • gc.garbage lists objects the collector found uncollectable (rare, a __del__ complication)
  • A steady rise in gc.get_count() alongside growing memory suggests cycles outpacing collection
  • Breaking a cycle deliberately (a weak reference on one side) avoids relying on the collector
  • Most programs never think about this — it matters only once investigation points here

Command

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)

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

8.Weak references, as a memory optimization

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.

Key features

  • A plain dict used as a cache holds strong references — cached values stay alive as long as the cache does
  • weakref.WeakValueDictionary holds weak references — an entry vanishes once nothing else references it
  • The standard fix for a cache that should reflect what is CURRENTLY in use, not keep everything alive
  • Not every object supports weak references — plain int, str, tuple do not by default
  • A bare weakref.ref(obj) needs manually checking whether the object is still alive (ref() returns None once freed)

Command

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

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

9.Caching side effects

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.

Key features

  • @lru_cache(maxsize=None) caches EVERY distinct argument set forever
  • Called with highly varied arguments (a user ID), an unbounded cache grows without limit
  • @lru_cache(maxsize=1000) bounds it — least-recently-used entries are evicted once full
  • A cache keyed on something high-cardinality (a request ID) is a common unbounded pattern
  • The fix is almost always a maxsize, a TTL, or switching to a weakref-based cache

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
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

Command

python
from functools import lru_cache

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

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

10.Memory profiling, in practice

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.

Key features

  • A single tracemalloc snapshot shows a moment; comparing TWO shows what grew between them
  • snapshot.statistics("lineno") groups allocations by source line — fast way to find a culprit
  • tracemalloc.get_object_traceback(obj) shows exactly where a specific object was allocated
  • gc.get_objects(), filtered by type, counts live instances of a suspected class
  • Take snapshots at two points separated by real traffic/time, not immediately back to back

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
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

Command

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")

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

Remember

  • A Python "leak" almost always means something is still referenced when it should not be, not unreachable memory.
  • A generator holds constant-size state; a list holds every item at once — drop the brackets for one-pass reductions.
  • __slots__ saves a modest amount per instance — worth it specifically for classes instantiated in bulk.
  • A deep copy duplicates every nested object and costs real memory; a shallow copy shares them and costs almost nothing.

Pro tip

  • snapshot2.compare_to(snapshot1, "lineno") finds exactly which line is allocating new memory between two points in time.
  • @lru_cache(maxsize=None) grows forever — bound it, or key on something that actually repeats.
  • weakref.WeakValueDictionary entries vanish automatically once nothing else references the value.
  • gc.get_objects() filtered by isinstance confirms exactly how many live instances of a suspected class exist right now.

Next up

  • Logging and Observability

Sources

  • tracemalloc — docs.python.org/3/library/tracemalloc.html
  • gc — docs.python.org/3/library/gc.html
  • Every measurement run for real on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.