Skip to content

Python Data Model, Memory, and Garbage Collection

A Python service can return every response correctly and still fail when its worker processes approach a container memory limit. A cache grows quietly, a request object remains reachable through a callback, a reference cycle survives ordinary reference counting, or a native library allocates memory that Python's profiler cannot see. Restarting the worker may temporarily hide the symptom without identifying the cause.

To investigate these problems, separate three questions:

  1. What is the object contract? Identity, equality, mutability, hashing, and references determine how values behave.
  2. When can the object be reclaimed? Python's language model and the selected interpreter's memory manager determine object lifetime.
  3. Which memory measurement are you looking at? Python allocation traces, object sizes, allocator arenas, and process resident memory describe different things.

This guide distinguishes Python-level guarantees from CPython implementation details. Examples target modern Python and use the current Python 3.15 documentation for version-specific notes.

1. Objects have identity, type, and value

Every Python object has an identity, a type, and a value. Its identity does not change during its lifetime. Its type determines the operations it supports. Its value may be mutable or immutable.

Use is to ask whether two references point to the same object. Use == to ask whether their values compare equal:

first = {"order_id": "ord-42"}
second = {"order_id": "ord-42"}

assert first == second
assert first is not second

alias = first
assert alias is first

Do not use is as a general equality check. Identity is appropriate for singletons such as None, not for strings, integers, or application values. In CPython, id(obj) is currently the object's memory address, but that is an implementation detail; an identity value can be reused after the object dies and is not a persistent identifier.

Mutability is about the object itself, not whether a variable can be rebound:

settings = {"features": []}
snapshot = ("tenant-a", settings)

settings["features"].append("exports")
assert snapshot[1]["features"] == ["exports"]

The tuple cannot replace its element reference, but it contains a reference to a mutable dictionary. The dictionary can still change. This distinction matters when a supposedly immutable configuration snapshot, cache key, or event payload contains mutable children.

Aliasing is equally important. If two request handlers or cache entries hold a reference to the same mutable object, a change through one reference is visible through the other. Copying a container is not automatically a deep copy of the object graph it references.

2. Equality and hashing are a correctness contract

Dictionaries and sets use a hash to find a candidate bucket, then equality to distinguish keys. The essential rule is:

If two objects compare equal, they must have the same hash value for as long as they are used as keys.

A value object with immutable fields is a good cache-key candidate:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class ResourceKey:
    tenant_id: str
    resource_id: str

left = ResourceKey("tenant-a", "ord-42")
right = ResourceKey("tenant-a", "ord-42")

assert left == right
assert hash(left) == hash(right)
cache = {left: {"status": "ready"}}
assert cache[right]["status"] == "ready"

A mutable value object should not normally be hashable. If a field that contributes to equality changes after insertion into a set or dictionary, the object may remain in the bucket selected by its old hash and become difficult to find or remove. Python makes a class that defines equality without a compatible hash implementation unhashable by default.

frozen=True prevents ordinary field reassignment; it does not recursively freeze objects stored inside those fields. Prefer immutable field types for keys and keep mutable state outside the key.

A common misconception is that hash() is a durable identifier. It is designed for in-process hash tables, not storage keys, signatures, or cross-process routing contracts. Persist a stable application identifier or an explicitly specified digest when a value must survive process restarts.

3. Instance layout: when __slots__ helps

A normal user-defined instance commonly has an instance dictionary that stores its attributes. That flexibility is useful, but the dictionary and its storage have a cost when a service creates millions of small records.

__slots__ declares the attributes an instance may store and can avoid creating an instance __dict__:

class CompactRecord:
    __slots__ = ("tenant_id", "resource_id", "__weakref__")

    def __init__(self, tenant_id: str, resource_id: str) -> None:
        self.tenant_id = tenant_id
        self.resource_id = resource_id

Slots can reduce per-instance memory and may improve attribute lookup. They are most useful when profiling shows that a large population of small objects is a material part of the worker's memory footprint. They are not a universal optimization.

Check the trade-offs before applying them:

  • An instance cannot accept undeclared attributes unless __dict__ is included.
  • Weak references require __weakref__ in the slots unless a parent already provides weak-reference support.
  • A subclass that does not declare its own slots can reintroduce an instance dictionary.
  • Multiple inheritance and slot layouts need deliberate design.
  • Some serializers, debugging tools, ORMs, and dynamic frameworks expect ordinary attribute dictionaries.

For dataclasses, @dataclass(slots=True) can generate a slotted layout. Measure the actual workload and verify compatibility with the libraries that inspect or extend your objects. Do not infer a production memory saving from one sys.getsizeof() result.

4. Object lifetime in CPython

The Python language does not promise that an unreachable object is destroyed immediately. An implementation may reclaim it later. Code must not rely on immediate finalization to release files, sockets, transactions, or other external resources.

CPython currently combines two mechanisms:

  • Reference counting reclaims most objects when their strong reference count reaches zero.
  • Cyclic garbage collection detects unreachable reference cycles that reference counting alone cannot reclaim.

Consider two objects that refer to each other:

