Python Lesson 10 of 71

File I/O & Basic Error Handling: Text vs Binary, Context Managers, try/except

Every program you have written so far has been an amnesiac. It starts, it thinks, it prints, it exits — and everything it knew evaporates. Files are how that stops. A file is the first thing you will meet that outlives your process, and that single fact is why file I/O is where beginners get hurt: a variable that is wrong just prints wrong, but a file that is wrong is still wrong tomorrow. One misread character in a mode string can delete a log you needed.

This lesson is about six things, and they are all connected: open() and the mode that decides your fate; with, the block that guarantees your file is closed even when your code explodes; the difference between text and binary (which is really the story of str versus bytes); pathlib, which makes paths stop being strings you glue together and hope; and try/except, which turns “my script crashed” into “my script handled it.”

Type every snippet. Not because typing is magic, but because file I/O is the first topic where the machine pushes back — the file isn’t there, the encoding is wrong, the directory doesn’t exist — and you learn far more from a real FileNotFoundError on your own screen than from reading about one. Everything here targets Python 3.12+, needs no pip install, and runs identically on macOS, Linux, and Windows unless we say otherwise.


Why this matters

Almost every useful program is a pipe with a file on at least one end. You read a config file to know what to do. You read a CSV to analyse it. You write a log so that future-you can find out what happened at 03:00. You save a model, a report, an image, a database dump. “Reading input, doing something, writing output” is the job — the interesting logic in the middle is sandwiched between two file operations.

Here is the mental model to carry through the whole lesson, because it prevents most of the bugs. open() does not give you a file. It gives you a file object — a small machine in your program’s memory that knows how to talk to a file on disk. Those are different things, and every confusing behaviour in this lesson comes from that gap:

So the three questions to ask about every file operation are: Am I sure it’s closed? Am I sure of the encoding? Am I sure of the mode? Answer those three and file I/O is easy. Ignore them and you get the classic beginner trio — an empty file, a crash on a colleague’s laptop, and a log missing its last ten lines.

One more reason this lesson matters: it is where you will first meet exceptions in earnest. Not as a punishment, but as an interface. The filesystem is the outside world, and the outside world says no — the file is missing, the disk is full, you lack permission. try/except is how Python hands you that “no” in a form you can actually do something about.


open(): one function, and the mode that decides everything

Everything starts with one built-in. Here is its real signature in Python 3.12:

open(file, mode='r', buffering=-1, encoding=None, errors=None,
     newline=None, closefd=True, opener=None)

In practice you will use the first four, and mostly the first three:

# The shape you should burn into muscle memory:
with open("app.log", "r", encoding="utf-8") as f:
    ...

The mode is a tiny string, and it is the single most consequential argument in this lesson. It answers two questions at once: what am I allowed to do? and is this text or bytes? Read this table slowly — the w row is the one that costs people real data:

Mode Name File must exist? If it exists Position at open Can read? Can write?
r read (default) Yes → else FileNotFoundError untouched start
w write No — created ⚠️ truncated to 0 bytes start
a append No — created kept; writes forced to end end
x exclusive create Must not exist → else FileExistsError n/a start
r+ read + update Yes kept, not truncated start
w+ write + read No — created ⚠️ truncated to 0 bytes start
a+ append + read No — created kept; writes forced to end end
b binary (modifier) gives you bytes takes bytes
t text (modifier, default) gives you str takes str

Two rules make the table smaller than it looks. First, b and t are modifiers you combine with a letter: "rb" is read-binary, "wb" is write-binary, "r" means "rt". Second, + means “add the other capability” — it never means “be safe.”

⚠️ The w trap, stated plainly. Mode w truncates the file at open() time, before you write a single character. It does not wait to see whether you actually write anything. It does not care whether your program crashes on the next line. Watch:

from pathlib import Path

print("before open :", Path("app.log").stat().st_size, "bytes")
f = open("app.log", "w", encoding="utf-8")   # <-- the data dies HERE
print("after open  :", Path("app.log").stat().st_size, "bytes")
f.close()
before open : 226 bytes
after open  : 0 bytes

Not one write() call was made, and a 226-byte file is already gone. This is why open(path, "w") on a file you meant to add to is one of the most expensive typos in Python. If you mean “add”, the mode is a. If you mean “create, but never clobber”, the mode is x — it raises FileExistsError rather than destroy something.

So pick the mode from the intent, not from habit:

I want to… Mode Why that one
Read a config/CSV/log "r" The default; fails loudly if it’s missing, which is what you want
Create a report, replacing any old one "w" Truncation is intended here
Add a line to a log "a" Never loses history; each write goes to the end
Create a file only if it’s new "x" Refuses to overwrite — FileExistsError is a feature
Edit part of a file in place "r+" Opens without truncating; use .seek() to position
Read a PNG/ZIP/model file "rb" Binary — no decoding, no newline translation
Write raw bytes "wb" Binary — you supply bytes, not str

The remaining parameters matter less often, but two of them matter a lot when they matter:

