Filter concepts by levelShowing all levels.

Python · Section 46

File and Resource Handling

Level
intermediate
Read
150 min
Concepts
6

Reading and writing files correctly regardless of size — text vs. binary mode and encoding, streaming instead of loading everything into memory, temporary files and directories that clean up automatically, file permissions, compression and archiving with gzip/zipfile/tarfile, and the resource-cleanup guarantee with provides on top of a plain open()/close() pair.

This section

What is true here

  1. Text mode ("r") always needs an explicit encoding to decode bytes to str; binary mode ("rb") never takes one and returns raw bytes untouched.
  2. for line in f: and while chunk := f.read(size): both keep memory use flat regardless of total file size — f.read() with no argument loads everything at once.
  3. with open(path) as f: guarantees close() runs on every exit path — success, early return, or exception — a manually placed f.close() does not.
  4. TemporaryDirectory() auto-deletes itself and its contents on with-block exit; NamedTemporaryFile(delete=False) is the portable choice for handing a path to another process.
  5. gzip compresses a single stream; zipfile and tarfile bundle multiple files into one archive, and extracting an untrusted archive needs a path-traversal-safe filter.

What you will be able to do

  • Choose the correct file mode (text vs. binary) and always pass an explicit encoding for text
  • Process a file too large to fit in memory by streaming it line by line or in fixed-size chunks
  • Create and clean up a temporary file or directory correctly, including across platforms
  • Read and set a file's permission bits with chmod/stat, and know where that guarantee is weaker (Windows)
  • Compress data with gzip and bundle multiple files into a zip or tar archive
  • Explain why with is the correct default for any resource that needs guaranteed cleanup, not just files

Reading and writing correctly, at any size

The mode/encoding decision every open() call makes, and processing a file too large to fit in memory at once.

File modes, binary vs. text files, and encoding

corebeginner

open(path, "r") reads text and decodes bytes to str using an encoding — always pass encoding="utf-8" explicitly. open(path, "rb") reads raw bytes with no decoding at all — no encoding applies.

Think of it as

A file on disk is always bytes — text mode is a translation layer Python adds on read/write, converting bytes to str using an encoding, and back again on write. Binary mode skips that translation entirely and hands you the raw bytes. Choosing the wrong mode either double-translates data that was never text (corrupting it) or leaves you manually decoding bytes that "r" mode would have handled for you.

python
with open(path, "r", encoding="utf-8") as f:   # text -- returns str
    text = f.read()

with open(path, "rb") as f:                     # binary -- returns bytes
    data = f.read()

from pathlib import Path
Path(path).read_text(encoding="utf-8")          # shortcut for the "r" case
Path(path).read_bytes()                         # shortcut for the "rb" case

What we're doing: Write a file with pathlib in text mode, read it back as both bytes and str, then trigger a real UnicodeDecodeError by reading UTF-8 bytes with the wrong encoding.

modes_and_encoding.pypython
from pathlib import Path

p = Path("unicode.txt")
p.write_bytes("café".encode("utf-8"))

print(repr(p.read_bytes()))
print(repr(p.read_text(encoding="utf-8")))

try:
    p.read_text(encoding="ascii")
except UnicodeDecodeError as e:
    print("UnicodeDecodeError:", e)
4
"café".encode("utf-8") produces the real multi-byte UTF-8 representation of é, written to disk as raw bytes.
6
read_bytes() returns those bytes completely unchanged — no interpretation at all.
10
Asking Python to decode the same UTF-8 bytes as ASCII fails, because ASCII has no byte value above 127 and UTF-8's multi-byte sequences use them.
Output
b'caf\xc3\xa9'
'café'
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3: ordinal not in range(128)

Why this works: é encodes to two UTF-8 bytes (\xc3\xa9), not one — read_bytes() shows those raw bytes untouched, and read_text(encoding="utf-8") correctly decodes them back to café. Asking for encoding="ascii" instead fails deterministically, because ASCII assigns no meaning to any byte above 127 — the exact byte UTF-8 used to represent é. This is the real failure encoding mismatches produce, not a hypothetical one.

Opening a genuinely binary file in text mode

Wrong

python
# image.png is a real PNG file -- binary from the first byte
with open("image.png", "r", encoding="utf-8") as f:
    data = f.read()   # UnicodeDecodeError almost immediately -- PNG bytes are not valid UTF-8

Better

python
with open("image.png", "rb") as f:
    data = f.read()   # raw bytes, exactly as stored -- no decoding attempted

What you see: UnicodeDecodeError raised almost immediately — a PNG's raw bytes are essentially never a valid UTF-8 byte sequence, so text mode's automatic decode step fails on the file's own header bytes.

Why: Text mode always tries to decode every byte it reads using the given encoding, whether or not the file is actually text. A binary format like PNG, ZIP, or a compiled executable has no encoding at all — it needs "rb" so Python returns the raw bytes untouched rather than attempting to interpret them as UTF-8 text.

Text mode vs. binary mode

Text mode ("r"/"w")

  • +Decodes bytes to str on read, encodes str to bytes on write
  • +Needs an encoding — always pass encoding="utf-8" explicitly
  • +For files that ARE genuinely text: source code, JSON, CSV, logs

Binary mode ("rb"/"wb")

  • No decoding at all — you get and give raw bytes
  • No encoding parameter — bytes have no encoding
  • For files that are NOT text: images, zips, compiled binaries
  • Text mode ("r"/"w")
    • Decodes bytes to str on read, encodes str to bytes on write
    • Needs an encoding — always pass encoding="utf-8" explicitly
    • For files that ARE genuinely text: source code, JSON, CSV, logs
  • Binary mode ("rb"/"wb")
    • No decoding at all — you get and give raw bytes
    • No encoding parameter — bytes have no encoding
    • For files that are NOT text: images, zips, compiled binaries

Common mode strings

Common mode strings
ModeMeaningReturns
"r"Read, text (default)str
"rb"Read, binarybytes
"w"Write, text — truncates existing contentstr in, nothing back
"a"Append, text — writes are added to the endstr in, nothing back
"x"Exclusive create — raises FileExistsError if the path already existsstr in, nothing back

Together

python
from pathlib import Path

p = Path("notes.txt")
p.write_text("first line\n", encoding="utf-8")   # "w" under the hood
with p.open("a", encoding="utf-8") as f:           # append, does not truncate
    f.write("second line\n")
print(p.read_text(encoding="utf-8"))

Remember: The mode string decides str ("r") vs. bytes ("rb") — text mode always needs an explicit encoding (encoding="utf-8"), binary mode never takes one. A file that is not actually text must open "rb".

See also: pathlib module · streaming and large file processing

Streaming files and large-file processing

coreintermediate

for line in open(path): reads one line at a time instead of loading the whole file into memory — essential once a file is bigger than the memory available to hold it. f.read(size) does the same thing in fixed-size chunks for files with no line structure.

Think of it as

file.read() is emptying a warehouse into one truck — it has to fit, all at once. Iterating a file line by line, or reading fixed-size chunks, is a conveyor belt — only one small piece needs to exist in memory at any moment, so the total file size stops being a memory constraint at all.

python
# Line-structured text -- constant memory regardless of file size
with open(path) as f:
    for line in f:
        process(line)

# No line structure -- fixed-size chunks
def read_in_chunks(path, size=8192):
    with open(path, "rb") as f:
        while chunk := f.read(size):
            yield chunk

What we're doing: Write a five-line file, then read it back two ways — once line by line, once in fixed-size 10-byte chunks — and confirm both process the whole file without ever holding it all in memory at once.

stream_a_file.pypython
from pathlib import Path

p = Path("big.txt")
with p.open("w", newline="\n") as f:
    for i in range(5):
        f.write(f"line {i}\n")

with p.open() as f:
    line_count = sum(1 for _ in f)
print("lines read one at a time:", line_count)

def read_in_chunks(path, size=10):
    with open(path, "rb") as f:
        while chunk := f.read(size):
            yield chunk

