Why is relying on CPython's refcounting to close files unsafe on other implementations?
answer
- The specification never promises a time
- One interpreter's strategy is not the language
- Tracing collectors decide when, or never
- Descriptors accumulate until a hard limit
- Lexically explicit lifetime is the portable fix
basics
~20 sFreeing an object the instant its reference count hits zero is a CPython implementation detail, not a language guarantee. Implementations with tracing collectors, such as PyPy or GraalPy, finalize whenever they choose, so an unclosed file can stay open indefinitely.
solid answer
~40 sThe language specification says nothing about *when* an object is finalized; CPython's prompt destruction falls out of reference counting, and other implementations do not count references at all. On a tracing collector, `open(path).read()` leaves the handle open until some later collection — possibly never before exit — so a job that opens thousands of files exhausts the process descriptor limit and dies with a 'too many open files' error. The fix is explicit lifetime: `with`, `try`/`finally`, `contextlib.closing`, or `contextlib.ExitStack` when the number of resources is dynamic. Even on CPython the guarantee is conditional — a caught exception's traceback pins the frames and locals that hold the handle, and an object in a reference cycle waits for the cycle collector. Surface the bug early by running under `-X dev`, which reports `ResourceWarning` for unclosed files.
code
python · 9 linesdef head_fragile(path):
return open(path, encoding="utf-8").readline()
def head_portable(path):
with open(path, encoding="utf-8") as f:
return f.readline()
print(head_fragile(__file__).strip())
print(head_portable(__file__).strip())go deeper
Recall the rule rather than the theory: always open files with a with block. Know that some interpreters do not free objects the moment you stop using them, so an unclosed file may stay open.
Explain the mechanism on both sides — reference counting frees at zero, a tracing collector finalizes at an unpredictable later time — and name the portable constructions: with, try/finally, contextlib.closing and contextlib.ExitStack.
Diagnose it: rising descriptor counts, an intermittent 'too many open files' failure that only appears at scale, and the CPython-side traps of tracebacks, caches and cycles that defer the free. Show that you would run under dev mode and escalate ResourceWarning to an error in CI.
Own it as a portability and operability constraint. Decide the codebase rule that resource-holding objects expose context-manager lifetimes rather than finalizers, and make the check automatic in CI, so an interpreter or build-configuration change is never gated on auditing cleanup by hand.
## The guarantee that is not one CPython frees an object the instant its reference count reaches zero, and that behaviour is so dependable that a whole idiom grew around it: `data = open(path).read()`, no `close()` in sight, the handle released when the temporary file object dies at the end of the expression. It works. It is also **an implementation artefact, not a rule of Python.** The language specification is deliberately silent about when finalization happens, precisely so that implementations are free to use a different memory manager. They do. Implementations built on tracing garbage collectors — PyPy, GraalPy, Jython — do not maintain per-object reference counts. Unreachable objects are discovered during a collection, which runs when the runtime decides it is worthwhile: after some allocation volume, under memory pressure, or not at all before the process exits. A file object that became garbage may sit unfinalized for a long time, and the operating system handle it owns stays open the whole while. ## What that looks like in production Consider a nightly search-index rebuilder that walks segment files, reading each one with a bare `open(path).readline()` inside a helper. On CPython it has behaved perfectly for a year. Moved to an implementation with a tracing collector for throughput, it opens files far faster than collections reclaim them, crosses the process descriptor limit somewhere in the middle of the run, and dies with `OSError` — "too many open files" — at a different segment on every attempt. The failure is a boundary effect, so it looks nondeterministic and reproduces only at scale; the code that "worked fine locally" for an 11-person team is the same code that fails in the rebuild. The same class of bug bites sockets (connections left in `CLOSE_WAIT`), database cursors and connections held out of a pool, subprocess pipes that keep a child from seeing EOF, memory-mapped regions, and any lock released only in a finalizer. ## Even CPython's guarantee is conditional It is worth being precise, because the honest senior answer is not "CPython is safe, others are not". On CPython the object is freed when the last reference goes — and several ordinary situations mean the last reference has *not* gone: * **A live traceback.** A caught exception keeps its traceback, which keeps every frame in the chain, which keeps those frames' locals — including your open handle — alive for as long as the exception object is referenced. * **A reference cycle.** If the object is in a cycle, no decref ever reaches zero and finalization waits for the cycle collector. * **A cache or registry.** A module-level dict that recorded the handle keeps it forever, and no scope exit changes that. * **Interpreter shutdown.** Finalizers are not guaranteed to run at all during teardown. So even on CPython, "the file closes when the function returns" is a probabilistic statement about your reference graph, not a contract. ## The correct construction Make the lifetime explicit and the question disappears on every implementation: ```python with open(path, encoding="utf-8") as f: head = f.readline() from contextlib import ExitStack with ExitStack() as stack: handles = [stack.enter_context(open(p, encoding="utf-8")) for p in paths] merge(handles) ``` `with` compiles to a `try`/`finally` around the block, so the resource is released on the normal path, on an exception and on an early return, in every implementation, with no dependence on the memory manager. `contextlib.closing` wraps a legacy object that has `close()` but no context-manager protocol; `contextlib.ExitStack` handles a count of resources known only at runtime. For classes of your own, expose `__enter__`/`__exit__` rather than doing cleanup in `__del__` — a finalizer is a safety net whose timing you do not control and whose exceptions are swallowed. ## Catching it before it ships The tooling for this is unusually good, and using it is the part that separates a senior answer from a textbook one: * Run the service and the test suite under **`-X dev`** (or `PYTHONDEVMODE=1`), which enables `ResourceWarning` — an unclosed file reports the line that opened it. * Turn the warning into a failure in CI with `-W error::ResourceWarning`, or per-test with `warnings.simplefilter("error", ResourceWarning)`. * Enable `tracemalloc` alongside dev mode so the warning carries the allocation traceback of the object that was never closed. * Track the descriptor count of long-running processes; a monotonically rising count is the leading indicator, long before the limit is hit. The rule that survives every implementation, every interpreter version and every build configuration is simple: **anything holding an operating-system resource gets an explicit, lexically visible lifetime.** Reference counting is an optimisation that makes sloppiness survivable on one interpreter, not a design you can build on.
- How would you catch this class of bug before it reaches production?Run the test suite and staging processes under `-X dev` (or `PYTHONDEVMODE=1`) so `ResourceWarning` is reported, and escalate it to an error in CI with `-W error::ResourceWarning`. Enable `tracemalloc` so each warning carries the allocation traceback. In long-running services, alert on a rising open-descriptor count rather than waiting for the limit.
- What is the idiom when the number of resources is only known at runtime?`contextlib.ExitStack`: enter it once, register each resource with `enter_context` as you open it, and every one is closed in reverse order when the block exits, including on an exception. `contextlib.closing` covers the older case of an object that has `close()` but no context-manager protocol.
- Is cleanup in `__del__` an acceptable substitute?Only as a last-resort safety net. Its timing is set by the memory manager, it may never run at interpreter shutdown, it will not run promptly for an object in a reference cycle, and exceptions raised inside it are reported and discarded rather than propagated. Real cleanup belongs in `__exit__` or a `finally` block.
- Where does this bite on CPython itself, despite refcounting?Anywhere the last reference has not actually gone: a caught exception's traceback pins whole frames and their locals, a module-level cache holds the object forever, and an object in a reference cycle waits for the cycle collector. Prompt destruction is a statement about your reference graph, not about scope exit.
Refcounting is a hotel that cleans a room the second the guest hands back the key; a tracing collector sweeps the whole floor when it gets around to it. Booking your next guest on the assumption that the room is already clean works in exactly one hotel.
saying these in an interview costs you the question
- Claims the language guarantees cleanup at end of scope
- Says `with` is optional because CPython closes files anyway
- Offers a manual collection call as a portable substitute for closing
- Treats `__del__` as a reliable destructor
- Assumes process exit makes descriptor leaks harmless
- Thinks only files are affected, not sockets or cursors