with open(...) uses the file as a context manager, which guarantees the file
is closed when the block exits — even if an exception is raised. Without it you
must remember to call .close() manually, and a crash mid-block leaks the handle.
with open("data.txt") as f: # f.close() is automatic
contents = f.read()
# file is closed here, even on error
f = open("data.txt") # manual style — fragile
try:
contents = f.read()
finally:
f.close()
Leaked handles can exhaust OS file descriptors and leave buffered writes unflushed.
Always prefer with for any resource that needs cleanup (files, locks, sockets).
A file object is its own iterator, yielding one line at a time and holding
only that line in memory. read() loads the entire file into a single string,
and readlines() loads all lines into a list — both can blow up memory on large
files.
with open("huge.log") as f:
for line in f: # lazy — one line in memory at a time
process(line)
with open("huge.log") as f:
data = f.read() # whole file as one string
lines = f.readlines() # whole file as a list of strings
Iterating is the idiomatic, memory-safe way to process big files line by line.
Reserve read()/readlines() for small files where you genuinely need the whole
content at once.
Text mode ("r", the default) decodes bytes into str using an encoding and
normalizes newlines. Binary mode ("rb"/"wb") reads and writes raw bytes
with no decoding — required for images, archives, or any non-text data. In text
mode you should pass encoding= explicitly, because the default is
platform-dependent.
with open("notes.txt", "r", encoding="utf-8") as f:
text: str = f.read() # decoded to str
with open("photo.jpg", "rb") as f:
raw: bytes = f.read() # raw bytes, no decoding
Relying on the default encoding is a classic cross-platform bug (UTF-8 on Linux/Mac,
often a legacy codepage on Windows). Always specify encoding="utf-8" for text, and
use binary mode for everything that isn't text.
pathlib.Path is the modern, object-oriented way to handle filesystem paths. A
Path is an object with methods and operators, whereas the older os.path
module is a collection of string-based functions. Path's / operator joins
segments cleanly and works across operating systems.
from pathlib import Path
p = Path("data") / "logs" / "app.log" # join with /
p.exists()
p.suffix # ".log"
p.stem # "app"
p.read_text(encoding="utf-8") # one-liner read
import os.path
old = os.path.join("data", "logs", "app.log")
os.path.exists(old)
pathlib is generally preferred for new code: it's more readable and bundles
common operations (read_text, mkdir, glob) as methods. Use os.path mainly
when working with existing string-based APIs.
Use Path.glob(pattern) for matches in one directory and Path.rglob(pattern)
(or glob("**/...")) to recurse into subdirectories. Both return a lazy generator
of Path objects, where * matches any characters and ** matches directories
recursively.
from pathlib import Path
root = Path("project")
for py in root.glob("*.py"): # top level only
print(py.name)
for py in root.rglob("*.py"): # all subdirectories too
print(py)
root.mkdir(parents=True, exist_ok=True) # create dirs safely
[p.name for p in root.iterdir()] # list directory contents
Other handy Path methods: iterdir() (list a directory), is_file()/is_dir(),
mkdir(), unlink() (delete), and with_suffix(). Globbing returns generators, so
wrap in list(...) if you need a concrete collection.
r read (default, must exist), w write (truncates/creates), a append
(writes at end), x exclusive create (fails if exists), and + adds the
other capability (read+write). Add b for binary.
open("f", "r") # read existing
open("f", "w") # overwrite/create — WIPES existing content!
open("f", "a") # append to end, create if missing
open("f", "x") # create new, FileExistsError if it exists
open("f", "r+") # read and write, must exist
Rule of thumb: w destroys existing content — use a to append or x to avoid
clobbering; combine with b ("rb", "wb") for binary data.
A Path exposes its components as properties: .name (file + ext), .stem
(name without ext), .suffix (extension), .parent (containing dir), and
.parts (tuple of all segments).
from pathlib import Path
p = Path("/home/user/report.tar.gz")
p.name # 'report.tar.gz'
p.stem # 'report.tar'
p.suffix # '.gz'
p.suffixes # ['.tar', '.gz']
p.parent # Path('/home/user')
p.parts # ('/', 'home', 'user', 'report.tar.gz')
Rule of thumb: use these properties instead of string splitting — .with_suffix()/
.with_name() build modified paths safely across platforms.
Path.resolve() returns an absolute, symlink-resolved path; .absolute() makes
it absolute without resolving symlinks; .relative_to(base) computes a relative
path. Path.cwd() and Path.home() give common roots.
from pathlib import Path
p = Path("data/file.txt")
p.resolve() # /full/abs/path/data/file.txt
Path.cwd() # current working directory
Path.home() # user home
abs_p = Path("/a/b/c.txt")
abs_p.relative_to("/a") # Path('b/c.txt')
Rule of thumb: resolve() to normalize to a canonical absolute path; relative_to
to express one path relative to a known base (raises if not a subpath).
Path.read_text()/write_text() and read_bytes()/write_bytes() are one-line
open-read/write-close helpers — no with block needed for simple whole-file I/O.
Pass encoding= for text.
from pathlib import Path
p = Path("notes.txt")
p.write_text("hello", encoding="utf-8") # opens, writes, closes
content = p.read_text(encoding="utf-8")
Path("img.bin").write_bytes(b"\x00\x01")
data = Path("img.bin").read_bytes()
Rule of thumb: use read_text/write_text for quick whole-file access; switch to
with open(...) when you need streaming, appending, or line-by-line processing.
Many os functions have Path method equivalents: os.mkdir→p.mkdir(),
os.remove→p.unlink(), os.rename→p.rename(), os.listdir→p.iterdir(),
os.path.exists→p.exists(). Directory trees still use shutil.
from pathlib import Path
import shutil
p = Path("out")
p.mkdir(parents=True, exist_ok=True)
(p / "a.txt").touch() # create empty file
(p / "a.txt").unlink() # delete a file
shutil.rmtree(p) # remove a whole directory tree
shutil.copy("src.txt", "dst.txt") # copy a file
Rule of thumb: prefer Path methods for single files/dirs; use shutil for
recursive copy/move/delete of trees (pathlib has no rmtree).
Use the tempfile module: TemporaryDirectory and NamedTemporaryFile are
context managers that auto-clean up on exit, and they create files securely
(avoiding race conditions of hand-rolled temp names).
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("scratch")
# directory and contents removed automatically here
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=True) as f:
f.write("a,b\n")
Rule of thumb: never build temp paths by hand — tempfile gives secure, auto-cleaned
temporary files/dirs via context managers.
tell() returns the current byte position; seek(offset, whence) moves it.
whence is 0=start (default), 1=current, 2=end. Useful for re-reading, skipping
headers, or random access in binary files.
with open("data.bin", "rb") as f:
f.read(4) # read header
pos = f.tell() # current position (4)
f.seek(0) # back to start
f.seek(-10, 2) # 10 bytes before end
Rule of thumb: use seek/tell for random access (mostly binary mode); in text mode
only seek to positions returned by tell() (byte offsets aren't char counts).
* matches any run of characters within one path segment; ** matches across
directories (recursive); ? matches a single character; [...] matches a character
set. ** needs rglob or glob("**/...").
from pathlib import Path
root = Path("src")
list(root.glob("*.py")) # .py in src only
list(root.glob("**/*.py")) # .py at any depth
list(root.glob("test_?.py")) # test_1.py, test_a.py
list(root.glob("[abc]*.txt")) # starts with a, b, or c
Rule of thumb: * within a directory, ** for recursion, ?/[...] for
single-char and set matches — same patterns the shell uses.
File writes are buffered — data sits in memory until the buffer fills, you call
flush(), or the file is closed. A crash before flush/close loses buffered
data. with guarantees a close (and flush).
f = open("log.txt", "w")
f.write("important") # may still be in the buffer, not on disk
f.flush() # force it to the OS now
import os
os.fsync(f.fileno()) # force OS to write to physical disk
f.close() # flushes and closes
Rule of thumb: rely on with (or close()) to flush; call flush()/os.fsync()
explicitly only when you need durability before the block ends.
Pass an errors= strategy to open/read_text: "strict" (default, raises),
"ignore" (drop bad bytes), "replace" (insert �), or "backslashreplace". Useful
for messy real-world data with unknown encodings.
open("messy.txt", encoding="utf-8", errors="replace").read() # bad bytes ->
open("messy.txt", encoding="utf-8", errors="ignore").read() # drop bad bytes
from pathlib import Path
Path("messy.txt").read_text(encoding="utf-8", errors="replace")
Rule of thumb: keep errors="strict" to catch encoding problems; use replace/
ignore only when you must tolerate malformed input and can accept data loss.
More Standard Library Essentials interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.