chunks = list(read_in_chunks(p))
print("num chunks:", len(chunks), "first chunk:", repr(chunks[0]))
9
sum(1 for _ in f) counts lines by iterating the file object directly — at no point does the whole file exist as one in-memory list.
17
read_in_chunks() is a generator — each yield hands back only ONE 10-byte chunk before continuing, so memory use never scales with total file size.
Output
lines read one at a time: 5
num chunks: 4 first chunk: b'line 0\nlin'

Why this works: Iterating a file object with for line in f: asks the file for one line at a time internally, which is why sum(1 for _ in f) never builds a full list of lines. read_in_chunks() applies the same idea to raw bytes with no line structure — f.read(10) returns at most 10 bytes per call, and the walrus-operator while loop keeps calling it until an empty chunk (b'') signals end-of-file, which is what stopped it here after exactly 4 chunks for a 34-byte file.

Reading an entire large file into memory just to process it line by line

Wrong

python
with open("access.log") as f:
    lines = f.readlines()      # every line loaded into one list, all at once
for line in lines:
    process(line)
# fine for a 10KB log; a 10GB log either exhausts memory or swaps badly

Better

python
with open("access.log") as f:
    for line in f:              # one line in memory at a time
        process(line)
# memory use is the same whether the log is 10KB or 10GB

What you see: MemoryError, or the process slows to a crawl from swapping, once the input file grows past what fits comfortably in RAM — a script that worked fine in testing fails only in production on real-sized data.

Why: f.readlines() (and f.read()) both fully materialize the entire file's content in memory before returning — the file object itself is only a thin wrapper around the OS file handle, but readlines() forces every line to exist as a Python list element simultaneously. Iterating the file object directly (for line in f:) instead asks the underlying buffered reader for one line at a time, so memory use depends on the longest single line, not the total file size.

One chunk in memory at a time, however large the file
next chunkempty read

big.txt on disk

size irrelevant — never fully loaded

f.read(size)

returns at most `size` bytes

yield chunk

hands back ONE chunk, pauses here

process(chunk)

downstream code, one piece at a time

b'' — EOF

loop stops automatically

  • big.txt on disk — size irrelevant — never fully loaded
    • leads to f.read(size)
  • f.read(size) — returns at most `size` bytes
    • leads to yield chunk
    • on error, leads to b'' — EOF (empty read)
  • yield chunk — hands back ONE chunk, pauses here
    • leads to process(chunk)
  • process(chunk) — downstream code, one piece at a time
    • leads to f.read(size) (next chunk)
  • b'' — EOF — loop stops automatically

Loading everything vs. streaming, by file shape

Loading everything vs. streaming, by file shape
File shapeWhole-file approachStreaming approach
Line-structured text (logs, CSV)f.read().splitlines()for line in f:
No line structure (binary, a single JSON blob)f.read()while chunk := f.read(8192):
Very large, still line-structuredlist(f) — all lines held at oncefor line in f: process(line) — one line held at a time

Together

python
def read_in_chunks(path, size=8192):
    with open(path, "rb") as f:
        while chunk := f.read(size):
            yield chunk

total_bytes = sum(len(chunk) for chunk in read_in_chunks("data.bin"))
print(total_bytes)

Remember: for line in f: and while chunk := f.read(size): both process a file in fixed, small pieces — memory use stays flat regardless of total file size. f.read() with no argument loads everything at once.

See also: generator functions · file modes binary text and encoding

Advertisement

Scratch files and permissions

Creating a temporary file or directory that cleans up automatically, and the Python API for reading and setting permission bits.

Temporary files

standardbeginner

tempfile.TemporaryDirectory() creates a scratch directory in a with block and deletes it (and everything in it) automatically on exit. tempfile.NamedTemporaryFile() does the same for a single file.

Think of it as

A temp file is a whiteboard, not a filing cabinet — you use it for scratch work during one operation, and it should disappear on its own when that operation ends, without you having to remember to erase it. tempfile handles both the naming (guaranteed not to collide with anything else on the system) and the erasing.

python
import tempfile

with tempfile.TemporaryDirectory() as td:
    ...                                    # td is deleted, with contents, on exit

with tempfile.NamedTemporaryFile(mode="w", delete=True) as tf:
    tf.write("data")
    tf.flush()
    ...                                    # tf's file is deleted on close

What we're doing: Create a real temporary file, confirm it exists, remove it, and confirm it is gone — then do the same for a temporary directory using the context-manager form that cleans up automatically.

temp_files.pypython
import tempfile
import os

with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tf:
    tf.write("scratch data")
    tmp_path = tf.name
print("exists:", os.path.exists(tmp_path))
os.remove(tmp_path)
print("exists after cleanup:", os.path.exists(tmp_path))

with tempfile.TemporaryDirectory() as td:
    print("temp dir exists during with:", os.path.isdir(td))
print("temp dir cleaned up after with")
4
delete=False keeps the file on disk after the with block closes it, so its path can still be used afterward — otherwise it would vanish the instant the block exits.
8
Because delete=False was used, the file has to be removed manually with os.remove() — nothing does it automatically.
11
TemporaryDirectory(), unlike NamedTemporaryFile(delete=False), always deletes itself and everything inside it on with-block exit — no manual cleanup needed.
Output
exists: True
exists after cleanup: False
temp dir exists during with: True
temp dir cleaned up after with

Why this works: NamedTemporaryFile(delete=False) leaves the file in place after the with block, which is why 'exists: True' prints before the explicit os.remove() — the file's own context manager only closes the handle, it does not delete anything when delete=False. TemporaryDirectory(), in contrast, has no delete= option at all — it always removes the directory and its full contents automatically the moment the with block exits, which is why nothing needed to call it a second time.

Using NamedTemporaryFile(delete=True) but trying to reopen the file by path while it is still open

Wrong

python
import tempfile

with tempfile.NamedTemporaryFile(mode="w", delete=True) as tf:
    tf.write("data")
    tf.flush()
    with open(tf.name) as f2:          # PermissionError on Windows: the file
        print(f2.read())               # is still exclusively open by tf

Better

python
import tempfile, os

with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf:
    tf.write("data")
    tmp_path = tf.name
# tf is closed here -- now it is safe to reopen by path on every platform
with open(tmp_path) as f2:
    print(f2.read())
os.remove(tmp_path)   # caller is responsible for cleanup with delete=False

What you see: PermissionError: [Errno 13] Permission denied — reproduced for real on this environment's Windows filesystem. NamedTemporaryFile keeps its own handle open for the file's entire lifetime, and Windows (unlike POSIX systems) will not let a second handle open a file that is still exclusively held open elsewhere.

Why: NamedTemporaryFile holds its file handle open continuously until the with block closes it — that is how it can delete the file automatically on close. Trying to open the SAME path again while that handle is still live works on POSIX (multiple handles to one file are fine there) but fails on Windows, which enforces exclusive access by default. delete=False, closing the file, and only then reopening it by path is the pattern that works identically on every platform.

The tempfile toolkit

The tempfile toolkit
FunctionCreatesAuto-cleanup
TemporaryDirectory()a directoryyes, on with-block exit
NamedTemporaryFile()a file with a real pathyes, on close (delete=True default)
NamedTemporaryFile(delete=False)a file with a real pathno — caller must os.remove() it
mkstemp() / mkdtemp()a file / a directoryno — lower-level, manual cleanup

Together

python
import tempfile, os

with tempfile.TemporaryDirectory() as td:
    scratch = os.path.join(td, "work.txt")
    with open(scratch, "w") as f:
        f.write("temporary")
    print(os.path.exists(scratch))
print(os.path.exists(td))

Remember: TemporaryDirectory() as a with-block context manager deletes itself and its contents automatically on exit. NamedTemporaryFile(delete=False) is the portable choice when another process or a reopened handle needs the path — clean it up manually with os.remove().

See also: resource cleanup · with statement

File permissions

standardintermediate