Parameter Default What it does When you care
encoding None → platform default Which codec maps bytes ↔ text Always pass "utf-8" in text mode. See the trap below
errors "strict" What to do when a byte won’t decode Salvaging dirty data: "replace", "ignore"
newline None Newline translation on read/write CSV, \r\n files, byte-exact output
buffering -1 (~8192 bytes) Size of the in-memory buffer 1 = line-buffered (text only); 0 = unbuffered (binary only — text raises ValueError: can't have unbuffered text I/O)
closefd True Close the underlying descriptor on close Only when passing an existing fd instead of a path
opener None Custom low-level open callable Rare — advanced permission/flag control

Why with is non-negotiable

You can open a file without with. You should not, and here is the demonstration that settles it. Both blocks below hit an exception; only one of them closes the file:

# 1: manual close — the close() NEVER runs
f = open("app.log", "r", encoding="utf-8")
try:
    raise ValueError("boom in the middle of processing")
    f.close()                      # unreachable — the raise jumped over it
except ValueError:
    pass
print("manual  -> closed?", f.closed)

# 2: with — closed no matter what
try:
    with open("app.log", "r", encoding="utf-8") as g:
        raise ValueError("same boom")
except ValueError:
    pass
print("with    -> closed?", g.closed)
manual  -> closed? False
with    -> closed? True

That is the whole argument. with is a context manager: a with block promises that when control leaves it — normally, via return, via break, or because a line raised an exception — Python calls the object’s cleanup method. For a file, that cleanup is close(), and close() flushes the buffer first. The exception still propagates; it just can’t leave your file dangling on the way out.

with open(...) as f: f = open(...) + f.close()
Closed on success ✅ (if you remembered)
Closed on exception guaranteed ❌ skipped by the raise
Closed on early return/break ❌ commonly missed
Buffer flushed to disk ✅ / ❌
Needs try/finally to be correct No Yes — and that’s just with with extra steps
Lines of code 1 4+

“But my file gets closed anyway” — often true, and it is a trap. CPython uses reference counting, so when the last reference to a file object disappears the file usually does get closed. That is an implementation detail of one interpreter, not a language guarantee (PyPy, for instance, won’t do it promptly). Meanwhile the file stays open for an unpredictable window, which on Windows means nobody else can delete or rename it, and in a loop means you can exhaust your descriptor budget.

Python will even tell you, if you ask. ResourceWarning is silenced by default; development mode turns it on:

# leak.py
def read_it():
    f = open("app.log", "r", encoding="utf-8")   # never closed!
    return f.readline()
print(read_it().strip())
$ python3 leak.py
2026-07-15 09:14:02 INFO  service started          # silent — looks fine

$ python3 -X dev leak.py
leak.py:4: ResourceWarning: unclosed file <_io.TextIOWrapper name='app.log' mode='r' encoding='utf-8'>
  print(read_it().strip())
ResourceWarning: Enable tracemalloc to get the object allocation traceback
2026-07-15 09:14:02 INFO  service started

Get in the habit of running scripts with python3 -X dev while you’re learning. It is a free, permanent code reviewer for exactly this class of bug. And you can open several files in one with:

with (open("in.txt", encoding="utf-8") as src,
      open("out.txt", "w", encoding="utf-8") as dst):
    for line in src:
        dst.write(line.upper())

Reading: four ways, and only one survives a big file

Once you hold a file object, there are four ways to get text out of it. They differ in exactly one property that matters at 3 a.m.: how much of the file lands in RAM at once.

with open("app.log", "r", encoding="utf-8") as f:
    whole = f.read()                   # 1: the WHOLE file into one str
print("read()     ->", type(whole).__name__, len(whole), "chars")

with open("app.log", "r", encoding="utf-8") as f:
    first = f.readline()               # 2: one line, keeps its "\n"
print("readline() ->", repr(first))

with open("app.log", "r", encoding="utf-8") as f:
    for i, line in enumerate(f, 1):    # 3: THE idiom — one line at a time
        print(f"  {i}: {line.rstrip()}")
read()     -> str 226 chars
readline() -> '2026-07-15 09:14:02 INFO  service started\n'
  1: 2026-07-15 09:14:02 INFO  service started
  2: 2026-07-15 09:14:07 WARN  cache miss ratio 0.42
  3: 2026-07-15 09:15:31 ERROR upstream timeout after 30s
  4: 2026-07-15 09:16:03 INFO  retry succeeded
  5: 2026-07-15 09:18:44 ERROR disk usage 91%
Method Returns Memory used At end of file Use it when
f.read() one str (or bytes) ⚠️ the entire file '' The file is small and you need it whole
f.read(n) n chars/bytes n '' Streaming fixed-size chunks; magic numbers
f.readline() one str, keeps \n one line '' Reading a header, then the rest
f.readlines() list[str], each keeps \n ⚠️ whole file + list overhead [] Small file, you genuinely need a list
for line in f: one str per loop one line loop ends The default. Any file, any size

Note the readlines() row carefully: it is read() with extra memory cost, because you pay for the text and a list object per line. Beginners reach for it because the name sounds like “read the lines,” and it is almost always the wrong call. Compare:

# ❌ 10 GB log -> tries to allocate 10 GB of str, plus a huge list. MemoryError.
for line in open("huge.log", encoding="utf-8").readlines():
    ...

# ✅ 10 GB log -> one line in memory at a time. Runs on a laptop.
with open("huge.log", encoding="utf-8") as f:
    for line in f:
        ...

Iterating the file object works because a file is lazy — it reads a block, hands you lines from it, and fetches the next block only when you ask. This is why “iterate the file” is not merely a style preference; it is the difference between a script that scales and one that dies on real data.

Two details that catch everyone. First, every line keeps its trailing \n — that is why the output above uses line.rstrip(). Forget it and your prints come out double-spaced. Second, a file object is a one-shot iterator: once you’ve read to the end, the position sits at EOF and a second loop yields nothing. If you need a second pass, either f.seek(0) or open it again.


Writing: write() returns a count, and nothing adds newlines for you

Writing has two surprises, and they are both about the things Python doesn’t do for you.

from pathlib import Path

with open("demo.txt", "w", encoding="utf-8") as f:
    n = f.write("2026-07-15 09:14:02 INFO  service started\n")
    print("write() returned:", n)      # a CHARACTER count — not "saved to disk"
    f.write("alpha")                   # write() appends NOTHING
    f.write("beta")
    f.writelines(["gamma", "delta"])   # writelines adds nothing either!

print(repr(Path("demo.txt").read_text(encoding="utf-8")))
write() returned: 42
'2026-07-15 09:14:02 INFO  service started\nalphabetagammadelta'

Surprise 1: write() does not add a newline. print() trained you to expect one; write() is the raw tool and appends nothing — which is why alpha, beta, gamma and delta fused into one word. Miss this on a log and your five lines become one 226-character line. Surprise 2: write() returns a count — characters in text mode, bytes in binary mode. That return value is a common beginner-quiz answer, and it carries a subtle lie: it means “accepted into the buffer,” not “safely on disk.”

And writelines() — despite the name — also adds nothing. It is a loop over write(), no more:

Call Adds \n? Takes Gotcha
f.write(s) ❌ never one str (text) / bytes (binary) Returns a count. f.write(42)TypeError: write() argument must be str, not int
f.writelines(seq) ❌ never any iterable of str Name lies. ["a","b"] writes ab. Returns None
print(s, file=f) ✅ yes anything printable The friendly option — converts to str for you
f.flush() Push the buffer to the OS now, without closing
Path.write_text(s) ❌ never one str Opens, writes, closes. Returns chars written

Because writelines adds nothing, the idiomatic way to write a list of lines is to put the newlines in yourself with a generator expression:

more = [
    "2026-07-15 09:16:03 INFO  retry succeeded",
    "2026-07-15 09:18:44 ERROR disk usage 91%",
]

with open("app.log", "a", encoding="utf-8") as f:          # "a" = append!
    f.writelines(line + "\n" for line in more)             # newlines are on YOU

That n = f.write(...) returning “accepted, not durable” is worth one more beat. Writes accumulate in an ~8192-byte buffer and only reach the operating system when the buffer fills, when you call flush(), or when the file is closed. This is the real reason “my log is missing the last few lines” happens: the process was killed before anything flushed. with fixes it by guaranteeing the close. If you need a tail-able log right now, either open(..., buffering=1) for line buffering or call f.flush() after each write.


Text vs binary: the codec in the middle

Here is the truth the t/b modifier hides: a disk stores only bytes. Never characters, never lines — numbers from 0 to 255. So when you ask for text, somebody has to translate, and that somebody is the file object.

That is what text mode is: open() in text mode wraps the raw file in a TextIOWrapper, which runs a codec in both directions — decoding bytesstr on the way in, encoding strbytes on the way out. Binary mode skips that wrapper entirely and hands you the bytes untouched. Here is the whole path, from your with statement down to the platters and back:

Left-to-right diagram of Python file I/O: your code's with-open block and try/except branch, the open() call resolving the path and applying the mode gate, the file object as a TextIOWrapper codec over an 8192-byte buffer, the OS kernel's file descriptor and permission errors, and finally the bytes landing on disk with the with-block guaranteeing flush and close

Follow the arrows: your with open(...) passes a path, a mode and an encoding into open(), which resolves the path against your working directory and applies the mode gate (this is where w truncates, and where x refuses). What comes back is not a file but a stack of layers — a TextIOWrapper doing UTF-8 decode/encode on top of a buffer — and only when that buffer flushes does the OS write real bytes to app.log.

The badges mark the six things that actually bite. with is a contract that closes the file on both the happy path and the exception branch (1), so catch the specific error rather than everything (2). The mode gate is where a stray w destroys 226 bytes before you write anything (3). Text mode is a codec, not a file, so a missing encoding= is a bug waiting for a Windows laptop (4). Nothing is durable until the buffer flushes (5), which is exactly what the guaranteed close() gives you (6).

Now watch the layer disappear. Same file, binary mode:

with open("app.log", "rb") as f:        # "rb" -> bytes, and NO encoding=
    head = f.read(20)                   # the first 20 BYTES

print("type :", type(head).__name__)
print("repr :", head)
print("byte0:", head[0])                # indexing bytes gives an int!

rupee = "billing ₹1,240"                # non-ASCII
print("str  :", len(rupee), "chars |  utf8 :", len(rupee.encode("utf-8")), "bytes")
type : bytes
repr : b'2026-07-15 09:14:02 '
byte0: 50
str  : 14 chars |  utf8 : 16 bytes

Three lessons in one output. Binary gives you a bytes object, not a str. Indexing bytes gives an int (head[0] is 50, the byte value of "2") — a classic surprise. And len(str)len(bytes): is one character but three UTF-8 bytes, which is precisely why you cannot treat “characters” and “bytes” as the same unit.

Text mode ("r", "w", "a") Binary mode ("rb", "wb", "ab")
You get / give str bytes
encoding= Used — always pass it ValueError if you pass it
Newline translation ✅ yes (universal newlines) ❌ none — bytes are literal
Indexing an element a 1-char str an int (0–255)
Underlying object TextIOWrapper BufferedReader / BufferedWriter
Can decode wrong UnicodeDecodeError ❌ impossible — no decoding
Use for .txt, .csv, .json, .py, logs .png, .zip, .pdf, .parquet, models

The encoding trap

encoding=None does not mean “UTF-8”. It means “whatever this machine’s locale says”locale.getencoding(). On Linux and macOS that is almost always UTF-8, so your script works. On Windows it has historically been the ANSI code page, commonly cp1252. Same code, same file, different machine, sudden crash:

# What a latin-1/cp1252 file does to a UTF-8 reader
with open("latin.txt", "r", encoding="utf-8") as f:
    print(f.read())
Traceback (most recent call last):
  File "/home/vinod/python-io-lab/e_unicode.py", line 2, in <module>
    print(f.read())
          ^^^^^^^^
  File "<frozen codecs>", line 322, in decode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte

Read that last line like a sentence: the utf-8 codec found byte 0xe9 at position 3, and in UTF-8 that byte must be followed by continuation bytes — it wasn’t. In latin-1, 0xe9 is simply é. The bytes aren’t corrupt; you brought the wrong decoder.

That is why the rule is absolute: always pass encoding="utf-8" explicitly in text mode. Not because your machine needs it — because the other machine does. (Python is slowly fixing this: PEP 686 makes UTF-8 mode the default in Python 3.15. Until every machine you run on is 3.15+, pass it yourself. You can also audit your code today with python3 -X warn_default_encoding script.py, which flags every open() that forgot.)

Encoding What it is When you’ll meet it
"utf-8" The universal default. Any character; ASCII-compatible Your answer 99% of the time
"utf-8-sig" UTF-8, but strips/writes a leading BOM Excel-exported CSVs — see below
"latin-1" / "cp1252" Single-byte Western European legacy Old Windows exports, old European data
"ascii" 7-bit only; anything ≥ 0x80 raises Validating that data really is plain ASCII
errors="strict" Default — raise on bad bytes Correctness matters (usually!)
errors="replace" Bad bytes → (U+FFFD) Salvaging a dirty file you must read
errors="ignore" Bad bytes silently dropped ⚠️ Last resort — silently loses data
errors="surrogateescape" Round-trips undecodable bytes unchanged Filenames / byte-exact passthrough

Those errors= values are your recovery kit when you can’t control the input:

from pathlib import Path
p = Path("latin.txt")                                # holds b'caf\xe9 ok'
print("latin-1  :", repr(p.read_text(encoding="latin-1")))
print("replace  :", repr(p.read_text(encoding="utf-8", errors="replace")))
print("ignore   :", repr(p.read_text(encoding="utf-8", errors="ignore")))
latin-1  : 'café ok'
replace  : 'caf� ok'
ignore   : 'caf ok'

Only the first is correct — the other two are damage control, and note how ignore quietly ate the é without telling anyone.

The BOM deserves its own warning, because it produces the strangest bug report in data work. A “UTF-8” CSV from Excel often starts with three invisible bytes (\xef\xbb\xbf):

Path("bom.txt").write_text("id,name", encoding="utf-8-sig")
print("bytes     :", Path("bom.txt").read_bytes())
print("as utf-8  :", repr(Path("bom.txt").read_text(encoding="utf-8")))
print("as utf8sig:", repr(Path("bom.txt").read_text(encoding="utf-8-sig")))
bytes     : b'\xef\xbb\xbfid,name'
as utf-8  : 'id,name'
as utf8sig: 'id,name'

Decoded as plain utf-8, your first column is named 'id' — not 'id' — so row["id"] raises KeyError while the file looks perfect in every editor. The fix is encoding="utf-8-sig", which eats the BOM on read.

Newlines

The other thing text mode does quietly is translate line endings. Unix ends lines with \n; Windows uses \r\n. Text mode’s universal newlines hides the difference:

Path("crlf.txt").write_bytes(b"one\r\ntwo\r\n")                 # a Windows-style file
print("raw bytes      :", Path("crlf.txt").read_bytes())
with open("crlf.txt", "r", encoding="utf-8") as f:              # newline=None (default)
    print("default read   :", f.readlines())
with open("crlf.txt", "r", encoding="utf-8", newline="") as f:  # no translation
    print("newline='' read:", f.readlines())
with open("crlf.txt", "rb") as f:
    print("binary read    :", f.readlines())
raw bytes      : b'one\r\ntwo\r\n'
default read   : ['one\n', 'two\n']
newline='' read: ['one\r\n', 'two\r\n']
binary read    : [b'one\r\n', b'two\r\n']

The default silently gave you clean \n lines from a \r\n file — usually exactly what you want, and the reason your code is portable at all.

newline= On read On write
None (default) \n, \r, \r\n all → \n \nos.linesep (\r\n on Windows!)
"" Universal, but line endings untranslated \n written literally, no translation
"\n" Only \n ends a line \n written literally
"\r\n" Only \r\n ends a line \n\r\n

The row that bites: with newline=None on Windows, writing "a\n" puts a\r\n on disk. Write a CSV that way and you get the infamous blank line between every row, because csv already emits \r\n and text mode turns the \n into a second \r. That is why the csv docs insist on newline="" — the one time you should override the default.


Paths: pathlib, the cwd trap, and the os.path you’ll inherit

You have been passing "app.log" — a string. Strings are a bad way to describe paths: they need OS-specific separators, gluing them together is error-prone, and asking questions about them means a second module. Since Python 3.4 the answer is pathlib, and it turns a path into an object that knows things.

from pathlib import Path

log = Path("app.log")                        # relative to your CWD
print("name/stem/suffix:", log.name, log.stem, log.suffix)
print("exists / size   :", log.exists(), log.stat().st_size, "bytes")

text = log.read_text(encoding="utf-8")       # open + read + close, one call
print("ERRORs          :", sum("ERROR" in l for l in text.splitlines()))

reports = Path("reports")
reports.mkdir(parents=True, exist_ok=True)   # never explodes if it exists
out = reports / "summary.txt"                # "/" joins paths, cross-platform
print("joined          :", out.as_posix())
name/stem/suffix: app.log app .log
exists / size   : True 226 bytes
ERRORs          : 2
joined          : reports/summary.txt

No open() anywhere: read_text() did open-read-close in one call, and Path answered questions about the file that would otherwise need a second module. The star of the snippet is the / operatorreports / "summary.txt" builds a path the right way on every OS (reports/summary.txt on Linux/macOS, reports\summary.txt on Windows) with no string gluing and no doubled separators.

pathlib What it does Note
Path("a") / "b" / "c.txt" Join path parts Correct separator on every OS
p.exists() / .is_file() / .is_dir() Does it exist, and what is it? Prefer try/except — see EAFP below
p.read_text(encoding="utf-8") Open + read + close → str Small files only (reads it all)
p.write_text(s, encoding="utf-8") Open + write + close ⚠️ Truncates like "w". Returns chars written
p.read_bytes() / p.write_bytes(b) Same, in binary No encoding involved
p.open("r", encoding="utf-8") A real file object Use with with for big files
p.mkdir(parents=True, exist_ok=True) Create a directory The two flags kill 90% of mkdir bugs
p.glob("*.log") / p.rglob("*.log") Match files / recursively Returns a generator, not a list
p.iterdir() Every entry in a directory Generator
p.name .stem .suffix .parent .parts app.log, app, .log, dir, tuple Pure string logic — no disk access
p.resolve() → absolute, symlinks resolved The “where am I really” call
p.stat().st_size / .st_mtime Size in bytes / modified time Raises if missing
p.rename(q) / p.replace(q) Move/rename replace overwrites, rename may not
p.unlink(missing_ok=True) ⚠️ Delete a file missing_ok=True = no error if absent
Path.cwd() / Path.home() Working dir / home dir cwd is the trap below

⚠️ On deleting. p.unlink() deletes a file and shutil.rmtree(p) deletes an entire tree, recursively, with no confirmation and no recycle bin. There is no undo. Never point either at a path you built from user input or a variable you haven’t printed first. When in doubt, print(p.resolve()) before you delete — the two seconds it costs are cheaper than the restore.

You will still meet os.path everywhere, because it predates pathlib by two decades. Learn to read it; write pathlib:

Legacy os.path / os Modern pathlib
os.path.join(a, b) Path(a) / b
os.path.exists(p) Path(p).exists()
os.path.basename(p) / dirname(p) Path(p).name / Path(p).parent
os.path.splitext(p)[1] Path(p).suffix
os.path.abspath(p) Path(p).resolve()
os.getcwd() Path.cwd()
os.makedirs(p, exist_ok=True) Path(p).mkdir(parents=True, exist_ok=True)
glob.glob("*.log") Path(".").glob("*.log")
os.remove(p) Path(p).unlink()

The “runs in my IDE, not from the terminal” bug

This one confuses every beginner exactly once, and the fix is worth a paragraph. A relative path like "app.log" is not resolved against your script’s folder. It is resolved against the current working directory — the folder your shell was in when it launched Python. Those are often different, and that is the entire bug:

$ cd ~/python-io-lab && python3 count_errors.py     # cwd = python-io-lab -> works
$ cd ~ && python3 python-io-lab/count_errors.py     # cwd = ~ -> FileNotFoundError
Traceback (most recent call last):
  File "/home/vinod/python-io-lab/e_notfound.py", line 1, in <module>
    with open("app.log") as f:
         ^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'app.log'

Same script, same file on disk, different directory to start from. Your IDE usually sets the cwd to the project root, which is why it “works in PyCharm” and fails in the terminal, or vice versa.

Path kind Example Resolved against Verdict
Relative "app.log", "data/in.csv" Path.cwd() — where the shell is Fine for CLI tools that act on the user’s dir
Absolute /home/vinod/app.log, C:\logs\app.log Nothing — it’s already complete Portable? No. Hard-coding these is its own bug
Script-relative Path(__file__).resolve().parent / "app.log" The script’s own folder ✅ The fix — works from any cwd
from pathlib import Path

HERE = Path(__file__).resolve().parent       # the folder THIS script lives in
log = HERE / "app.log"                       # works from any directory

Path(__file__).resolve().parent is the idiom to remember: it anchors data files to the code that owns them, and it is immune to whatever directory the user happens to be in.

⚠️ Windows backslashes. In a Python string, \ starts an escape sequence — so a Windows path pasted into quotes is quietly a different string:

bad = "C:\temp\new.txt"          # looks fine, is not
print("len      :", len(bad))
print("chars    :", [c for c in bad[:8]])
good = r"C:\temp\new.txt"        # raw string
print("raw len  :", len(good))
len      : 13
chars    : ['C', ':', '\t', 'e', 'm', 'p', '\n', 'e']
raw len  : 15

\t became a tab and \n a newline — the path is now 13 characters of nonsense, and nothing warned you. Sometimes it does raise, which is friendlier:

  File "<string>", line 1
    print("C:\Users\vinod\app.log")
          ^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: (unicode error) 'unicodeescape' codec can't decode bytes in position 2-3: truncated \UXXXXXXXX escape

(\U begins an 8-hex-digit Unicode escape, so C:\Users is a syntax error.) Three fixes: a raw string r"C:\temp\new.txt", forward slashes "C:/temp/new.txt" (Windows accepts them), or best — Path("C:/temp") / "new.txt".


Error handling: specific exceptions and the EAFP idiom

The filesystem is the outside world, and the outside world fails. try/except is how you meet it.

try:
    with open("missing.log", "r", encoding="utf-8") as f:
        data = f.read()
except FileNotFoundError:
    data = ""                      # a sensible default

Read a traceback bottom-up; the last line is always ExceptionType: message, and that type is the thing you catch:

Exception Errno Raised when Typical fix
FileNotFoundError 2 Path doesn’t exist ("r"), or its directory doesn’t Check cwd; create the dir; default the value
PermissionError 13 No OS permission to read/write Fix permissions, or write somewhere you own
IsADirectoryError 21 You opened a directory as a file You wanted p / "file.txt"
NotADirectoryError 20 A path component is a file, not a dir Fix the path
FileExistsError 17 Mode "x" and the file exists Use "w"/"a", or handle it — often intended
IsADirectoryError on delete 21 unlink() on a directory p.rmdir() (empty) or ⚠️ shutil.rmtree()
UnicodeDecodeError Bytes aren’t valid in your encoding Not an OSError — it’s a ValueError
ValueError: I/O operation on closed file. Using f after the with block ended Do the work inside the block
TypeError: write() argument must be str, not int f.write(42) in text mode f.write(str(42)) or f.write(f"{n}")
TypeError: a bytes-like object is required, not 'str' f.write("hi") in "wb" mode f.write(b"hi") or "hi".encode("utf-8")
OSError: [Errno 28] No space left on device 28 Disk full Free space; check before big writes
OSError: [Errno 24] Too many open files 24 Descriptor leak — files never closed Use with

The structure is important: FileNotFoundError, PermissionError, IsADirectoryError and friends are all subclasses of OSError and carry .errno, .strerror and .filename. So except OSError: catches the whole family when you genuinely want to. But UnicodeDecodeError is not in that family — it’s a ValueError — which surprises people who wrote except OSError and still crashed.

A full statement has four clauses, and beginners rarely use the last two:

from pathlib import Path

for target in ("app.log", "missing.log"):
    try:
        text = Path(target).read_text(encoding="utf-8")  # the risky bit — keep it SMALL
    except FileNotFoundError as exc:
        print(f"{target}: no such file (errno {exc.errno}) - skipping")
    except PermissionError:
        print(f"{target}: not allowed to read it")
    else:
        print(f"{target}: {len(text)} chars")            # ran ONLY if no exception
    finally:
        print(f"  ...done with {target}")                # runs ALWAYS
app.log: 226 chars
  ...done with app.log
missing.log: no such file (errno 2) - skipping
  ...done with missing.log
Clause Runs when Use it for
try: Always The smallest risky operation — not your whole program
except X: X (or a subclass) was raised Handling this failure. Order: most specific first
except X as e: Same, binding the object Reading e.errno, e.filename, e.strerror
else: try finished with no exception The success path — keeps it out of try
finally: Always — success, exception, even return Cleanup. (with already does this for files)

Why a bare except: is wrong. It looks tidy and it is a trap:

# ❌ Catches EVERYTHING — including your own typos and Ctrl-C
try:
    with open(path) as f:
        data = f.raed()          # typo!
except:
    data = ""                    # you'll never learn about the typo

except: catches BaseException, which includes KeyboardInterrupt (Ctrl-C) and SystemExit — so your program becomes unkillable and silently swallows the AttributeError from f.raed(). You’d debug “why is data always empty?” for an hour. Catch what you can actually handle; if you must be broad, except Exception as exc: at least spares Ctrl-C and lets you log exc.

EAFP beats LBYL

Python has a name for its preferred style: EAFPEasier to Ask Forgiveness than Permission. Just try the thing and handle the failure. The alternative, LBYL (Look Before You Leap), checks first — and for files it is subtly broken:

# ❌ LBYL — two trips to the filesystem, and a race between them
if path.exists():
    text = path.read_text(encoding="utf-8")   # may STILL raise!
else:
    text = ""

# ✅ EAFP — one atomic attempt
try:
    text = path.read_text(encoding="utf-8")
except FileNotFoundError:
    text = ""

The LBYL version has a real bug called TOCTOU (time-of-check to time-of-use): between exists() saying yes and read_text() running, another process can delete the file, and you crash anyway — on the line you “protected.” You cannot fix it by checking harder; the check and the open are two separate operations and something can always happen in the gap. open() is atomic: it either hands you the file or raises, with no window in between.

LBYL (if exists:) EAFP (try: open)
Filesystem calls 2+ 1
Race condition ⚠️ Yes — TOCTOU ✅ None — atomic
Covers permissions/is-a-dir? exists() only answers existence ✅ The except sees every failure
Pythonic? No Yes
Still use it when… Choosing a branch (“if config exists, use it, else generate one”) Everything else

exists() is not banned — it is fine for genuine branching. It is just not a guard.


Hands-on lab

You will build a small log-processing script: create a file, append to it, read it line by line, count matches, survive a missing file, redo it with pathlib, peek at raw bytes, and finish by watching w destroy your work on purpose. Everything is stdlib — no pip install, no virtual environment needed (a venv is only for third-party packages, which we don’t use here). About 10 minutes.

Step 1 — Make a lab directory and check your Python.

mkdir ~/python-io-lab && cd ~/python-io-lab
python3 -V
# Python 3.12.3        # any 3.12+ is fine. On Windows use: py -3 -V

What just happened: You cd’d into the lab, so your cwd is ~/python-io-lab and every relative path below resolves there. That matters in Step 8.

Step 2 — Create the log (write_log.py).

# write_log.py — create the log from scratch with mode "w"
lines = [
    "2026-07-15 09:14:02 INFO  service started",
    "2026-07-15 09:14:07 WARN  cache miss ratio 0.42",
    "2026-07-15 09:15:31 ERROR upstream timeout after 30s",
]

with open("app.log", "w", encoding="utf-8") as f:
    for line in lines:
        n = f.write(line + "\n")       # write() RETURNS a character count
        print(f"wrote {n} chars: {line[:24]}")

print("closed?", f.closed)             # the with block closed it for us
$ python3 write_log.py
wrote 42 chars: 2026-07-15 09:14:02 INFO
wrote 48 chars: 2026-07-15 09:14:07 WARN
wrote 53 chars: 2026-07-15 09:15:31 ERRO
closed? True

What just happened: "w" created the file. Each write() returned a character count (42/48/53 — the line plus the \n you added yourself). After the block, f.closed is True without you calling anything.

Step 3 — Append, never overwrite (append_log.py).

# append_log.py — add lines to the END with mode "a"
more = [
    "2026-07-15 09:16:03 INFO  retry succeeded",
    "2026-07-15 09:18:44 ERROR disk usage 91%",
]

with open("app.log", "a", encoding="utf-8") as f:
    f.writelines(line + "\n" for line in more)   # writelines adds NO newlines

with open("app.log", "r", encoding="utf-8") as f:
    print("lines now:", len(f.readlines()))
$ python3 append_log.py
lines now: 5

What just happened: "a" kept the first three lines and added two. Had you typed "w", you’d now have 2 lines and the first three would be gone forever. Run it twice and you’ll see 7 — appending is additive, which is exactly the point.

Step 4 — Read it back three ways (read_log.py).

# read_log.py — three ways to read; only one of them scales
with open("app.log", "r", encoding="utf-8") as f:
    whole = f.read()                   # 1: the WHOLE file into one str
print("read()     ->", type(whole).__name__, len(whole), "chars")

with open("app.log", "r", encoding="utf-8") as f:
    first = f.readline()               # 2: one line, keeps its "\n"
print("readline() ->", repr(first))

with open("app.log", "r", encoding="utf-8") as f:
    for i, line in enumerate(f, 1):    # 3: THE idiom — one line at a time
        print(f"  {i}: {line.rstrip()}")
$ python3 read_log.py
read()     -> str 226 chars
readline() -> '2026-07-15 09:14:02 INFO  service started\n'
  1: 2026-07-15 09:14:02 INFO  service started
  2: 2026-07-15 09:14:07 WARN  cache miss ratio 0.42
  3: 2026-07-15 09:15:31 ERROR upstream timeout after 30s
  4: 2026-07-15 09:16:03 INFO  retry succeeded
  5: 2026-07-15 09:18:44 ERROR disk usage 91%

What just happened: read() pulled all 226 characters into one str. readline() returned one line with its \n (see the repr). The loop held one line at a time — the only version that would survive a 10 GB log. Each block re-opened the file because a file object is a one-shot iterator.

Step 5 — The real job: count matches, survive a missing file (count_errors.py).

# count_errors.py — count matches, survive a missing file
def count_errors(path: str) -> int:
    """Count ERROR lines. EAFP: just try to open it."""
    hits = 0
    with open(path, "r", encoding="utf-8") as f:
        for line in f:                 # memory-safe on a 10 GB log
            if "ERROR" in line:
                hits += 1
    return hits

for target in ("app.log", "missing.log"):
    try:
        found = count_errors(target)
    except FileNotFoundError as exc:
        print(f"{target}: no such file (errno {exc.errno}) - skipping")
    except PermissionError:
        print(f"{target}: not allowed to read it")
    else:
        print(f"{target}: {found} ERROR lines")
    finally:
        print(f"  ...done with {target}")
$ python3 count_errors.py
app.log: 2 ERROR lines
  ...done with app.log
missing.log: no such file (errno 2) - skipping
  ...done with missing.log

What just happened: The missing file didn’t crash the script — it took the except branch, reported errno 2, and the loop continued. Note else: ran only for the successful file, and finally: ran for both.

Step 6 — The same job with pathlib (pathlib_count.py).

# pathlib_count.py — the same job, the modern way
from pathlib import Path

log = Path("app.log")                        # relative to your CWD
print("where    :", "/".join(log.resolve().parts[-2:]))
print("exists   :", log.exists())
print("stem/suf :", log.stem, log.suffix)
print("size     :", log.stat().st_size, "bytes")

text = log.read_text(encoding="utf-8")       # open + read + close, one call
errors = sum("ERROR" in line for line in text.splitlines())
print("ERRORs   :", errors)

reports = Path("reports")
reports.mkdir(parents=True, exist_ok=True)   # never explodes if it exists
out = reports / "summary.txt"                # "/" joins paths, cross-platform
n = out.write_text(f"errors={errors}\n", encoding="utf-8")
print("wrote    :", n, "chars ->", out.as_posix())
print("globbed  :", sorted(p.name for p in Path(".").glob("*.log")))
$ python3 pathlib_count.py
where    : python-io-lab/app.log
exists   : True
stem/suf : app .log
size     : 226 bytes
ERRORs   : 2
wrote    : 9 chars -> reports/summary.txt
globbed  : ['app.log']

What just happened: Same answer, no open() in sight. read_text() did open-read-close in one call, mkdir(parents=True, exist_ok=True) was safe to re-run, and reports / "summary.txt" built the path correctly for your OS. Run it twice — it won’t complain about the existing directory.

Step 7 — Binary: the bytes underneath (peek_bytes.py).

# peek_bytes.py — binary mode: no decode, no newline translation
with open("app.log", "rb") as f:        # "rb" -> bytes, and NO encoding=
    head = f.read(20)                   # the first 20 BYTES

print("type :", type(head).__name__)
print("repr :", head)
print("hex  :", head[:8].hex(" "))
print("byte0:", head[0])                # indexing bytes gives an int!
print("text :", head.decode("utf-8"))   # you decode, explicitly

rupee = "billing ₹1,240"                # non-ASCII
print("str  :", len(rupee), "characters")
print("utf8 :", len(rupee.encode("utf-8")), "bytes")
$ python3 peek_bytes.py
type : bytes
repr : b'2026-07-15 09:14:02 '
hex  : 32 30 32 36 2d 30 37 2d
byte0: 50
text : 2026-07-15 09:14:02 
str  : 14 characters
utf8 : 16 bytes

What just happened: No encoding= and no TextIOWrapper — just 20 raw bytes. head[0] is the int 50, not "2". And proved that one character can be three bytes, which is why “characters” and “bytes” are different units. This read(n)-then-inspect pattern is exactly how you check a file’s magic number (a PNG always starts \x89PNG).

Step 8 — ⚠️ Watch "w" destroy your log (truncate_demo.py).

⚠️ This step deliberately destroys app.log. That is the lesson. You’ve already read the file, and Step 2 rebuilds it in one command.

# truncate_demo.py — WARNING: this DESTROYS app.log. That is the whole point.
from pathlib import Path

print("before open :", Path("app.log").stat().st_size, "bytes")
f = open("app.log", "w", encoding="utf-8")     # <-- truncates RIGHT HERE
print("after open  :", Path("app.log").stat().st_size, "bytes  <-- gone already")
f.close()
print("after close :", Path("app.log").stat().st_size, "bytes")
$ python3 truncate_demo.py
before open : 226 bytes
after open  : 0 bytes  <-- gone already
after close : 0 bytes

What just happened: 226 bytes vanished on the open() line, before any write(). No exception, no warning, no recycle bin. Internalise this and you will never again type "w" when you meant "a". Rebuild with python3 write_log.py && python3 append_log.py.

Step 9 — Prove the cwd bug, then clean up.

cd ~ && python3 python-io-lab/count_errors.py    # same script, different cwd
# app.log: no such file (errno 2) - skipping

What just happened: The script is fine; your cwd moved. "app.log" resolved against ~, not against the script’s folder. This is the “works in my IDE, fails in the terminal” bug — and Path(__file__).resolve().parent / "app.log" is the fix.

⚠️ Cleanup deletes the whole lab folder. Check the path before you press Enter — rm -rf and shutil.rmtree() have no undo.

cd ~ && rm -rf ~/python-io-lab       # Windows: rmdir /s /q %USERPROFILE%\python-io-lab

Common mistakes and troubleshooting

Symptom / traceback Cause Fix
FileNotFoundError: [Errno 2] No such file or directory: 'app.log' Relative path resolved against the cwd, not the script’s folder print(Path.cwd()) to see where you are; use Path(__file__).resolve().parent / "app.log"
The file is empty / my data is gone open(p, "w") truncated it at open time Use "a" to append or "x" to refuse to clobber. "w" means “replace”
Last few lines missing from the log Buffer never flushed — no close(), or the process was killed Use with; or f.flush(); or open(..., buffering=1) for line buffering
ResourceWarning: unclosed file (under -X dev) You never closed it; CPython’s refcounting hid it Use with. Run python3 -X dev to catch these early
OSError: [Errno 24] Too many open files Descriptor leak — opening in a loop without closing Use with inside the loop
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte Wrong decoder — the file isn’t UTF-8 (or encoding= omitted on Windows) Pass the real encoding (latin-1, cp1252); or errors="replace" to salvage
First CSV column is 'id', then KeyError: 'id' UTF-8 BOM from Excel decoded as plain utf-8 encoding="utf-8-sig"
PermissionError: [Errno 13] Permission denied: 'secret.txt' No OS permission, or the file is open/locked (Windows), or it’s read-only ls -l / check ownership; write somewhere you own; close other programs
IsADirectoryError: [Errno 21] Is a directory: 'reports' You opened a directory as a file You meant Path("reports") / "summary.txt"
FileExistsError: [Errno 17] File exists: 'app.log' Mode "x" and the file already exists That’s "x" working. Use "w"/"a", or catch it
TypeError: write() argument must be str, not int f.write(42) — text mode takes str only f.write(str(42)), or f.write(f"{n}\n")
TypeError: a bytes-like object is required, not 'str' f.write("hi") on a "wb" file f.write(b"hi") or f.write("hi".encode("utf-8"))
ValueError: I/O operation on closed file. Using f after its with block ended Move the work inside the block
Output is double-spaced Lines from a file keep their \n, and print adds another print(line.rstrip()), or print(line, end="")
All lines run together / one giant line write() and writelines() never add \n Add it: f.write(line + "\n")
Blank line between every CSV row (Windows) newline=None turned csv’s \r\n into \r\r\n open(p, "w", newline="", encoding="utf-8")
MemoryError on a big file f.read() / f.readlines() loaded the whole thing for line in f: — one line at a time
SyntaxError: (unicode error) 'unicodeescape' codec can't decode bytes ... truncated \UXXXXXXXX escape Windows path in a normal string — \U, \t, \n are escapes Raw string r"C:\Users\...", forward slashes, or Path()
Script “does nothing”, no error at all A bare except: swallowed the real exception Never except:. Catch the specific type, or except Exception as exc: and log it

Three of these deserve more than a table row.

1. "w" is not “write”, it is “replace”. Reading a mode string as English is the mistake: "w" sounds like “let me write to this file,” which is what "a" and "r+" do. "w" means “this file’s history is over.” It truncates on open(), before any write, and if your program crashes on the very next line the file is still empty. The tell is a file that’s 0 bytes when you expected content. Make "a" your reflex for anything log-shaped, and reserve "w" for output you genuinely regenerate each run.

2. The default encoding is a bug that only fires on someone else’s machine. open(p) without encoding= asks the locale what to do. On your Mac that’s UTF-8 and everything is fine. On a colleague’s Windows box it may be cp1252, and the same file now raises UnicodeDecodeError — or worse, decodes without error into subtly wrong text (mojibake), because cp1252 has a meaning for almost every byte. That silent version is the dangerous one: no traceback, just wrong data. encoding="utf-8" on every text open() costs 18 characters and removes the entire class of bug. Audit an existing codebase with python3 -X warn_default_encoding yourscript.py.

3. A bare except: doesn’t handle errors, it hides them. except: catches BaseException — every error in the language, including your typos, plus KeyboardInterrupt and SystemExit. So a misspelled f.raed() becomes “the file must have been missing,” you set data = "", and the program cheerfully computes a wrong answer while Ctrl-C stops working. The rule: catch only what you have a plan for. except FileNotFoundError: says “I know what to do when it’s missing.” except: says “I don’t want to know,” and files are the one place where not knowing gets expensive.


Cheat-sheet

Syntax What it does
open(p, "r", encoding="utf-8") Read text. Fails if missing. The default mode
open(p, "w", encoding="utf-8") ⚠️ Write — truncates at open
open(p, "a", encoding="utf-8") Append — creates if needed, never truncates
open(p, "x", encoding="utf-8") Create only if absent → else FileExistsError
open(p, "r+", encoding="utf-8") Read and write, no truncation; must exist
open(p, "rb") / open(p, "wb") Binary → bytes. No encoding=
with open(...) as f: Always. Guarantees flush + close, even on exception
f.read() Whole file → one str ⚠️ memory
f.read(n) Up to n chars/bytes
f.readline() One line, keeps \n; '' at EOF
f.readlines() list[str] ⚠️ whole file in memory
for line in f: The idiom — one line at a time, any file size
f.write(s) Write; returns count; adds no newline
f.writelines(seq) Write each item; adds no newlines; returns None
print(s, file=f) Write with a newline; converts to str
f.flush() Push the buffer to the OS without closing
f.seek(0) / f.tell() Jump to a position / report the position
f.closed .name .mode .encoding Inspect the file object
s.encode("utf-8") / b.decode("utf-8") strbytes / bytesstr
python3 -X dev script.py Surface ResourceWarning: unclosed file
python3 -X warn_default_encoding s.py Flag every open() missing encoding=
pathlib / errors What it does
from pathlib import Path The import you want
Path("a") / "b" / "c.txt" Join — correct separator everywhere
Path(__file__).resolve().parent The script’s own folder — beats the cwd bug
Path.cwd() / Path.home() Working directory / home directory
p.read_text(encoding="utf-8") Open + read + close → str
p.write_text(s, encoding="utf-8") ⚠️ Open + write (truncates) + close
p.read_bytes() / p.write_bytes(b) Same, binary
p.exists() .is_file() .is_dir() Existence checks (branching, not guarding)
p.mkdir(parents=True, exist_ok=True) Make a directory tree, idempotently
p.glob("*.log") / p.rglob("*.log") Match files / recursively → generator
p.name .stem .suffix .parent app.log · app · .log · its folder
p.stat().st_size Size in bytes
p.unlink(missing_ok=True) ⚠️ Delete a file — no undo
shutil.rmtree(p) ⚠️⚠️ Delete a whole tree — no undo, no prompt
try/except FileNotFoundError: Missing file (errno 2)
except PermissionError: No access (errno 13)
except IsADirectoryError: It’s a directory (errno 21)
except UnicodeDecodeError: Wrong encoding — a ValueError, not an OSError
except OSError as e: The whole filesystem family; read e.errno
else: / finally: Success-only path / always-runs cleanup
except: Never. Catches typos, KeyboardInterrupt, SystemExit

Interview and exam questions

Q: What does with open(...) give you that open() + close() doesn’t? A: A guarantee. with is a context manager: when control leaves the block — normally, via return/break, or because an exception was raised — Python calls close(), which flushes the buffer first. A manual close() on a later line is simply jumped over by any exception above it, leaking the file descriptor and possibly losing buffered writes. Achieving the same by hand needs try/finally, which is just with with extra steps.

Q: What’s the difference between modes w, a, and x? A: w creates the file, or truncates it to zero bytes at open() time if it exists. a creates it if needed and forces every write to the end, never destroying existing content. x refuses to touch an existing file and raises FileExistsError. If you mean “add,” use a; if you mean “create but never clobber,” use x.

Q: Why is for line in f: better than f.readlines()? A: Memory. readlines() loads the entire file into RAM and builds a list object per line, so a 10 GB log needs >10 GB and raises MemoryError. Iterating the file object is lazy — it reads a block and hands you one line at a time, so peak memory is one line regardless of file size. Both give you the same lines (each keeping its trailing \n).

Q: What does f.write("hello") return, and does it mean the data is saved? A: It returns 5 — the number of characters written in text mode (bytes in binary mode). It does not mean the data is on disk. The text is copied into an in-memory buffer (~8192 bytes) and only reaches the OS when the buffer fills, when you call flush(), or when the file is closed. Kill the process before that and the bytes never existed.

Q: What is the difference between text mode and binary mode? A: Text mode wraps the file in a TextIOWrapper that runs a codec — decoding bytesstr on read and encoding strbytes on write using encoding — and also translates newlines. Binary mode skips both: you get and give raw bytes, indexing gives an int, and passing encoding= raises ValueError. Use text for .txt/.csv/.json, binary for .png/.zip/.parquet.

Q: Why should you always pass encoding="utf-8"? A: Because encoding=None means “the platform’s locale encoding,” not UTF-8. On Linux/macOS that’s usually UTF-8 so it works; on Windows it’s often cp1252, so the identical script either raises UnicodeDecodeError or — worse — silently decodes into mojibake on a colleague’s machine. Passing it explicitly makes the code deterministic everywhere. (PEP 686 makes UTF-8 the default in Python 3.15, but you can’t assume that yet.)

Q: Which exception is UnicodeDecodeError a subclass of, and why does that matter? A: ValueError (via UnicodeError) — not OSError. It matters because except OSError: catches FileNotFoundError, PermissionError and IsADirectoryError but sails straight past a decoding failure, so code that “handles all file errors” still crashes on a badly encoded file.

Q: What are EAFP and LBYL, and which does Python prefer for files? A: LBYL (“look before you leap”) checks first: if p.exists(): open(p). EAFP (“easier to ask forgiveness than permission”) just tries: try: open(p) except FileNotFoundError:. Python prefers EAFP, and for files LBYL is actually buggy — between the exists() check and the open() another process can delete the file (a TOCTOU race), so you crash on the line you thought you’d protected. open() is atomic; exists() + open() is not.

Q: Why is except: bad? A: It catches BaseException — everything, including KeyboardInterrupt and SystemExit, so Ctrl-C stops working — and it swallows your own bugs. A typo like f.raed() raises AttributeError, gets caught by the same handler as the missing file, and the program silently continues with wrong data. Catch the specific exception you have a plan for; if you must be broad, use except Exception as exc: and log exc.

Q (coding): Write a function that returns the number of lines containing “ERROR”, returning 0 if the file doesn’t exist. It must work on a 10 GB file. A:

from pathlib import Path

def count_errors(path: Path | str) -> int:
    try:
        with open(path, "r", encoding="utf-8") as f:
            return sum(1 for line in f if "ERROR" in line)   # one line at a time
    except FileNotFoundError:
        return 0

The points being tested: with (guaranteed close), iterating the file rather than read()/readlines() (memory), explicit encoding, and EAFP with the specific exception. The generator inside sum() keeps peak memory at one line.

Q (coding): A script writes a report but the file is sometimes empty when it crashes. What are the two most likely causes? A: (1) It opens with "w", which truncates at open() — so any crash after the open but before the writes leaves a zero-byte file where the old report used to be. (2) It never closes the file (no with), so the buffered writes were never flushed when the process died. Fix both: with open(path, "w", encoding="utf-8") as f:, and if the old content must survive a failure, write to a temporary file and Path(tmp).replace(final) only on success — replace() is atomic.

Q: Your script works in PyCharm but raises FileNotFoundError from the terminal. Why? A: The path is relative, and relative paths resolve against the current working directory — the folder the shell was in when it launched Python — not the folder the script lives in. Your IDE sets the cwd to the project root; your terminal doesn’t. Fix it by anchoring to the script: Path(__file__).resolve().parent / "app.log".


Key takeaways

pythonfile-ioopencontext-managerswith-statementpathlibencodingutf-8exceptionstry-excepterror-handlingbinary-fileseafpfundamentals
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments