The close() method is one of those things in Python that appears deceptively simple. Call it on a file, the file closes. Done, right? But close() is not a single method. It is a pattern that appears across files, generators, sockets, database connections, and any resource that borrows something from the operating system and needs to give it back.
Understanding how close() actually works, why it exists in so many forms, and what happens when you skip it will change how you think about resource management in Python. This article traces close() from its lowest level (OS file descriptors) through its most elegant expression (the with statement), covering the PEPs that shaped its evolution and the real-world failures that happen when developers get it wrong.
The Operating System Beneath Your Python
Before looking at Python's close(), you need to understand what it is closing and why closing matters at all.
When Python opens a file with open(), it does not interact with the disk directly. It asks the operating system for access, and the OS returns an integer called a file descriptor. This is essentially a ticket number. The OS maintains a table of open file descriptors for every running process, and each entry in that table consumes kernel memory and may hold an exclusive lock on the underlying resource.
Every operating system imposes a limit on how many file descriptors a single process can hold open simultaneously. On Linux, you can check this limit with ulimit -n, and the default is often 1024. On macOS, it is commonly 256. That sounds like plenty until you consider that every open file, every network socket, every pipe to a subprocess, and every database connection each consumes a file descriptor.
When you call close() on a file object in Python, two things happen in sequence. First, Python flushes any buffered data that has not yet been written to disk. Second, Python calls the C-level close() system call, which tells the operating system to release the file descriptor and free the associated kernel resources. The file descriptor number becomes available for reuse.
If you never call close(), those file descriptors remain allocated until the Python process exits or until CPython's garbage collector destroys the file object. In CPython (the standard implementation), garbage collection of file objects happens fairly quickly due to reference counting. But on other implementations like PyPy, Jython, or IronPython, garbage collection is non-deterministic. A file that goes out of scope might not be collected for seconds, minutes, or at all during the program's lifetime. Meanwhile, its file descriptor sits occupied.
What Actually Happens When You Forget
The consequences of not closing files are not theoretical. They manifest as the infamous OSError: [Errno 24] Too many open files, and they tend to appear in production rather than development, because the pattern that triggers them requires volume.
Consider a function that processes log files:
def process_logs(directory):
results = []
for filename in os.listdir(directory):
f = open(os.path.join(directory, filename))
results.append(parse(f.read()))
# f is never closed
return results
If the directory contains 50 files, this might work fine. If it contains 2,000 files, the process will exhaust its file descriptor limit and crash. The fix is trivial, but the failure mode is insidious because it only appears at scale.
As documented in PEP 433 (authored by Victor Stinner), the consequences of leaked descriptors extend beyond resource exhaustion. Leaking file descriptors is a major security vulnerability: an untrusted child process can read sensitive data like passwords and take control of the parent process through leaked file descriptors. File descriptors are OS-level resources with real security implications.
The Buffering Problem: Why close() Matters for Correctness, Not Just Cleanup
Resource exhaustion is the dramatic failure mode, but there is a subtler one that affects correctness: buffered writes.
Python's file I/O is buffered by default. When you call f.write("hello"), the string does not necessarily hit the disk immediately. Python accumulates writes in an internal buffer and flushes them to the OS in larger chunks for performance. The buffer is flushed when it fills up, when you explicitly call f.flush(), or when you call f.close().
If your program crashes or you simply forget to close a file opened for writing, the unflushed buffer contents are lost:
f = open("output.txt", "w")
f.write("critical data")
# Program crashes here, or f goes out of scope
# "critical data" may never reach the disk
This is not a bug in Python. It is how buffered I/O works at every level of the software stack, from C's stdio to Java's BufferedWriter. But it means close() is not just a cleanup obligation. It is part of the correctness contract for any write operation.
You can verify this behavior directly:
import os
f = open("test_buffer.txt", "w")
f.write("buffered content")
# Check: has anything been written to disk yet?
print(os.path.getsize("test_buffer.txt")) # Likely 0
f.close()
print(os.path.getsize("test_buffer.txt")) # Now shows 16
The first getsize call may return 0 because the data exists only in Python's in-process buffer. After close(), the data is flushed to the OS and the size reflects the written content.
file.close(): The Mechanics
The close() method on file objects is idempotent: calling it multiple times is safe and raises no error. After the first call, the file object's closed attribute becomes True, and any subsequent read or write operation raises a ValueError:
f = open("example.txt", "w")
f.write("hello")
f.close()
f.close() # No error, safe to call again
print(f.closed) # True
f.write("world") # ValueError: I/O operation on closed file.
Internally, CPython's file object wraps a C FILE* pointer (for text mode) or raw file descriptor (for binary mode). The close() method calls fclose() or close() at the C level, which handles both flushing and descriptor release in a single operation.
There is also os.close(fd), which operates directly on integer file descriptors without the Python file object layer:
import os
fd = os.open("raw.txt", os.O_WRONLY | os.O_CREAT)
os.write(fd, b"low-level write")
os.close(fd)
This is lower-level and rarely needed in application code, but it is what libraries like subprocess use internally when managing pipes and child process I/O.
The try/finally Era: Close Before with Existed
Before Python 2.5, the recommended pattern for ensuring files were closed was try/finally:
f = open("data.txt", "r")
try:
data = f.read()
process(data)
finally:
f.close()
The finally block executes regardless of whether the try block raises an exception, so the file is always closed. This works, but it is verbose, easy to forget, and scales poorly when you need to manage multiple resources:
f1 = open("input.txt", "r")
try:
f2 = open("output.txt", "w")
try:
f2.write(f1.read())
finally:
f2.close()
finally:
f1.close()
The nesting becomes painful. This pattern was so common, and so commonly done wrong, that the Python community spent years designing a language-level solution.
PEP 343: The with Statement
PEP 343, titled "The 'with' Statement," was originally written by Guido van Rossum and later updated by Nick Coghlan. It was accepted and implemented in Python 2.5 (September 2006). The PEP's opening motivation was direct: the with statement exists to factor out the standard try/finally pattern.
The mechanism is the context management protocol. Any object that implements __enter__() and __exit__() methods can be used with the with statement:
with open("data.txt", "r") as f:
data = f.read()
# f.close() has been called automatically
When execution enters the with block, Python calls f.__enter__(), which returns the file object (bound to the name f). When execution leaves the block, whether normally, via an exception, a return, break, or continue, Python calls f.__exit__(), which calls f.close().
"Many context managers (such as files and generator-based contexts) will be single-use objects. Once the __exit__() method has been called, the context manager will no longer be in a usable state (e.g. the file has been closed, or the underlying generator has finished execution)." — PEP 343
The with statement is now the universally recommended way to handle file I/O in Python. It is shorter than try/finally, impossible to forget (the close() is implicit), and correctly handles exceptions. Multiple resources can be managed in a single statement:
with open("input.txt") as fin, open("output.txt", "w") as fout:
fout.write(fin.read())
# Both files closed automatically
Generator close(): PEP 342 and the Birth of Coroutines
Files are not the only objects in Python that need closing. Generators have a close() method too, and its story is intertwined with some of the most important changes in Python's history.
When generators were introduced in Python 2.3 (via PEP 255), they were one-way producers of data. A generator function could yield values, but there was no way to send information back into it, no way to throw exceptions into it, and no way to tell it to shut down gracefully. Critically, you could not use yield inside a try/finally block, which meant generators could not perform cleanup when they were abandoned mid-iteration.
This was a significant limitation. Consider a generator that opens a file and yields its lines:
# Python 2.3 - this pattern was BROKEN
def read_lines(path):
f = open(path)
try:
for line in f:
yield line
finally:
f.close() # This finally clause could never run!
In Python 2.3, the above code was a syntax error. You could not put yield inside try/finally. The file would only be closed if the generator was fully exhausted. If the caller abandoned the generator partway through, the file leaked.
PEP 325, "Resource-Release Support for Generators" (authored by Samuele Pedroni, June 2003), first proposed adding a close() method to generators to solve this problem. It was ultimately rejected in favor of a more comprehensive solution: PEP 342.
PEP 342, "Coroutines via Enhanced Generators" (authored by Guido van Rossum and Phillip J. Eby), was accepted for Python 2.5 and introduced three new methods on generator objects: send(), throw(), and close().
The close() method works by injecting a GeneratorExit exception at the point where the generator is suspended:
def counting():
i = 0
try:
while True:
yield i
i += 1
finally:
print(f"Generator cleaned up at i={i}")
gen = counting()
print(next(gen)) # 0
print(next(gen)) # 1
gen.close() # Prints: Generator cleaned up at i=2
When close() is called, Python throws GeneratorExit at the yield point. The generator's finally clause runs, giving it a chance to release resources. If the generator catches GeneratorExit and tries to yield another value instead of exiting, Python raises a RuntimeError.
The addition of close() to generators had one crucial side effect: close() is called when a generator is garbage-collected, meaning the generator's code gets one last chance to run before the generator is destroyed. This is what made try...finally statements in generators reliable, and it is the foundation that made PEP 343's with statement possible. Without PEP 342's close(), generator-based context managers could not exist.
contextlib.closing: Wrapping Objects That Forgot to Be Context Managers
Not every object with a close() method implements the context management protocol. Many third-party libraries, older Python code, and even some standard library objects have a close() method but no __enter__/__exit__ methods.
The contextlib module provides closing() for exactly this situation:
from contextlib import closing
import urllib.request
with closing(urllib.request.urlopen("https://example.com")) as page:
content = page.read()
# page.close() called automatically
The implementation is simple:
class closing:
def __init__(self, thing):
self.thing = thing
def __enter__(self):
return self.thing
def __exit__(self, *exc_info):
self.thing.close()
It wraps any object with a close() method in a context manager. This is a practical adapter that bridges the gap between the older close() convention and the modern with statement protocol.
close() Across the Standard Library
The close() pattern extends far beyond files and generators. Here is how it manifests across the Python standard library, with the real mechanics behind each one.
Sockets (socket.close()): Releases the network file descriptor. For TCP connections, this initiates the FIN handshake to cleanly terminate the connection. An improperly closed socket can leave the remote end waiting indefinitely and can also leave the local port in a TIME_WAIT state for minutes:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("example.com", 80))
s.sendall(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
response = s.recv(4096)
s.close()
# Or, using the context manager (socket supports it since Python 3.2):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("example.com", 80))
# ...
Database connections (connection.close()): Releases the database connection back to the server (or to a connection pool). Open database connections consume server resources (memory, process slots) and are typically limited. A web application that leaks database connections will eventually fail to serve requests:
import sqlite3
conn = sqlite3.connect("app.db")
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
rows = cursor.fetchall()
finally:
conn.close()
# Or with context manager:
with sqlite3.connect("app.db") as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
rows = cursor.fetchall()
# Note: sqlite3's context manager commits/rolls back
# but does NOT close the connection
The sqlite3 connection's __exit__ method commits or rolls back the transaction, but it does not call close(). You still need to close the connection separately, or wrap it with contextlib.closing.
subprocess.Popen (.stdin.close(), .stdout.close(), .stderr.close()): Subprocess pipes are file descriptors. Failing to close them can cause deadlocks, where the child process blocks waiting for input that will never come, and the parent blocks waiting for output the child cannot produce because its buffers are full:
import subprocess
proc = subprocess.Popen(
["sort"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE
)
proc.stdin.write(b"banana\napple\ncherry\n")
proc.stdin.close() # Critical: signals EOF to the child process
output = proc.stdout.read()
proc.stdout.close()
proc.wait()
Without proc.stdin.close(), the sort command never receives EOF and never produces output. The program deadlocks.
zipfile.ZipFile and tarfile.TarFile (.close()): These must be closed to finalize the archive. For zip files, the central directory (the index of all files in the archive) is written during close(). If you skip it, the archive is corrupt:
import zipfile
with zipfile.ZipFile("archive.zip", "w") as zf:
zf.write("document.txt")
# Central directory written during close()
# Without close(), archive.zip is unreadable
io.StringIO and io.BytesIO (.close()): These are in-memory streams. Calling close() frees the internal buffer. After close(), the buffer memory is released and further read/write operations raise ValueError.
Async close(): The aclose() Pattern
With the introduction of asynchronous generators in Python 3.6 (PEP 525, authored by Yury Selivanov), the question of generator cleanup gained a new dimension. Async generators can hold resources like network connections that require await to properly release.
Async generators have an aclose() method (note the a prefix) that works like close() but is a coroutine:
async def read_stream(url):
session = aiohttp.ClientSession()
try:
async with session.get(url) as response:
async for chunk in response.content.iter_chunked(1024):
yield chunk
finally:
await session.close()
# Consumer code:
gen = read_stream("https://example.com/large-file")
async for chunk in gen:
if enough_data(chunk):
break
await gen.aclose() # Ensures session.close() runs
Without await gen.aclose(), the HTTP session might never be properly cleaned up. The async with statement handles this automatically for context managers, but for async generators that are abandoned mid-iteration, explicit aclose() is required.
Building Your Own Closeable Objects
If you are writing a class that acquires resources, implementing the context management protocol is straightforward and strongly recommended:
class DatabasePool:
def __init__(self, connection_string, pool_size=5):
self.connections = [
create_connection(connection_string)
for _ in range(pool_size)
]
def close(self):
for conn in self.connections:
conn.close()
self.connections.clear()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False # Do not suppress exceptions
def __del__(self):
# Safety net, not a guarantee
if self.connections:
self.close()
The __del__ method serves as a last resort, but you should never depend on it. In CPython, __del__ runs when the reference count drops to zero, which is usually immediate. But objects involved in reference cycles may not be collected until the cyclic garbage collector runs, and __del__ methods can actually prevent collection in some cases (especially in Python versions before 3.4). Always provide an explicit close() and support with.
The closed Property: Checking State
File objects and many other closeable types expose a closed property (not a method) that returns True after close() has been called:
f = open("test.txt", "w")
print(f.closed) # False
f.close()
print(f.closed) # True
This is useful for defensive programming. If you are writing a function that might receive a file object in an unknown state, checking closed before attempting operations avoids ValueError:
def safe_read(f):
if f.closed:
raise RuntimeError("Cannot read from a closed file")
return f.read()
Common Mistakes and How to Avoid Them
Mistake 1: Closing only on the happy path.
f = open("data.txt")
data = f.read()
process(data) # If this raises, f.close() never runs
f.close()
Fix: use with, always.
Mistake 2: Forgetting that reassignment does not close.
f = open("file1.txt")
f = open("file2.txt") # file1.txt's descriptor is now leaked
f.close() # Only closes file2.txt
The first file object has no remaining references in CPython, so reference counting will eventually close it. But "eventually" is not "immediately," and on non-CPython implementations, it may be "never during the program's lifetime."
Mistake 3: Assuming sqlite3's with closes the connection.
with sqlite3.connect("app.db") as conn:
conn.execute("INSERT INTO logs VALUES (?)", (message,))
# Connection is NOT closed here, only committed
The sqlite3 module's context manager handles transactions, not connection lifecycle. Close explicitly or use contextlib.closing.
Mistake 4: Silently swallowing exceptions during close.
try:
f = open("output.txt", "w")
f.write(data)
except:
pass
finally:
f.close() # What if open() failed? f is not defined.
If open() itself raises (permissions error, disk full), then f was never assigned, and f.close() raises NameError. The with statement handles this correctly because __exit__ is only called if __enter__ succeeded.
The Design Philosophy: Explicit Is Better Than Implicit
Python's approach to resource cleanup reflects a clear philosophy. The language provides deterministic cleanup through with and close(), but does not require it. You can leave files unclosed and rely on garbage collection. In CPython, this usually works. But the language community, its documentation, and its tooling (ResourceWarning in newer Python versions) consistently push toward explicit cleanup.
This stands in contrast to languages like Go (where defer handles cleanup), Rust (where RAII ties resource lifetime to scope), and Java (where try-with-resources arrived only in Java 7). Python's with statement arrived in 2006 with Python 2.5, making it one of the earlier mainstream implementations of this pattern, and the PEP trail that led to it (PEP 310, PEP 340, PEP 342, PEP 343) shows years of careful deliberation about getting it right.
"I'd rather not introduce a new exception class just for this purpose, since it's not an exception that I want people to catch: I want it to turn into a traceback which is seen by the programmer who then fixes the code." — Guido van Rossum, PEP 343
He was speaking specifically about the RuntimeError raised when a generator misbehaves during close(), but the broader principle applies: Python's resource management is designed to surface mistakes clearly rather than hide them behind silent cleanup.
Proving It: Measuring File Descriptor Leaks
Here is a concrete demonstration of what happens when you forget to close. This code counts the number of file descriptors consumed by opening files without closing them:
import os
def count_open_fds():
"""Count open file descriptors for this process (Linux/macOS)."""
return len(os.listdir(f"/proc/{os.getpid()}/fd"))
baseline = count_open_fds()
print(f"Baseline FDs: {baseline}")
# Open 100 files without closing them
leaked = []
for i in range(100):
f = open(f"/dev/null", "r")
leaked.append(f) # Keep reference so GC doesn't collect
print(f"After opening 100 files: {count_open_fds()}")
print(f"Leaked FDs: {count_open_fds() - baseline}")
# Now close them all
for f in leaked:
f.close()
print(f"After closing all: {count_open_fds()}")
print(f"Recovered FDs: {count_open_fds() - baseline}")
On a Linux system, this will show 100 additional file descriptors allocated, then returned to the baseline after closing. Scale this to thousands, and you hit the ulimit wall.
Summary: A Timeline of close() in Python
The evolution of close() in Python tracks the language's maturing understanding of resource management.
- Earliest Python versions:
file.close()as a manual method, requiring developers to remember to call it. - PEP 325 (Samuele Pedroni, June 2003): First proposed adding
close()to generators for resource release, identifying the problem that generators could not perform cleanup when abandoned. - PEP 342 (Guido van Rossum and Phillip J. Eby, accepted for Python 2.5, September 2006): Superseded PEP 325 and implemented
generator.close(), along withsend()andthrow(), enabling generators to be used as coroutines and to perform cleanup viatry/finally. - PEP 343 (Guido van Rossum and Nick Coghlan, accepted for Python 2.5, September 2006): Introduced the
withstatement and the context management protocol, making automaticclose()the standard idiom for files, locks, and other resources. - PEP 433 (Victor Stinner, December 2012): Addressed file descriptor inheritance, highlighting the security and reliability implications of leaked file descriptors across process boundaries.
- PEP 525 (Yury Selivanov, accepted for Python 3.6, September 2016): Extended the pattern to async generators with
aclose().
The story of close() is really the story of Python learning, one PEP at a time, that acquiring a resource is the easy part. The hard part is guaranteeing that you let it go, every time, even when things go wrong. The with statement is Python's answer to that problem, and close() is the engine underneath it.
References and Further Reading
- PEP 343 — The "with" Statement (Guido van Rossum & Nick Coghlan): https://peps.python.org/pep-0343/
- PEP 342 — Coroutines via Enhanced Generators (Guido van Rossum & Phillip J. Eby): https://peps.python.org/pep-0342/
- PEP 325 — Resource-Release Support for Generators (Samuele Pedroni): https://peps.python.org/pep-0325/
- PEP 433 — Easier Suppression of File Descriptor Inheritance (Victor Stinner): https://peps.python.org/pep-0433/
- PEP 525 — Asynchronous Generators (Yury Selivanov): https://peps.python.org/pep-0525/
- Python Documentation — contextlib module: https://docs.python.org/3/library/contextlib.html
- Python 2.5 "What's New" — PEP 342: New Generator Features: https://docs.python.org/2.5/whatsnew/pep-342.html
- Python 2.5 "What's New" — PEP 343: The 'with' Statement: https://docs.python.org/2.5/whatsnew/pep-343.html