Path.chmod(0o644) (or os.chmod(path, 0o644)) sets a file's permission bits from Python — the same octal numbers chmod uses at the shell. stat.S_IMODE(path.stat().st_mode) reads them back.

Think of it as

chmod's three octal digits are owner/group/other, and each digit is read-write-execute added together (4+2+1=7 is everything, 6 is read+write, 4 is read-only). Python's os.chmod/Path.chmod set exactly the same bits a shell's chmod command would — there is no separate Python-specific permission model.

python
from pathlib import Path
import stat

p = Path("file.txt")
p.chmod(0o644)                              # set permissions
mode = stat.S_IMODE(p.stat().st_mode)       # read them back
print(oct(mode))                            # '0o644'

What we're doing: Set a file read-write, read back its permission bits, then set it read-only and confirm the change with stat.S_IMODE().

file_permissions.pypython
from pathlib import Path
import stat

p = Path("notes.txt")
p.write_text("draft\n")

p.chmod(0o644)
print("mode:", oct(stat.S_IMODE(p.stat().st_mode)))

p.chmod(0o444)
print("read-only mode:", oct(stat.S_IMODE(p.stat().st_mode)))
7
p.stat().st_mode includes the file TYPE bits too — stat.S_IMODE() masks those off, leaving just the permission bits.
9
chmod(0o444) removes every write bit — owner, group, and other all lose write access, leaving only read.
Output
mode: 0o644
read-only mode: 0o444

Why this works: stat.S_IMODE() exists specifically because st_mode is not just the permission bits — it also encodes whether the path is a regular file, directory, or symlink, packed into the same integer. Masking with S_IMODE() isolates the twelve permission-relevant bits, which is why the printed value matches exactly the octal number passed to chmod(), with no extra bits from the file-type portion leaking through.

Assuming os.chmod's owner/group/other model behaves identically on Windows

Wrong

python
import os
# Written assuming full POSIX semantics everywhere
os.chmod("secrets.txt", 0o600)   # "only the owner can read this" -- NOT
                                   # reliably true on Windows, which does not
                                   # honor group/other bits the same way

Better

python
import os, stat

# On Windows, chmod reliably controls only the read-only bit --
# for real access control there, use the Windows ACL APIs (pywin32)
# or design the deployment to run on a POSIX host for this guarantee
os.chmod("secrets.txt", stat.S_IREAD)   # portable: marks read-only, at minimum

What you see: A script that correctly restricts a file to owner-only access on Linux/macOS provides no such guarantee when the same code runs on Windows — silently, with no exception raised.

Why: os.chmod (and Path.chmod) call the underlying OS's own permission API, and Windows's model is fundamentally not the POSIX owner/group/other model — chmod() on Windows only reliably toggles the read-only attribute, not real per-user access control. Code that assumes chmod(0o600) means "only the owner can read this" everywhere is making a real, unverified portability assumption; genuine access control on Windows needs the Windows ACL APIs instead.

Common octal permission values

Common octal permission values
OctalMeaningTypical use
0o644owner rw, group r, other ra regular file others may read
0o600owner rw, group —, other —a private file (secrets, credentials)
0o755owner rwx, group rx, other rxan executable script, or a directory
0o444owner r, group r, other ra read-only file — no one can write without changing the mode first

Together

python
from pathlib import Path
import stat

p = Path("config.txt")
p.write_text("secret=value\n")
p.chmod(0o600)
mode = stat.S_IMODE(p.stat().st_mode)
print(oct(mode))

Remember: Path.chmod(0o644) / os.chmod(p, 0o644) set permission bits; stat.S_IMODE(p.stat().st_mode) reads them back, masking off the file-type bits st_mode also carries. On Windows, chmod only reliably controls the read-only bit — it is not the full POSIX model.

See also: permissions users and groups · file modes binary text and encoding

Advertisement

Bundling data and guaranteeing cleanup

Compressing and archiving multiple files into one, and the guarantee with provides that a plain open()/close() pair does not.

Compression and archives

standardintermediate

gzip compresses a single stream of bytes. zipfile and tarfile bundle multiple files (and directories) into one archive — tarfile can also apply gzip compression to the whole bundle at once ("w:gz" mode).

Think of it as

Compression and archiving solve two different problems that are often used together. Compression shrinks bytes — one stream in, a smaller stream out. Archiving bundles multiple files and their names/structure into one container — many files in, one file out. A .tar.gz is both: tar bundles, gzip shrinks the bundle.

python
import gzip, zipfile, tarfile

with gzip.open("out.gz", "wb") as f:        # single-stream compression
    f.write(data)

with zipfile.ZipFile("bundle.zip", "w") as zf:
    zf.write("notes.txt", arcname="notes.txt")

with tarfile.open("bundle.tar.gz", "w:gz") as tf:
    tf.add("notes.txt")

What we're doing: Compress a file with gzip and compare its size to the original, then bundle two files into a zip archive and list its contents back.

compress_and_archive.pypython
import gzip
import zipfile
from pathlib import Path

p = Path("notes.txt")
p.write_text("cafe\n", encoding="utf-8")

gz_path = Path("notes.txt.gz")
with open(p, "rb") as f_in, gzip.open(gz_path, "wb") as f_out:
    f_out.writelines(f_in)
print("gz size:", gz_path.stat().st_size, "original size:", p.stat().st_size)

zip_path = Path("bundle.zip")
with zipfile.ZipFile(zip_path, "w") as zf:
    zf.write(p, arcname="notes.txt")
with zipfile.ZipFile(zip_path) as zf:
    print("zip contents:", zf.namelist())
9
gzip.open(..., "wb") wraps a normal binary file handle, compressing every byte written through it.
15
ZipFile.write(source_path, arcname=...) adds one file to the archive under the given internal name — arcname controls the name inside the zip, independent of the source path.
Output
gz size: 35 original size: 5
zip contents: ['notes.txt']

Why this works: A five-byte file compressed with gzip actually grows here (35 bytes) — gzip's fixed header and checksum overhead exceeds any savings on data this tiny; compression only pays off once the input is large enough for gzip's DEFLATE algorithm to find repeated patterns worth encoding. The zip's namelist() confirms exactly one entry, under the arcname given, independent of what the source file was actually called on disk.

Extracting an archive from an untrusted source without checking its member paths

Wrong

python
import tarfile

with tarfile.open("uploaded.tar.gz") as tf:
    tf.extractall("uploads/")   # a malicious archive can contain a member
                                 # named "../../etc/passwd" -- path traversal

Better

python
import tarfile

with tarfile.open("uploaded.tar.gz") as tf:
    # Python 3.12+ defaults extraction filters to "data", which rejects
    # absolute paths and ../ traversal -- explicit here for clarity
    tf.extractall("uploads/", filter="data")

What you see: A file ends up written OUTSIDE the intended extraction directory — potentially overwriting a sensitive file elsewhere on the filesystem — because a tar member's name is not required to be a simple relative path.

Why: A tar (or zip) archive member's recorded name is just a string chosen by whoever created the archive — nothing stops it from being "../../etc/passwd" or an absolute path. Before Python 3.12's safer default extraction filter, extractall() would honor that name literally, writing outside the target directory. Explicitly passing filter="data" (or checking member names before extracting on older Python) rejects that class of malicious archive.

gzip, zipfile, and tarfile — one file each, one row each

gzip, zipfile, and tarfile — one file each, one row each
ModuleBundles multiple files?Compresses?
gzipno — one stream in, one outyes
zipfileyesyes (DEFLATE, per-file)
tarfile ("w")yesno
tarfile ("w:gz")yesyes (gzip, whole archive)

Together

python
import tarfile

with tarfile.open("bundle.tar.gz", "w:gz") as tf:
    tf.add("notes.txt")
    tf.add("data.csv")

with tarfile.open("bundle.tar.gz") as tf:
    print(tf.getnames())

Remember: gzip compresses a single stream; zipfile and tarfile bundle multiple files, and tarfile's "w:gz" mode does both in one step. Never extractall() an untrusted archive without a path-traversal-safe filter (filter="data" on Python 3.12+).

