Lesson 14 / 16
File Operations
The same 41 bytes give a byte sequence of length 41 in binary mode, and a 34-character string in text mode with utf-8; a wrong encoding breaks at read time, not open time, and latin-1 never breaks at all, silently producing 41 wrong characters.
Contents
The previous lesson’s twelve records sat ready in memory. The value field was already
a string; the only job was converting it to a number and checking it against the
field’s bounds. The domain root paid off because the error’s source was the domain
itself.
When records come from a file, one more layer gets inserted. What sits in a file is neither a string nor a number — it is bytes. Converting bytes to a string is an encoding decision, and if that decision is wrong, the error never reaches the domain layer at all; it shows up while reading. The concepts of file, encoding, and byte were built in the How Computers Work course; the concept was built there, what is measured here is what Python’s two file modes give on the same bytes.
Two Modes, Two Types
open works in two modes, and the difference between them is not an option, it is
the type of the returned object.
Binary mode ("rb", "wb") gives a byte sequence. Whatever is in the file is what
you get; no translation happens. The length is the byte count.
Text mode ("r", "w") gives a string. A decoder steps in between: bytes are
turned into characters according to an encoding, and line endings are translated.
The length is the character count.
If encoding is not given in text mode, the interpreter picks a default, and that
default depends on the environment it runs in. Same file, same program, a different
environment — a different result. Every measurement in this lesson writes the encoding
explicitly; that is the condition for it to be measurable.
Same Bytes, Different Encodings
The measurement starts with a single piece of text: a measurement line carrying Turkish letters, a degree sign, and an em dash. This text is encoded with utf-8, and the resulting bytes are tried for decoding with five separate encodings.
The measurement’s assumptions:
- EF19 — The entire measurement runs on a single byte sequence; the sequence is produced by encoding the text, it is not written by hand.
- EF20 — The oracle is the source text itself: whether the decoded string is identical to the source can be asked, because we produced the source ourselves.
- EF21 — The “broken byte position” is read from the exception object’s own field; the error message is not parsed.
- EF22 — The encoding names come from the language’s own decoder catalog; none of them belong to an outside tool.
- EF23 — The chunk-size scan splits the same bytes at different sizes and decodes every chunk separately; no state is carried between chunks.
"""Same bytes, different encodings: does it decode, is it identical, where does it break.""" TEXT = "kuzey yamaç: 12,5 °C — ölçüm tamam" RAW = TEXT.encode("utf-8") print(f"characters {len(TEXT)} | utf-8 bytes {len(RAW)} | " f"multi-byte characters {sum(len(c.encode('utf-8')) > 1 for c in TEXT)}") ENCODINGS = ("utf-8", "utf-16", "latin-1", "ascii", "cp1254") print() print(f"{'encoding':<12s}{'decoded':<10s}{'characters':>11s}{'identical':>10s}" f" broken class / byte position") for name in ENCODINGS: try: decoded = RAW.decode(name) except UnicodeDecodeError as e: print(f" {name:<10s}{'no':<10s}{'-':>11s}{'-':>10s}" f" {type(e).__name__} / {e.start}") else: print(f" {name:<10s}{'yes':<10s}{len(decoded):>11d}" f"{('yes' if decoded == TEXT else 'no'):>10s} -") print() print(f"{'error mode':<20s}{'characters':>11s}{'identical':>10s} first seven characters") for mode in ("ignore", "replace", "backslashreplace"): c = RAW.decode("ascii", errors=mode) print(f" {mode:<18s}{len(c):>11d}{('yes' if c == TEXT else 'no'):>10s}" f" {c[:7]!r}") print() print(f"{'chunk size':<12s}{'chunks':>7s}{'broken chunks':>15s}") for size in range(4, 25, 2): chunks = [RAW[i:i + size] for i in range(0, len(RAW), size)] broken = 0 for chunk in chunks: try: chunk.decode("utf-8") except UnicodeDecodeError: broken += 1 print(f" {size:<10d}{len(chunks):>7d}{broken:>15d}") whole = 0 try: RAW.decode("utf-8") except UnicodeDecodeError: whole = 1 print(f"broken when the same bytes are decoded whole {whole}")
characters 34 | utf-8 bytes 41 | multi-byte characters 6 encoding decoded characters identical broken class / byte position utf-8 yes 34 yes - utf-16 no - - UnicodeDecodeError / 40 latin-1 yes 41 no - ascii no - - UnicodeDecodeError / 10 cp1254 yes 41 no - error mode characters identical first seven characters ignore 28 no 'kuzey y' replace 41 no 'kuzey y' backslashreplace 80 no 'kuzey y' chunk size chunks broken chunks 4 11 4 6 7 2 8 6 2 10 5 2 12 4 2 14 3 2 16 3 0 18 3 0 20 3 2 22 2 0 24 2 2 broken when the same bytes are decoded whole 0
The Silent Wrong Answer
The first line gives the lesson’s core: 34-character text holds 41 bytes. The 7-byte difference comes from the 6 multi-byte characters. Character count and byte count are independent of each other, and which one you are counting depends on which mode you read in.
The encoding table shows three separate behaviors. utf-8 decodes, gives 34 characters, and is identical to the source. This is the definition of the correct encoding.
ascii and utf-16 cannot decode and raise UnicodeDecodeError. The positions
differ: ascii breaks at the 10th byte — the first byte of the first Turkish letter.
utf-16, by contrast, breaks at the 40th byte, nearly at the end; because utf-16
reads bytes in pairs and 41 is an odd number, the last byte is left unpaired. Both
break, but where they break comes from the encoding’s own rule.
The real finding is the latin-1 and cp1254 rows. Both decode — raising no exception at all — and give 41 characters. That is, they turn every byte into a character. But they are not identical. A single-byte encoding accepts every byte that comes its way as valid; there is nothing there to break on. This is the measurement’s most dangerous result: a wrong encoding does not always produce an exception. An encoding that raises one warns you; one that does not silently gives back the wrong text, and the mistake is only noticed by a human reading it.
Error modes are the deliberate version of this. ignore drops bytes it cannot decode
and 28 characters remain — the bytes of 6 characters fell away. replace puts
one character in place of every faulty byte and gives 41; the number matches the
byte count because the replacement is done byte by byte. backslashreplace describes
every faulty byte with a four-character notation, and the length grows to 80. All
three have identical reading no: giving an error mode does not recover the data,
it only prevents breaking.
The Chunk Boundary
The last table gives the cost of reading in chunks in binary mode. The same 41 bytes are split into different sizes, and each chunk is decoded separately.
The result is irregular. At 4-byte chunks, 4 of 11 chunks break; at sizes 16 and 18, none break; at 20, 2 chunks break again, 0 at 22, 2 at 24. The break count does not depend on the chunk size’s magnitude, it depends on whether a chunk boundary falls in the middle of a multi-byte character. When the same bytes are decoded whole, the break count is 0.
The direct consequence of this: if you are reading in binary mode and decoding the bytes yourself, carrying state between chunks is your job. Text mode already does this work — the decoder holds back a character left incomplete at a chunk boundary. Binary mode is fast and impartial; because it does not take on the translation, it does not take on translation’s problems either, and leaves them to the caller.
Mode, Type, and the Break Point
The second measurement works with a real file. The file is built in a temporary directory, and the directory is deleted when the measurement ends; the measurement leaves no permanent trace.
- EF24 — The file is produced in a temporary directory, and its bytes come from the previous measurement’s text; no path is printed, because a path depends on the environment.
- EF25 — “Length” is bytes in binary mode, characters in text mode; the two are measured under the same name but are not the same thing.
- EF26 — The break-point measurement tries opening and reading as separate steps; which one produces the exception is read from the run.
- EF27 — In the newline measurement, the file carries two separate line-ending forms, one two characters long, one a single character; the translation difference arises from this.
"""Text mode vs binary mode: same file, different type, different length, different break point.""" import tempfile from pathlib import Path TEXT = "kuzey yamaç: 12,5 °C — ölçüm tamam" LINED = b"kuzey\r\nyama\xc3\xa7\nolcum\r\n" with tempfile.TemporaryDirectory() as temp_dir: data_file = Path(temp_dir) / "measurement.dat" data_file.write_bytes(TEXT.encode("utf-8")) lines_file = Path(temp_dir) / "lines.dat" lines_file.write_bytes(LINED) print(f"{'read mode':<22s}{'type':<8s}{'length':>8s}{'identical':>10s}") with open(data_file, "rb") as f: binary = f.read() print(f" {'binary':<20s}{type(binary).__name__:<8s}{len(binary):>8d}{'-':>10s}") for name, encoding, mode in (("text utf-8", "utf-8", "strict"), ("text latin-1", "latin-1", "strict"), ("text ascii replace", "ascii", "replace")): with open(data_file, "r", encoding=encoding, errors=mode) as f: text = f.read() print(f" {name:<20s}{type(text).__name__:<8s}{len(text):>8d}" f"{('yes' if text == TEXT else 'no'):>10s}") print() print("step result") try: f = open(data_file, "r", encoding="ascii") except UnicodeDecodeError as e: print(f" {'open':<18s}{type(e).__name__} / byte {e.start}") else: print(f" {'open':<18s}succeeded, file opened") try: f.read() except UnicodeDecodeError as e: print(f" {'read':<18s}{type(e).__name__} / byte {e.start}") finally: f.close() print() print(f"{'read form':<22s}{'length':>8s}{'CR count':>11s}{'lines':>7s}") with open(lines_file, "rb") as f: raw = f.read() print(f" {'binary':<20s}{len(raw):>8d}{raw.count(13):>11d}" f"{len(raw.splitlines()):>7d}") for name, newline_arg in (("text (default)", None), ('text newline=""', "")): with open(lines_file, "r", encoding="utf-8", newline=newline_arg) as f: text = f.read() with open(lines_file, "r", encoding="utf-8", newline=newline_arg) as f: lines = f.readlines() print(f" {name:<20s}{len(text):>8d}{text.count(chr(13)):>11d}" f"{len(lines):>7d}") print("temp directory deleted:", not Path(temp_dir).exists())
read mode type length identical binary bytes 41 - text utf-8 str 34 yes text latin-1 str 41 no text ascii replace str 41 no step result open succeeded, file opened read UnicodeDecodeError / byte 10 read form length CR count lines binary 21 2 3 text (default) 18 0 3 text newline="" 20 2 3 temp directory deleted: True
Reading the Numbers
The first table gives the two modes’ difference by type. Binary mode returns bytes
and the length is 41; text mode returns str, and with the correct encoding the
length is 34. Same file, same content, two different numbers — and both are
correct. latin-1 again gives 41, and the identical column reads no: the
silent wrong answer seen in the in-memory measurement repeats exactly the same way when
reading from a file.
The second table is the lesson’s sharpest finding. The open call with ascii
encoding succeeds — the file opens, no exception comes out. The exception comes at
the read call, at byte 10. open only prepares the file and stores the encoding
name; the decoding work is done at the first read. An encoding error is not an open
error, it is a read error, and the practical consequence is this: a try block
wrapping the open call cannot catch this error. The block that catches it has to wrap
the read.
The third table counts newline translation. The file is 21 bytes and carries 2
carriage-return characters inside it, because two lines end with a two-character line
ending and one with a single-character line ending. When text mode reads with the
default setting, the length drops to 18 and the carriage-return count becomes
0: the decoder turned every line-ending form into a single character. When
newline="" is given, the translation turns off, and the length is 20, the
carriage-return count 2.
The line count is 3 across all three reads. Translation does not change where the lines end, it only changes how many characters the line ending is written with. Comparing a file’s length when read in text mode to the file’s byte size therefore gives the wrong result: both encoding and newline translation get in between.
The Write Side and Open Modes
The read side assumes the encoding; the write side establishes it. The third measurement tries writing the same text with five encodings and does a round-trip test on each: when the encoded bytes are decoded back with the same encoding, does the source text come back?
- EF28 — The round-trip test is done with its own encoding in every case; what is measured is whether the encoding can carry the text.
- EF29 — The open modes are tried on the same file in sequence; at every step the file’s byte count is read from the file system, not computed.
- EF30 — The file is again built in a temporary directory; no path is printed, and the directory is deleted when the measurement ends.
"""Write side: unencodable characters and file open modes.""" import tempfile from pathlib import Path TEXT = "kuzey yamaç: 12,5 °C — ölçüm tamam" print(f"{'encoding':<12s}{'encoded':<10s}{'bytes':>6s}" f"{'round trip identical':>22s} broken class / position") for name in ("utf-8", "utf-16", "latin-1", "ascii", "cp1254"): try: raw = TEXT.encode(name) except UnicodeEncodeError as e: print(f" {name:<10s}{'no':<10s}{'-':>6s}{'-':>22s}" f" {type(e).__name__} / {e.start}") else: identical = "yes" if raw.decode(name) == TEXT else "no" print(f" {name:<10s}{'yes':<10s}{len(raw):>6d}{identical:>22s} -") print() with tempfile.TemporaryDirectory() as temp_dir: log_file = Path(temp_dir) / "measurement.log" print(f"{'mode':<6s}{'call':<8s}{'file bytes':>12s} result") for step, mode in (("1.", "w"), ("2.", "w"), ("3.", "a"), ("4.", "x")): try: with open(log_file, mode, encoding="utf-8") as f: f.write(TEXT + "\n") except FileExistsError as e: print(f" {mode:<4s}{step:<8s}{log_file.stat().st_size:>12d}" f" {type(e).__name__}") else: print(f" {mode:<4s}{step:<8s}{log_file.stat().st_size:>12d} written") with open(log_file, "r", encoding="utf-8") as f: lines = f.readlines() print(f"final state lines {len(lines)}, " f"characters {sum(len(s) for s in lines)}, " f"bytes {log_file.stat().st_size}")
encoding encoded bytes round trip identical broken class / position utf-8 yes 41 yes - utf-16 yes 70 yes - latin-1 no - - UnicodeEncodeError / 21 ascii no - - UnicodeEncodeError / 10 cp1254 yes 34 yes - mode call file bytes result w 1. 42 written w 2. 42 written a 3. 84 written x 4. 84 FileExistsError final state lines 2, characters 70, bytes 84
The cp1254 row is the counterpart of the read table and the lesson’s most instructive
pair. There, cp1254 silently misread utf-8 bytes; here, that same encoding writes
the text into 34 bytes, and the round trip comes back identical. Encoding is
not a property of a file; it is the agreement between the writer and the reader,
and the file itself does not carry that agreement. What was wrong was not cp1254, it
was mistaking bytes written with utf-8 for cp1254.
The write side parts ways with the read side at one point: latin-1 and ascii
break here. latin-1 breaks at the 21st character, at the em dash; ascii
breaks at the 10th character, at the first Turkish letter. When writing, a
character that cannot be encoded cannot silently pass through, because that character
has no counterpart in the encoding. When reading, a silent wrong answer was
possible, because every byte had some counterpart. utf-16 writes the text into 70
bytes — against utf-8’s 41 — and it too comes back identical; choosing an encoding
is a size decision as much as a correctness one.
The open-modes table sets apart three separate behaviors. "w" writes 42 bytes on
the first call; after the second "w" call, the file is still 42 — the mode
truncated the file and wrote it from scratch, the second record erased the first.
The "a" call brings it up to 84: append mode does not truncate. The "x" call
raises FileExistsError without writing anything, and the byte count stays at 84.
The last line says the file holds 2 lines, 70 characters, and 84 bytes; the
gap between character count and byte count stands here too.
Summary
- Binary mode gives
bytesand the length is the byte count; text mode givesstr, the length is the character count — the same file reads as 41 and 34. - A wrong encoding does not always break:
asciibreaks at the 10th byte,utf-16at the 40th, whilelatin-1andcp1254never break and silently produce 41 wrong characters. - Error modes prevent breaking, they do not recover the data:
ignoregives 28,replace41,backslashreplace80 characters, and none is identical to the source. - Decoding bytes chunk by chunk depends on the chunk boundary: the same 41 bytes break in 0, 2, or 4 chunks depending on size, and in 0 when decoded whole.
- An encoding error shows up not at the
opencall but at thereadcall; newline translation brings the length down from 21 to 18 but keeps the line count at 3. - Encoding is a property not of the file but of the writer and the reader:
cp1254gives an identical round trip writing in 34 bytes while silently corrupting utf-8 bytes on read; an unencodable character breaks at the 21st character inlatin-1when writing, the 10th inascii.
Next Step
Every file example in this lesson was opened inside a with block, and the file
closed when the block ended — the temporary directory could even be deleted at the end
of the measurement, because no open handle was left behind. with has been used as a
habit up to now; but it too is syntax, and there is a protocol underneath it. The next
lesson measures that protocol: which special method with calls on entry, which on
exit, and what changes when an exception occurs inside the block. The shared
setup’s with n + exception line’s three calls get paid off there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.