File I/O
The open() Built-in
Section titled “The open() Built-in”open() is the primary interface for file I/O in Python. It returns a file object (an instance of io.TextIOWrapper for text mode or io.BufferedReader/io.BufferedWriter for binary mode) that Provides methods for reading, writing, and seeking.
The mode string controls what operations are permitted and whether the file is interpreted as text Or binary data. Modes are composed from the following characters:
| Character | Meaning |
|---|---|
r | Read (default). File must exist; raises FileNotFoundError otherwise. |
w | Write. Truncates the file to zero length if it exists; creates it otherwise. Data loss on open. |
x | Exclusive creation. Creates the file; raises FileExistsError if it already exists. |
a | Append. Creates the file if it does not exist; writes always go to the end. |
b | Binary mode. Data is bytesNo encoding, no newline translation. |
t | Text mode (default). Data is strEncoding applied, newline translation. |
+ | Update. Allows both reading and writing (file is not truncated on open for r+). |
The mode defaults to "rt' (read, text) if no mode is specified.
Combination modes and their semantics:
open("file.txt", "r") # read-only, text, must existopen("file.txt", "w") # write-only, text, truncate or createopen("file.txt", "a") # write-only, text, append (or create)open("file.txt", "x") # write-only, text, exclusive createopen("file.txt", "rb") # read-only, binaryopen("file.txt", "r+") # read+write, text, must exist, no truncationopen("file.txt", "w+") # read+write, text, truncate or createopen("file.txt", "a+") # read+write, text, append (or create), reads start at beginningopen("file.txt", "rb+") # read+write, binary, must exist, no truncationThe distinction between w and a is critical. w truncates the file to zero bytes at the Moment open() is called, before any data is written. If you open a file with w and then your Program crashes before writing anything, the original data is already gone. a does not truncate — It positions the write pointer at the end of the file (or at the beginning if the file is empty or Newly created).
The x mode is the safest way to create a new file because it fails atomically if the file already Exists. This prevents race conditions in concurrent environments where another process might create The file between your existence check and your open call.
The + mode allows simultaneous reading and writing, but the seek position is shared. After Writing, the file position is at the end of the written data. You must seek back to the beginning to Read what you just wrote. After reading, the file position is after the last byte read.
The encoding Parameter
Section titled “The encoding Parameter”In Python 3, open() defaults to the platform’s default encoding, which is UTF-8 on Linux And macOS, and cp1252 (Windows-1252) on Windows. This is a source of cross-platform bugs.
The correct practice is to always specify the encoding explicitly:
open("file.txt", "r", encoding="utf-8")open("file.txt", "w", encoding="utf-8")UTF-8 is the de facto standard for text files in 2024+. If you need to handle legacy files, common Encodings include:
utf-8: Variable-width encoding, 1-4 bytes per character. Covers all Unicode code points. Self-synchronizing (any byte position can be identified as a start, continuation, or invalid byte).utf-16: 2 or 4 bytes per character. May include a BOM (Byte Order Mark:U+FEFF) at the start. Files without a BOM are ambiguous in byte order. Rarely used for general text files.utf-16-le``utf-16-be: Explicit endianness variants of UTF-16 without BOM.latin-1/iso-8859-1: Single-byte encoding covering the first 256 Unicode code points. Every byte inlatin-1is valid, so decoding never fails — but the decoded text may be garbage if the file is not actually Latin-1.ascii: 7-bit encoding. RaisesUnicodeDecodeErrorfor any byte > 127.cp1252: Windows default. A superset of ISO 8859-1 with additional characters in the 0x80-0x9F range (smart quotes, em dash, euro sign).
The errors Parameter
Section titled “The errors Parameter”Controls how encoding/decoding errors are handled:
| Value | Behavior |
|---|---|
"strict" (default) | Raises UnicodeDecodeError or UnicodeEncodeError. |
"ignore" | Silently skips invalid bytes or characters. Data loss. |
"replace" | Replaces invalid bytes with U+FFFD (REPLACEMENT CHARACTER) on decode, or ? on encode. |
"surrogateescape" | Maps invalid bytes to private-use code points (U+DC80-U+DCFF). Allows round-tripping arbitrary bytes through str without data loss. Used for filenames on Unix. |
"backslashreplace" | Replaces invalid bytes with Python escape sequences (e.g., \xff). Useful for debugging. |
"namereplace" | Replaces unsupported characters with \N{...} escape sequences. Encode-only. |
"xmlcharrefreplace" | Replaces unsupported characters with XML numeric character references (e.g., ÿ). Encode-only. |
The surrogateescape error handler is essential for handling filenames on Unix systems. Unix Filenames are arbitrary byte sequences (except / and \0), and they may not be valid UTF-8. When Python lists directory entries using os.listdir()It uses surrogateescape to decode the raw Bytes into str objects. The resulting strings may contain surrogate code points, which are not Valid Unicode but can be round-tripped back to the original bytes by encoding with surrogateescape.
import os
for name in os.listdir("/tmp"): # 'name' may contain surrogates if the filename is not valid UTF-8 encoded = name.encode("utf-8", errors="surrogateescape") # 'encoded' is the original raw bytesThe buffering Parameter
Section titled “The buffering Parameter”Controls the size of the I/O buffer. The default is system-dependent ( 4096 or 8192 bytes For text mode).
buffering=-1(default): Uses the system default buffer size (io.DEFAULT_BUFFER_SIZE).buffering=0: Unbuffered. Only valid in binary mode. Every read/write hits the OS directly. Useful for interacting with devices or pipes where you need byte-level control.buffering=1: Line-buffered. Only valid in text mode. The buffer is flushed whenever a newline character is written. Useful for interactive output (logs, progress indicators) where you want each line to appear immediately.buffering > 1: Fixed-size buffer of the specified number of bytes.
Buffering has performance implications. Unbuffered I/O requires a system call for every read/write Operation, which is orders of magnitude slower than buffered I/O for small operations. Fully Buffered I/O accumulates data in memory and writes it to the OS in large chunks, amortizing the System call overhead.
The newline Parameter
Section titled “The newline Parameter”Controls how newline characters are handled in text mode. This is one of the most misunderstood Parameters in Python’s I/O system.
By default (newline=None), Python performs universal newline translation on input and platform-native newline writing on output:
- On read:
\n``\r\nAnd\rare all translated to\n. - On write:
\nis translated to the platform’s line separator (\non Unix,\r\non Windows).
newline value | Read behavior | Write behavior |
|---|---|---|
None (default) | Universal newlines (all forms to \n) | Platform-native (\n to \r\n on Windows) |
"" (empty string) | Universal newlines (all forms to \n) | No translation (\n written as \n) |
"\n" | Only \n recognized as newline | No translation |
"\r" | Only \r recognized as newline | No translation |
"\r\n" | Only \r\n recognized as newline | No translation |
Setting newline="" (empty string) is the most common explicit choice. It enables universal newline Translation on read (so you always get \n regardless of the file’s origin) while writing \n As-is on output (no platform-specific translation). This is what you want for source code files, Configuration files, and any text format that defines \n as the line separator.
Setting newline="\n" disables all translation in both directions. The file is read and written As-is. Use this when you need to preserve the exact byte content of newlines.
The closefd Parameter
Section titled “The closefd Parameter”When closefd=True (the default), closing the file object also closes the underlying file Descriptor. When closefd=FalseThe file descriptor is not closed when the file object is closed. This is only relevant when passing an existing file descriptor (integer) instead of a file path:
import os
fd = os.open("file.txt", os.O_RDONLY)f = open(fd, "r", closefd=False)## Closing 'f' does not close the file descriptorf.close()os.close(fd) # Must close the descriptor explicitlyIf you open a file by path (the normal case), closefd must be True (the default). Setting it to False when opening by path raises ValueError.
Context Manager Pattern
Section titled “Context Manager Pattern”with open(...) as f:
Section titled “with open(...) as f:”The context manager protocol ensures that the file is closed even if an exception occurs during Processing:
with open("data.txt", "r", encoding="utf-8") as f: content = f.read()## f is guaranteed to be closed here, even if read() raised an exceptionThis is equivalent to:
f = open("data.txt", "r", encoding="utf-8")try: content = f.read()finally: f.close()The __enter__ method returns the file object itself (which is why as f gives you the same Object). The __exit__ method calls f.close()Which flushes the write buffer and releases the File descriptor back to the OS.
Why You Must Always Use Context Managers for Files
Section titled “Why You Must Always Use Context Managers for Files”Exception safety. Without
withAny exception betweenopen()andclose()leaks the file descriptor. In a long-running process, leaked file descriptors accumulate until the process hits the OS limit ( 1024 or 65536 per process) and starts gettingOSError: [Errno 24] Too many open files.Buffer flushing.
close()flushes the write buffer. Withoutclose()Data in the buffer may never reach disk. The garbage collector will eventually callclose()But the timing is nondeterministic — it could be seconds or minutes later, and data could be lost if the process crashes.Explicitness. The
withblock makes the scope of the open file visually clear. Readers can see exactly where the file is open and where it is closed.Resource limits. On Linux, each process has a file descriptor limit (
ulimit -n). Leaking descriptors is a real production issue, not just a theoretical concern. Tools likelsofcan reveal descriptor leaks in running processes.
The __enter__ and __exit__ Protocol
Section titled “The __enter__ and __exit__ Protocol”The context manager protocol consists of two methods:
__enter__(self): Called when thewithblock is entered. The return value is bound to the variable afteras.__exit__(self, exc_type, exc_value, traceback): Called when thewithblock exits, whether normally or due to an exception. If an exception occurred, the three arguments are the exception type, value, and traceback. If no exception occurred, all three areNone. If__exit__returnsTrueThe exception is suppressed.
For file objects, __exit__ always calls self.close() and returns None (exceptions propagate). It does not suppress exceptions.
Opening Multiple Files Simultaneously
Section titled “Opening Multiple Files Simultaneously”Python supports multiple context managers in a single with statement:
with open("input.txt", "r", encoding="utf-8") as fin, \ open("output.txt", "w", encoding="utf-8") as fout: for line in fin: fout.write(line.upper())Or with ExitStack for dynamic numbers of files:
from contextlib import ExitStack
with ExitStack() as stack: files = [ stack.enter_context(open(f, "r", encoding="utf-8")) for f in filenames ] # all files are closed when the with block exitsExitStack is also useful when you need to conditionally open files:
from contextlib import ExitStack
with ExitStack() as stack: sources = [] for path in paths: if path.exists(): sources.append(stack.enter_context(path.open("r", encoding="utf-8"))) else: sources.append(io.StringIO("")) # process sourcesText vs Binary I/O
Section titled “Text vs Binary I/O”Text Mode (t)
Section titled “Text Mode (t)”Text mode is the default. In text mode:
- Reading returns
strobjects. The file’s bytes are decoded using the specified encoding (default: platform encoding, which you should always override withencoding="utf-8"). - Writing accepts
strobjects. They are encoded using the specified encoding. - Newline translation is performed (controlled by the
newlineparameter, as described above). seek()andtell()operate on character positions, not byte positions. Seeking is only allowed at positions that correspond to the start of a character encoding (for UTF-8, seeking to arbitrary byte positions within a multi-byte character raisesUnicodeDecodeError).
Text mode adds an io.TextIOWrapper layer on top of a io.BufferedReader/io.BufferedWriter Which itself sits on top of a raw io.FileIO. This is the I/O stack: raw I/O -> buffered I/O -> Text I/O.
Binary Mode (b)
Section titled “Binary Mode (b)”In binary mode:
- Reading returns
bytesobjects. No decoding is performed. - Writing accepts
bytes(orbytearrayOrmemoryview) objects. No encoding is performed. - No newline translation occurs. You get the raw bytes exactly as stored on disk.
seek()andtell()operate on byte positions. You can seek to any byte offset.- The
encoding``errorsAndnewlineparameters are not accepted and will raiseValueError.
When to Use Each
Section titled “When to Use Each”Use text mode for human-readable text files: source code, configuration files (JSON, YAML, TOML, INI), log files, CSV files (with appropriate dialect handling), and any file where the content is Semantically text.
Use binary mode for everything else: images, audio, video, compiled binaries, network protocols, Serialized data (pickle, protobuf, msgpack), database files, compressed archives, and any file Format where the byte values matter independently of any text encoding.
Use binary mode for text files when:
- You need byte-level control (e.g., parsing a binary format that contains embedded text).
- You are dealing with files that may not be valid in any known encoding.
- Performance is critical and you want to avoid the overhead of encoding/decoding.
- You need to handle files with mixed encodings (some sections UTF-8, others Latin-1, etc.).
Reading Files
Section titled “Reading Files”f.read()
Section titled “f.read()”Reads the entire file content as a single string (text mode) or bytes object (binary mode):
with open("file.txt", "r", encoding="utf-8") as f: content = f.read()This loads the entire file into memory. For large files (anything over available RAM minus the size Of other in-memory data), this will cause MemoryError or trigger aggressive swapping. Do not use f.read() on files that may be large. Use iteration instead.
f.read(n)
Section titled “f.read(n)”Reads up to n characters (text mode) or bytes (binary mode):
with open("file.txt", "r", encoding="utf-8") as f: chunk = f.read(4096) while chunk: process(chunk) chunk = f.read(4096)In text mode, n is the number of characters, not bytes. A single UTF-8 character can be 1-4 bytes, So f.read(4) in text mode reads 4 characters (up to 16 bytes), not 4 bytes. This is a common Source of confusion when switching between text and binary mode.
f.readline()
Section titled “f.readline()”Reads a single line, including the trailing newline character (or \r\n which is translated to \n In text mode with default newline=None). Returns an empty string at EOF:
with open("file.txt", "r", encoding="utf-8") as f: while True: line = f.readline() if not line: break process(line)readline() retains the newline character so you can distinguish between a blank line ("\n") and EOF (""). This is the only way to tell if the file ends with a trailing newline.
f.readlines()
Section titled “f.readlines()”Reads all remaining lines into a list:
with open("file.txt", "r", encoding="utf-8") as f: lines = f.readlines()This is equivalent to list(f) but slightly less Pythonic. Both load all lines into memory. Each Line retains its trailing newline. Do not use this on large files.
Iteration: for line in f:
Section titled “Iteration: for line in f:”The idiomatic way to read a file line by line without loading the entire file into memory:
with open("file.txt", "r", encoding="utf-8") as f: for line in f: process(line)This uses internal buffering to read the file in chunks ( 8KB) and yields individual lines. Memory usage is bounded by the buffer size plus the longest line in the file, regardless of the Total file size. This is the correct pattern for processing large files.
The internal implementation uses a read-ahead buffer in io.TextIOWrapper. It reads a large chunk From the underlying BufferedReaderSplits it into lines, and yields them one at a time. When the Buffer is exhausted, it reads another chunk. This amortizes the system call overhead while keeping Memory usage low.
f.seek() and f.tell()
Section titled “f.seek() and f.tell()”f.tell() returns the current position in the file (character offset in text mode, byte offset in Binary mode). f.seek(offset, whence) moves the position:
whence | Meaning |
|---|---|
0 (default) | Absolute position from the start of the file. |
1 | Relative to the current position. |
2 | Relative to the end of the file. |
with open("file.bin", "rb") as f: f.seek(0, 2) # seek to end size = f.tell() # get file size f.seek(0, 0) # seek back to start data = f.read(size)In text mode, seeking is restricted. You can only seek to positions returned by f.tell() (or to Position 0). Seeking to arbitrary positions is not allowed because the text wrapper cannot Efficiently determine character boundaries at arbitrary byte offsets. In binary mode, you can seek To any byte offset.
The Read Pointer
Section titled “The Read Pointer”The file position (read pointer, write pointer, or seek pointer — it is a single pointer shared Between read and write operations in + mode) determines where the next read or write operation Will occur. Reads advance the pointer by the number of bytes/characters read. Writes advance it by The number of bytes/characters written. Seeks set it explicitly.
In r+ mode, the initial position is at the start of the file. In a and a+ mode, the initial Position for writing is at the end of the file, but the initial position for reading is at the Start. After a write in a+ mode, a seek is required before reading (because the write position is At the end, but you may want to read from the beginning).
Writing Files
Section titled “Writing Files”f.write()
Section titled “f.write()”Writes a string (text mode) or bytes (binary mode) to the file. Returns the number of characters (text) or bytes (binary) written:
with open("output.txt", "w", encoding="utf-8") as f: n = f.write("Hello, world!\n") print(n) # 14The return value may be less than the length of the input if the write was interrupted (e.g., by a Signal) or if the underlying system call performed a partial write. However, in practice on modern Operating systems with buffered I/O, the return value is almost always equal to the input length. If You need guaranteed complete writes, use os.write() on the raw file descriptor, or check the Return value and retry.
f.writelines()
Section titled “f.writelines()”Writes a list of strings (text mode) or bytes-like objects (binary mode):
lines = ["first line\n", "second line\n", "third line\n"]with open("output.txt", "w", encoding="utf-8") as f: f.writelines(lines)Despite the name, writelines() does not add newlines between items. Each item must include its Own newline if desired. The name is misleading — it writes all items from the iterable, not Individual lines. The primary advantage over a loop calling f.write() is that writelines() can Be more efficient because it avoids the overhead of repeated Python-level method calls.
Flush Behavior
Section titled “Flush Behavior”When you write to a buffered file, the data goes into an in-memory buffer, not directly to disk. The Buffer is flushed to the OS (via a write() system call) when:
- The buffer is full.
f.flush()is called explicitly.- The file is closed (
f.close()). - The program exits normally (the interpreter flushes all open files during shutdown).
- In line-buffered mode (
buffering=1), when a newline is written.
Note the distinction between “flushed to the OS” and “written to disk.” The OS maintains its own Page cache. Data flushed from Python’s buffer goes into the OS page cache, which may not be written To the physical disk for seconds or minutes. To ensure data is physically on disk (durability Guarantee), you must call os.fsync(f.fileno()) after f.flush():
with open("critical.dat", "wb") as f: f.write(data) f.flush() # Python buffer -> OS page cache os.fsync(f.fileno()) # OS page cache -> physical diskos.fsync() is expensive because it forces a physical disk write (or at least an acknowledgment From the disk’s write cache). Use it only when you need durability guarantees (database write-ahead Logs, financial transaction records, etc.).
Write Buffering Layers
Section titled “Write Buffering Layers”Python’s I/O stack has two levels of buffering:
Python-level buffering (controlled by the
bufferingparameter toopen()). This is theio.BufferedWriterorio.BufferedReaderlayer. It sits between your Python code and the OS.OS-level buffering (the page cache). This is managed by the kernel and is transparent to Python. The OS aggregates small writes into larger disk operations for performance.
When you call f.write("data"):
- The
io.TextIOWrapperencodes the string to bytes. - The
io.BufferedWriterappends the bytes to its internal buffer. - If the buffer exceeds its size limit, it calls
os.write(fd, buffer_data)to flush to the OS. - The OS writes the data to its page cache (not necessarily to disk).
- The OS may flush the page cache to disk at any time (periodically, under memory pressure, or when
fsync()is called).
Understanding this two-level buffering is critical for:
Data loss scenarios. If the process crashes after
f.write()but beforef.flush()The data is in Python’s buffer and is lost. If the process crashes afterf.flush()but before the OS writes to disk, the data is in the OS page cache and is also lost (unless the disk has a battery-backed write cache).Performance tuning. Larger Python buffers reduce the number of system calls. But larger buffers also mean more data at risk of loss if the process crashes.
pathlib Integration
Section titled “pathlib Integration”pathlib.Path provides a higher-level, object-oriented interface for file I/O. Every Path method That performs I/O has a corresponding open()-based implementation under the hood.
Path.open()
Section titled “Path.open()”Equivalent to the built-in open()But the path is implicit:
from pathlib import Path
p = Path("file.txt")with p.open("r", encoding="utf-8") as f: content = f.read()This is syntactic sugar. The underlying call is open(self, mode, buffering, encoding, errors, newline). All the same parameters and caveats apply.
Path.read_text() and Path.write_text()
Section titled “Path.read_text() and Path.write_text()”Convenience methods for reading and writing entire text files:
from pathlib import Path
p = Path("file.txt")content = p.read_text(encoding="utf-8")p.write_text("new content", encoding="utf-8")These are equivalent to:
with p.open("r", encoding="utf-8") as f: content = f.read()
with p.open("w", encoding="utf-8") as f: f.write("new content")The advantage is conciseness. The disadvantage is that read_text() loads the entire file into Memory, which is unsafe for large files. Use p.open() with iteration for large files.
Path.read_bytes() and Path.write_bytes()
Section titled “Path.read_bytes() and Path.write_bytes()”Binary equivalents of the above:
from pathlib import Path
data = Path("image.png").read_bytes()Path("copy.png").write_bytes(data)Path.mkdir()
Section titled “Path.mkdir()”Creates directories:
from pathlib import Path
Path("mydir").mkdir()Path("mydir/subdir").mkdir(parents=True, exist_ok=True)parents=True creates all intermediate directories (like mkdir -p). exist_ok=True suppresses FileExistsError if the directory already exists (but raises if the path exists as a file, not a Directory). Without exist_ok=True``mkdir() raises FileExistsError if the directory exists.
Path.exists()``Path.is_file()``Path.is_dir()
Section titled “Path.exists()``Path.is_file()``Path.is_dir()”from pathlib import Path
p = Path("file.txt")print(p.exists()) # True if the path exists (file, directory, symlink)print(p.is_file()) # True if it's a regular file (follows symlinks)print(p.is_dir()) # True if it's a directory (follows symlinks)Be aware of TOCTOU (Time Of Check, Time Of Use) races. Between calling p.exists() and opening the File, another process may delete or rename it. In concurrent environments, open the file and handle The FileNotFoundError rather than checking existence first.
Path.glob() and Path.rglob()
Section titled “Path.glob() and Path.rglob()”Pattern matching for finding files:
from pathlib import Path
# All .py files in the current directory (non-recursive)for p in Path(".").glob("*.py"): print(p)
# All .py files recursivelyfor p in Path(".").rglob("*.py"): print(p)
# Multiple patternsfor p in Path(".").glob("*.py"): passfor p in Path(".").glob("**/*.json"): passglob() uses Unix shell-style wildcards: * matches any sequence of characters (excluding /), ? matches any single character, [] matches any character in the set. ** matches zero or more Directories (only in rglob() or when used explicitly as **/pattern).
Path.glob() returns a generator, not a list. This means it does not build the entire result set in Memory — it yields matches one at a time as it traverses the directory tree. For directories with Millions of files, this is significantly more memory-efficient than glob.glob() (which returns a List).
io Module
Section titled “io Module”The io module provides Python’s I/O stack as a set of composable classes. Understanding this stack Is essential for advanced I/O operations, custom stream implementations, and debugging I/O issues.
The I/O Stack
Section titled “The I/O Stack”Application code (str/bytes) | v io.TextIOWrapper (encoding/decoding, newline translation) | v io.BufferedReader (read buffering) io.BufferedWriter (write buffering) io.BufferedRandom (read+write buffering) | v io.RawIOBase (unbuffered, OS-level I/O) io.FileIO (concrete implementation: file descriptor)Each layer wraps the layer below it. The open() built-in assembles this stack automatically. You Can also construct it manually for fine-grained control.
io.StringIO
Section titled “io.StringIO”An in-memory text stream. It implements the full io.TextIOBase interface but reads/writes to a str buffer instead of a file:
import io
buf = io.StringIO()buf.write("hello\n")buf.write("world\n")buf.seek(0)print(buf.read()) # "hello\nworld\n"Use cases:
- Capturing output that would normally go to a file, for testing.
- Building strings incrementally when concatenation would be O(n^2) (though
io.StringIOis not necessarily faster than list-join for all cases). - Providing a file-like interface to string data.
io.StringIO only works with str. If you need to work with bytesUse io.BytesIO.
io.BytesIO
Section titled “io.BytesIO”An in-memory binary stream:
import io
buf = io.BytesIO()buf.write(b"\x00\x01\x02\x03")buf.seek(0)data = buf.read()print(data) # b'\x00\x01\x02\x03'Use cases:
- Testing code that expects a file-like object without writing to disk.
- Building binary payloads for network protocols.
- Processing binary data in memory.
io.BytesIO supports the buffer protocol, so you can pass its buffer to functions expecting bytes-like objects:
buf = io.BytesIO(b"hello")view = buf.getbuffer() # memoryviewio.BufferedReader and io.BufferedWriter
Section titled “io.BufferedReader and io.BufferedWriter”Buffered wrappers around raw I/O objects. You rarely construct these directly (the open() built-in Does it for you), but you may encounter them when working with sockets, pipes, or custom raw I/O:
import io
raw = open("file.bin", "rb", buffering=0)buffered = io.BufferedReader(raw, buffer_size=65536)data = buffered.read(1024)BufferedReader provides read()``read1() (unbuffered read of up to n bytes), readinto() peek() (read without advancing the position), and readline().
BufferedWriter provides write()``flush()And internal buffer management.
io.TextIOWrapper
Section titled “io.TextIOWrapper”The text layer that sits on top of a buffered binary stream. It handles encoding, decoding, and Newline translation:
import io
raw = open("file.txt", "rb", buffering=0)buffered = io.BufferedReader(raw)text = io.TextIOWrapper(buffered, encoding="utf-8", newline="\n")content = text.read()When you call open("file.txt", "r", encoding="utf-8")The returned object is an instance of io.TextIOWrapper that wraps a io.BufferedReader that wraps a io.FileIO.
You can detach the underlying binary stream from a TextIOWrapper using f.detach(). This removes The text wrapper and returns the raw buffered stream. After detaching, the TextIOWrapper is Unusable. This is useful when you need to switch between text and binary mode on the same underlying Stream (which is not otherwise possible with a single open() call).
Temporary Files
Section titled “Temporary Files”tempfile.NamedTemporaryFile
Section titled “tempfile.NamedTemporaryFile”Creates a temporary file with a visible name in the filesystem:
import tempfile
with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", suffix=".txt", prefix="myapp_", dir="/tmp", delete=True,) as tmp: tmp.write("temporary data") print(tmp.name) # e.g., /tmp/myapp_abc123.txt
# File is deleted when the context manager exits (if delete=True)Key parameters:
suffix: File extension. Useful for tools that inspect the extension.prefix: Filename prefix for identification.dir: Directory where the file is created. Defaults to the platform’s temp directory.delete: IfTrue(default), the file is deleted when closed. IfFalseThe file persists.
Critical limitation on Windows: By default, NamedTemporaryFile opens the file with an Exclusive lock that prevents other processes from opening it. This means you cannot pass the Filename to another process for reading while the file is still open. The workaround is to close the File first (setting delete=False), pass the name, then delete manually:
import tempfileimport os
fd, path = tempfile.mkstemp(suffix=".txt")try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write("data") # File is now closed and can be read by other processes other_process(path)finally: os.unlink(path)tempfile.TemporaryDirectory
Section titled “tempfile.TemporaryDirectory”Creates a temporary directory that is cleaned up when the context manager exits:
import tempfile
with tempfile.TemporaryDirectory(prefix="myapp_") as tmpdir: print(tmpdir) # e.g., /tmp/myapp_abc123 Path(tmpdir, "file.txt").write_text("data")# Directory and all contents are deleted hereOn Unix, the directory is deleted with shutil.rmtree(). On Windows, this can fail if another Process has files open within the directory (the onerror callback can be used for retry logic).
tempfile.mkdtemp()
Section titled “tempfile.mkdtemp()”Creates a temporary directory without automatic cleanup. You are responsible for deleting it:
import tempfileimport shutil
tmpdir = tempfile.mkdtemp()try: # use tmpdir passfinally: shutil.rmtree(tmpdir, ignore_errors=True)Security Considerations
Section titled “Security Considerations”tempfile uses os.urandom() to generate unpredictable filenames, preventing race conditions where An attacker pre-creates a file with a predictable name (symlink attacks). The default permissions Are 0o600 (owner read/write only). Never construct temporary filenames manually (e.g., f"/tmp/myapp_{os.getpid()}.dat") — the predictability makes symlink attacks trivial.
On shared systems (web servers, CI runners), the temp directory (/tmp on Unix) may be World-readable. Use tempfile.mkstemp() or NamedTemporaryFile with dir pointing to a directory With restricted permissions.
Directory Traversal
Section titled “Directory Traversal”os.walk()
Section titled “os.walk()”Recursively walks a directory tree, yielding (dirpath, dirnames, filenames) tuples for each Directory:
import os
for dirpath, dirnames, filenames in os.walk("/my/project"): for filename in filenames: if filename.endswith(".py"): filepath = os.path.join(dirpath, filename) print(filepath)Key behaviors:
os.walk()is top-down by default. Thedirnameslist can be modified in-place to control which subdirectories are visited:
for dirpath, dirnames, filenames in os.walk("/my/project"): dirnames[:] = [d for d in dirnames if not d.startswith(".")] dirnames[:] = [d for d in dirnames if d != "__pycache__"] # This prunes .git, __pycache__, node_modules, etc.- Setting
topdown=Falsechanges to bottom-up traversal. In bottom-up mode, you cannot prune directories (because they have already been visited), but you can safely delete directories after processing their contents:
for dirpath, dirnames, filenames in os.walk("/my/cleanup", topdown=False): for filename in filenames: os.unlink(os.path.join(dirpath, filename)) if not os.listdir(dirpath): os.rmdir(dirpath)os.walk()usesos.scandir()internally (since Python 3.5), which is significantly faster than the olderos.listdir()approach becausescandir()returns entries with cachedstatinformation, avoiding extra system calls.
pathlib.rglob()
Section titled “pathlib.rglob()”Recursive globbing with Path objects:
from pathlib import Path
for p in Path("/my/project").rglob("*.py"): if p.is_file(): print(p)rglob("**/*.py") and rglob("*.py") are equivalent — rglob() automatically adds **/ if the Pattern does not start with **. The difference from os.walk() is that rglob() returns matching Paths only (not directories without matches), and it is lazy (returns a generator).
os.scandir() for Performance
Section titled “os.scandir() for Performance”os.scandir() is a lower-level directory iteration that returns os.DirEntry objects. Each DirEntry caches the stat result from the readdir system call, avoiding an extra stat() call When you check entry.is_file() or entry.is_dir():
import os
with os.scandir("/my/project") as entries: for entry in entries: if entry.is_file() and entry.name.endswith(".py"): print(entry.path, entry.stat().st_size)Benchmark: os.scandir() is 2-3x faster than os.listdir() + os.path.isfile() on Linux With ext4, because it avoids one stat() system call per entry. On network filesystems (NFS, SMB), The speedup can be 10x or more because each stat() is a round-trip to the server.
Filtering by Extension
Section titled “Filtering by Extension”import os
target_extensions = {".py", ".pyx", ".pyi"}
with os.scandir("/my/project") as entries: for entry in entries: if entry.is_file(): ext = os.path.splitext(entry.name)[1] if ext in target_extensions: process(entry.path)Using a set for target_extensions gives O(1) membership testing, which matters when scanning Directories with thousands of files.
Recursive Operations
Section titled “Recursive Operations”For complex recursive operations (copy, move, delete), use shutil rather than manual traversal:
import shutil
shutil.copytree("src/", "dst/") # recursive copyshutil.rmtree("old_dir/") # recursive deleteshutil.move("old_location/", "new_location/") # recursive moveshutil.rmtree() is the standard way to delete a directory tree. It handles read-only files on Windows (via the onerror/onexc callback) and symbolic links correctly. Manual implementations Using os.walk() and os.unlink() are error-prone and unnecessary.
Memory-Mapped Files
Section titled “Memory-Mapped Files”mmap.mmap()
Section titled “mmap.mmap()”Memory-mapped files map a file’s contents directly into the process’s virtual address space. Reads And writes to the mapped region operate on the file data without explicit read() or write() Calls. The OS handles paging — portions of the file are loaded into physical memory on demand and Written back when the OS decides to flush.
import mmap
with open("large_file.bin", "r+b") as f: with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm: # 'mm' behaves like a mutable bytes object (read-only in this case) print(mm[:100]) # first 100 bytes print(mm.find(b"pattern")) # search for byte sequence print(len(mm)) # file size
# Alternative: read-writewith open("large_file.bin", "r+b") as f: with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_WRITE) as mm: mm[100:104] = b"\xde\xad\xbe\xef" # write bytes directly to fileUse Cases
Section titled “Use Cases”Large file processing. When working with files larger than available RAM,
mmapallows you to access any part of the file without loading it entirely into memory. The OS pages in only the portions you access. This is the standard approach for processing multi-gigabyte files (log analysis, genome data, satellite imagery).Random access.
mmapprovides O(1) access to any byte offset, regardless of the file’s position pointer. You can read from offset 0, then offset 1000000, then offset 500, without any seek operations. Each access is a direct memory load.Inter-process communication. Multiple processes can map the same file and communicate by reading/writing the shared memory region. This is faster than pipes or sockets for large data transfers because no data is copied between kernel and user space.
Binary format parsing. Structured binary formats (ELF executables, database pages, bitmap headers) benefit from random access to specific offsets.
mmapcombined withstruct.unpack_from()allows zero-copy parsing.
Limitations
Section titled “Limitations”File size limit. On 32-bit systems,
mmapis limited to the virtual address space size ( 2-3GB). On 64-bit systems, the limit is effectively the file system’s maximum file size.No resizing. The mapped region has a fixed size determined at creation time. If the file grows after mapping, the new data is not visible in the mapped region (unless you remap).
Platform differences. Windows
mmapbehavior differs from Unix in several ways:
- Windows requires
mmap.ACCESS_COPYfor writable mappings of files opened in read-only mode. - Unix supports
MAP_SHARED(writes visible to other processes) andMAP_PRIVATE(writes are copy-on-write, not visible to others). Windows hasACCESS_WRITE(shared) andACCESS_COPY(private).
Concurrency. For read-write shared mappings, you need external synchronization (mutexes, semaphores, file locks) to prevent data races between processes.
mmapitself provides no synchronization.Cannot use with text mode.
mmapoperates on raw bytes. If you need text processing, you must decode the bytes yourself.io.TextIOWrappercannot wrap anmmapobject.
Common Pitfalls
Section titled “Common Pitfalls”Not Specifying Encoding
Section titled “Not Specifying Encoding”This is the most common file I/O bug in Python 3. The default encoding is platform-dependent, which Means code that works on Linux (UTF-8 default) may produce UnicodeEncodeError or UnicodeDecodeError on Windows (cp1252 default), or silently produce mojibake (garbled text) if the File is encoded in a different encoding than the platform default.
# WRONG -- platform-dependent encodingwith open("file.txt", "w") as f: f.write("café")
# CORRECT -- explicit UTF-8with open("file.txt", "w", encoding="utf-8") as f: f.write("café")On Windows, the default encoding is cp1252Which cannot encode characters outside the Latin-1 Range (e.g., CJK characters, emoji, many mathematical symbols). Writing such characters with the Default encoding raises UnicodeEncodeError. Always specify encoding="utf-8".
Windows Newline Issues
Section titled “Windows Newline Issues”On Windows, writing \n in text mode (default newline=None) produces \r\n in the file. Reading A file with \r\n line endings in text mode translates them to \n. This is transparent, But it causes problems when:
File size mismatch.
f.write("hello\n")writes 6 characters but 7 bytes on Windows. The return value off.write()is the number of characters (6), not bytes. If you are tracking byte positions for binary protocols embedded in text, the count is wrong.Binary format corruption. If you open a file that contains binary data (even if it is mostly text) in text mode, the newline translation will corrupt the data. Always use binary mode for files that contain any non-text content.
Line ending consistency. If files are edited on both Windows and Unix, the line endings may be inconsistent. Git’s
core.autocrlfsetting can convert line endings, but this causes other problems (diff noise, patch failures). The safest approach is to standardize on LF (\n) in the repository and configure editors to use LF.
# Force LF on all platformswith open("file.txt", "w", encoding="utf-8", newline="") as f: f.write("line1\nline2\n")Not Closing Files
Section titled “Not Closing Files”Every open file consumes a file descriptor. The OS limits the number of file descriptors per process ( 1024 soft limit, 65536 hard limit on Linux; configurable with ulimit -n). When the Limit is reached, new open() calls raise OSError: [Errno 24] Too many open files.
The garbage collector will eventually close files, but the timing is nondeterministic. In CPython, Reference counting closes files immediately when the last reference is dropped (in most cases), but In other implementations (PyPy, with its garbage collector), the delay can be significant. In all Implementations, circular references prevent immediate collection.
# WRONG -- file is not closed if an exception occursf = open("file.txt", "r", encoding="utf-8")data = f.read()# If an exception occurs here, f.close() is never calledf.close()
# CORRECT -- context manager guarantees cleanupwith open("file.txt", "r", encoding="utf-8") as f: data = f.read()Reading Large Files into Memory
Section titled “Reading Large Files into Memory”f.read() and f.readlines() load the entire file into memory. For a 10GB file, this requires 10GB Of RAM (plus Python object overhead, which roughly doubles the memory usage for text mode due to the Str object structure). This triggers MemoryError or aggressive swapping.
# WRONG -- loads entire file into memorywith open("large.csv", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: process(line)
# CORRECT -- iterates line by line, bounded memorywith open("large.csv", "r", encoding="utf-8") as f: for line in f: process(line)The iteration pattern processes one line at a time. Memory usage is bounded by the size of the Internal read buffer ( 8KB) plus the length of the longest line. Even for files with very Long lines (e.g., single-line JSON files), you can use f.read(chunk_size) to read in fixed-size Chunks.
Binary Mode Gotchas
Section titled “Binary Mode Gotchas”Writing strings to binary files.
f.write("hello")raisesTypeError: a bytes-like object is required, not 'str'in binary mode. Encode first:f.write("hello".encode("utf-8")).Comparing bytes with strings.
b"hello" == "hello"isFalsein Python 3. This is a common source of bugs when mixing text and binary mode in the same codebase.Indexing bytes.
b"hello"[0]returns104(the integer value of'h'), notb'h'. To get a single-byte bytes object, use slicing:b"hello"[0:1]returnsb'h'.Line splitting on bytes.
b"hello\nworld".split(b"\n")returns[b"hello", b"world"]. Butb"hello\r\nworld".split(b"\n")returns[b"hello\r", b"world"]— the\ris not stripped. Binary mode does not perform newline translation.Seeking in text mode.
f.seek(n)wherenis not a position returned byf.tell()raisesOSErroror produces undefined behavior in text mode. This is because theTextIOWrappercannot determine character boundaries at arbitrary byte offsets. Use binary mode if you need arbitrary seeking.
Forgetting That write() Returns a Count
Section titled “Forgetting That write() Returns a Count”f.write() returns the number of characters or bytes written, not the string itself. This is not a Bug, but it trips up beginners who expect the return value to be the written data (as in some other Languages):
n = f.write("hello")print(n) # 5, not "hello"Mixing Read and Write in + Mode
Section titled “Mixing Read and Write in + Mode”In r+``w+And a+ modes, the file supports both reading and writing. However, the read and Write pointers are shared. After writing, you must seek or flush before reading. After reading, you Must seek before writing (because the read position may be in the middle of the file, and writing at That position would overwrite data without moving the write pointer to the end).
with open("file.txt", "r+", encoding="utf-8") as f: content = f.read() f.seek(0) f.write(content.upper())Without the f.seek(0)The write would occur at the end of the file (where the read pointer Stopped), not at the beginning.
Using pathlib Methods Without Error Handling
Section titled “Using pathlib Methods Without Error Handling”Path.read_text() raises FileNotFoundError if the file does not exist, PermissionError if Access is denied, and UnicodeDecodeError if the file content is not valid in the specified Encoding. These are the same errors as open()But the one-liner syntax makes it tempting to skip Error handling:
# This will crash if the file doesn't exist or has wrong encodingcontent = Path("config.json").read_text(encoding="utf-8")
# Better: handle errors explicitlytry: content = Path("config.json").read_text(encoding="utf-8")except FileNotFoundError: content = '{"default": true}'except UnicodeDecodeError as e: raise RuntimeError(f"Invalid encoding in config.json: {e}")Assuming Path.glob() Returns All Files
Section titled “Assuming Path.glob() Returns All Files”Path.glob() returns files and directories that match the pattern. If you only want files, you must Filter with .is_file(). Symlinks are followed by default (a symlink to a directory matches is_dir() and is_file() based on the target, not the link itself):
for p in Path(".").glob("*.py"): if p.is_file(): # excludes directories named *.py (unlikely but possible) process(p)Use p.is_file(follow_symlinks=False) if you want to exclude symlinks to files.
Summary
Section titled “Summary”This topic covers the core concepts of file i/o, including underlying theory, practical implementation, and key applications.
Key concepts include:
- core concepts and terminology
- algorithms and computational thinking
- practical implementation
- security and ethical considerations
- applications in the real world
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Intuition
Section titled “Intuition”File I/O is the bridge between your program’s temporary memory and the permanent world of stored data. Reading a file is like borrowing a book from a library: you open it, read what you need, and return it. The with statement ensures you always return the book, even if you get distracted mid-sentence. Text mode and binary mode are like reading a novel versus copying a photograph: one interprets the content, the other preserves it byte-for-byte. Buffered I/O is a librarian who checks out multiple books at once to save you trips to the shelf.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Cross-References
Section titled “Cross-References”- Essential Modules — The io module builds on os.path and built-in functions for comprehensive file system interaction.
- Context Managers — File objects support the context manager protocol for safe resource management with with statements.
- Generators and Iterators — File objects are iterators that yield lines lazily, connecting file I/O to the iterator protocol.