See also: file modes binary text and encoding · json

Resource cleanup

corebeginner

with open(path) as f: guarantees f.close() runs even if an exception happens inside the block — a plain f = open(path) followed by f.close() at the end does not, if something raises before that line runs.

Think of it as

An open file is a borrowed resource — the operating system only allows a limited number open at once, and anything you wrote to it might still be sitting in an in-memory buffer, not actually on disk, until it is closed. with guarantees the "give it back" step happens no matter how the block exits — normally, via return, or via an exception — the same guarantee a try/finally gives you, with less code.

python
with open(path) as f:
    data = f.read()
# f.close() has already run here, guaranteed -- even if the block raised

class ManagedResource:
    def __enter__(self):
        self.handle = acquire()
        return self.handle
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.handle.release()   # runs even if the with block raised
        return False             # don't suppress the exception

What we're doing: Write a custom context manager wrapping a file, deliberately let the with block do nothing unusual, then confirm __exit__ still ran by checking a tracked "closed" flag.

resource_cleanup.pypython
class TrackedFile:
    def __init__(self, path):
        self.path = path
        self.closed = False
    def __enter__(self):
        self.f = open(self.path)
        return self.f
    def __exit__(self, *exc):
        self.f.close()
        self.closed = True
        return False

t = TrackedFile("big.txt")
with t as f:
    pass
print("closed after with block:", t.closed)
8
__exit__ closes the real file handle AND records that cleanup ran — this is the hook with calls automatically on block exit.
13
Even though the with block does nothing (pass), __exit__ still runs when the block ends — the guarantee holds regardless of what happened inside.
Output
closed after with block: True

Why this works: __exit__ is called by the with statement itself as part of the protocol, not by any code written inside the block — which is exactly why t.closed is True even though the block's only statement was pass. The same __exit__ call would happen if the block had raised an exception instead, which is the actual guarantee with exists to provide over a plain open()/close() pair.

Opening a file without with and forgetting close() on an early-exit path

Wrong

python
def read_config(path):
    f = open(path)
    data = f.read()
    if not data:
        return None          # f.close() below is SKIPPED -- handle leaks
    config = parse(data)
    f.close()
    return config

Better

python
def read_config(path):
    with open(path) as f:    # closes automatically on every exit path
        data = f.read()
        if not data:
            return None       # f still gets closed
        return parse(data)

What you see: No immediate error — the leak is silent. In a long-running process (a server handling many requests) this accumulates until the OS's per-process open-file-descriptor limit is hit, then OSError: Too many open files starts failing operations that have nothing to do with the original leak.

Why: A bare return statement exits the function immediately, skipping every line after it — including a manually placed f.close(). with removes this entire class of bug because __exit__ runs on ANY exit from the block: normal completion, an early return, or an exception, without needing the cleanup line to be reachable on every code path.

What with guarantees around the block it wraps
runs firstsuccess, return,OR exception

__enter__

opens/acquires the resource

the with block

normal code, may raise

__exit__

ALWAYS runs — close(), release, etc.

  • __enter__ — opens/acquires the resource
    • leads to the with block (runs first)
  • the with block — normal code, may raise
    • leads to __exit__ (success, return, OR exception)
  • __exit__ — ALWAYS runs — close(), release, etc.

Cleanup guarantee, with vs. without with

Cleanup guarantee, with vs. without with
PatternRuns close() on exception?Runs close() on early return?
with open(path) as f: ...yes, alwaysyes, always
f = open(path); ...; f.close()no — an exception before f.close() skips itno — an early return before f.close() skips it
try: ...\nfinally: f.close()yesyes

Together

python
def read_first_line(path):
    with open(path) as f:      # closed automatically no matter what happens below
        line = f.readline()
        if not line:
            raise ValueError("empty file")   # f still gets closed
        return line

Remember: with open(path) as f: guarantees close() runs on every exit path — normal completion, an early return, or an exception — which a manually placed f.close() at the end of a function does not.

See also: with statement · temporary files

Advertisement