left ───────► right
  ▲             │
  └─────────────┘

After the last external reference disappears, each object still has an incoming reference from the other. Their reference counts do not reach zero. The cyclic collector can identify that the group is unreachable and reclaim it.

del name removes a name binding; it does not directly destroy the object. Another local variable, container, closure, task callback, exception traceback, or global cache may still reference it. Even in CPython, del is not a resource-management API.

The gc module supplements reference counting. Its automatic collector is generational, and explicit gc.collect() is useful in controlled diagnostics and tests. It is not a general fix for retained objects: a reachable object is not garbage, and a full collection does not promise that the process will return memory to the operating system.

Python 3.15 adds duration and candidates to the dictionaries returned by gc.get_stats(). These fields help distinguish collection frequency from collection cost. Check the documentation for the exact Python minor release you deploy before tuning thresholds; collection-generation behavior changed during the Python 3.14 release series.

5. Resource lifetime is not garbage-collection lifetime

Do not wait for garbage collection to close an external resource. Use an explicit lifetime boundary:

from pathlib import Path

def read_first_line(path: Path) -> str:
    with path.open("r", encoding="utf-8") as file:
        return file.readline()

The context manager closes the file when the block exits, including when an exception is raised. Apply the same principle to database transactions, HTTP responses, sockets, locks, and temporary files.

Avoid using __del__() as the primary cleanup mechanism. Finalizers can run at awkward times, may execute during interpreter shutdown, can resurrect objects, and cannot propagate exceptions to the code that originally created the object. Blocking or acquiring a lock from a finalizer can deadlock. Modern CPython can collect many cycles containing finalizers, but that does not make finalizer timing deterministic.

weakref.finalize() is sometimes useful for a last-resort cleanup callback. Its callback and arguments must not keep the target object alive; in particular, do not pass a bound method of the target. Prefer with and explicit close() methods whenever the caller can control the resource lifetime.

A weak reference does not keep its referent alive. WeakValueDictionary can support a cache where values should disappear when no other strong reference exists. It is not a replacement for a bounded TTL cache: entries may disappear earlier than expected, and weak references do not provide expiry, capacity limits, or durable ownership.

6. Why freed objects do not always reduce RSS

Python objects live in a private heap managed by the interpreter. CPython's default GIL-enabled build uses pymalloc for many small allocations. The allocator groups small blocks into pools and arenas; on a 64-bit build, a standard arena is 1 MiB. pymalloc is optimized for allocations of 512 bytes or fewer and uses lower-level allocators for larger blocks.

When an object is freed, its block can become reusable by the Python allocator without the containing pages being returned to the operating system. Fragmentation can leave an arena partly occupied, so the process keeps those pages mapped for later allocations. A full garbage collection may reclaim unreachable objects while RSS remains almost unchanged.

This is not proof of a leak, but it is not proof of health either. Distinguish:

  • Live-object growth: more objects remain reachable after each comparable unit of work.
  • Allocator retention or fragmentation: objects are freed, but arenas or pages remain available to the process.
  • Native-memory growth: an extension, image codec, database driver, compression library, or memory map allocates outside the Python allocation domain.
  • Expected warm-up: imports, caches, connection pools, and one-time initialization increase the steady-state footprint before it plateaus.

This section describes the default GIL-enabled CPython build. Allocator choices differ by interpreter build, so confirm the allocator used by the exact runtime you deploy rather than assuming that every Python implementation uses pymalloc.

7. Choose the measurement that answers the question

Tool or signal What it tells you Important limitation
sys.getsizeof(obj) Direct size attributed to one object Does not recursively include referenced objects
tracemalloc snapshots Python allocation growth by traceback, file, or line Does not include allocations made before tracing starts; native allocations may be outside its domain
gc.get_stats() Collection counts and, in Python 3.15, candidates and duration Describes collector activity, not total process memory
Process RSS / USS and container metrics Memory resident in the process or charged to its container Includes native memory and allocator-retained pages, not just live Python objects

Start tracemalloc early when you need to compare a repeatable workload. Take one snapshot after warm-up, run the same operation a controlled number of times, and compare a second snapshot:

import tracemalloc

tracemalloc.start(10)
baseline = tracemalloc.take_snapshot()

# Run a representative batch of requests here.
run_request_batch()

after = tracemalloc.take_snapshot()
for difference in after.compare_to(baseline, "lineno")[:10]:
    print(difference)

More traceback frames improve attribution but increase tracing overhead. Snapshots do not include allocations made before tracing began. C extensions can use separate tracing domains, but native allocations that bypass Python's memory allocator will not automatically appear in ordinary Python allocation statistics.

Use process and container measurements alongside tracemalloc. If RSS rises while traced Python memory stays flat, investigate native buffers, extension modules, memory maps, allocator behavior, and the container's accounting. If both rise, inspect the largest positive snapshot differences and identify which references keep those allocations alive.

sys.getsizeof() is useful for comparing direct object layouts, not for estimating an application's full memory footprint. A list's direct size does not include all the objects referenced by the list, and summing nested sizes naively can count shared objects more than once.

8. A production investigation workflow

  1. Establish a comparable baseline. Record interpreter version and build, worker count, container limit, RSS, traced memory, request rate, and cache sizes after warm-up.
  2. Reproduce the growth. Run the same request mix for multiple windows. Compare memory per request or per job, not only total memory after an arbitrary interval.
  3. Compare snapshots. Start tracing early, take before-and-after snapshots, and sort by positive size difference and allocation count. Keep the workload and sampling interval consistent.
  4. Check ownership. Inspect caches, module globals, callback registries, closures, task references, exception tracebacks, and long-lived request objects. Use gc.get_referrers() only in controlled debugging; it can expose temporary or surprising references and is not a production monitoring API.
  5. Separate Python and native memory. If RSS grows without corresponding traced growth, inspect native libraries and process-level memory maps with tools appropriate to the deployment.
  6. Fix the retention policy. Bound caches by size and/or time, remove completed callbacks, release large temporary references, and make ownership explicit. Do not replace a strong cache with weak references unless early eviction is acceptable.
  7. Validate the fix under load. Repeat the same workload and check the memory slope, latency, GC pauses, and error rate. A worker restart that resets RSS is a mitigation, not proof of a fix.

Avoid calling gc.collect() on every request or disabling automatic collection because one benchmark looked faster. Both can move the cost elsewhere and hide the actual retention problem. For pre-fork servers, gc.freeze() is a specialized copy-on-write optimization: the documented sequence disables GC in the parent early, freezes long-lived objects just before fork(), and re-enables GC in children. Use it only when the server's process model and measured memory behavior justify it.

9. Common failure modes

  • An unbounded in-memory cache: retained objects are reachable, so the collector correctly leaves them alone. Add explicit capacity and expiry policies.
  • A mutable dictionary key: changing fields that affect equality or hashing breaks lookup assumptions. Use immutable value keys.
  • A callback retains a request: a long-lived registry or closure can keep an entire object graph alive. Unregister callbacks when their work is complete.
  • A finalizer owns the resource lifecycle: cleanup timing becomes nondeterministic and shutdown behavior becomes fragile. Use context managers and explicit close operations.
  • RSS is treated as a live-object count: allocator retention and native allocations are misdiagnosed as Python cycles. Compare several memory signals before changing GC settings.
  • __slots__ is applied everywhere: dynamic behavior or weak-reference support breaks, while the measured memory improvement may be negligible. Optimize high-volume types only after profiling.

10. Interview questions with reasoned answers

1. What is the difference between identity and equality?

Identity asks whether two references point to the same object; equality asks whether their values compare equal. Use is for singleton checks such as value is None. Use == for application values. Do not depend on integer or string interning to make identity comparisons work.

2. Why can a tuple contain mutable state?

A tuple is immutable with respect to its element references. If one element refers to a list or dictionary, that referenced object can still change. Immutability of a container does not recursively freeze the object graph.

3. What must be true when implementing __eq__() and __hash__()?

Equal objects must have equal hashes, and a key's hash must remain stable while it is in a dictionary or set. If equality depends on mutable fields, the object should normally be unhashable. A frozen value object with immutable fields is a safer key.

4. Why does CPython need cyclic garbage collection if it has reference counting?

Reference counting promptly reclaims most acyclic objects, but a group of objects can keep one another's reference counts above zero after all external references disappear. The cyclic collector detects unreachable cycles and reclaims them.

5. Does del obj guarantee that the object is destroyed?

No. It removes one name binding. Other references may remain, and Python does not promise immediate reclamation. Use explicit resource-management methods rather than relying on object destruction.

6. Why might gc.collect() run without RSS decreasing?

It may reclaim unreachable cycles, but the allocator can retain arenas for reuse. RSS also includes native allocations and mapped pages. Compare live Python allocation traces with process and container metrics.

7. When would you use __slots__?

When profiling shows that many small instances make per-object dictionaries a meaningful memory cost, and the class does not need arbitrary attributes. Check inheritance, weak-reference support, serializers, and framework behavior before adopting it.

8. What does tracemalloc miss?

It cannot report allocations made before tracing started, and ordinary Python-domain tracing does not automatically include native allocations that bypass Python's memory allocator. Pair it with process-level memory metrics and native profiling when RSS growth is unexplained.

9. Why are weak references not a general cache policy?

A weak reference does not keep an object alive. The value can disappear as soon as no strong references remain, even if the cache would otherwise want to retain it. Weak references do not provide TTL, capacity, or persistence guarantees.

Practical checklist

  • Use is for identity and == for value equality.
  • Keep hashable keys immutable for their entire time in a set or dictionary.
  • Treat frozen=True and tuples as shallow immutability when they contain mutable references.
  • Consider __slots__ only after measuring high-volume object populations.
  • Distinguish CPython reference counting and cyclic GC from language-level lifetime guarantees.
  • Close external resources explicitly with context managers or documented close methods.
  • Measure traced Python allocations and process/container memory together.
  • Investigate retained references before changing GC thresholds or disabling collection.
  • Reproduce memory growth under a representative workload and verify the slope after a fix.

References