◈ python mapVol 2 · Ch 15/30
Volume 2 Python, from the metal up · chapter 15

15Files are syscalls — the file descriptor

In Volume 1 we lived entirely inside one running process. Every object sat at a real address in RAM, every name pointed straight at it, and the whole world was one warm, private room. In this chapter we cut a door in that wall, and here's the plan. A program that does anything useful has to reach past itself — to the disk, to other encodings, to other people's code, to future-you. And every one of those boundaries obeys a single law: what crosses a process boundary is always bytes, never objects. We start at the oldest boundary of all, the one between memory that forgets and memory that remembers. The whole way through, we keep asking the one thing that actually matters — if a file isn't a Python object, then what is it, and what really happens the instant you type open('song.txt')? By the end you'll see the answer with your own eyes: a "file" is a kernel thing you're only ever allowed to touch through a tiny integer. And open, read, write, close are four traps into the operating system, each wearing a friendly object as a disguise.

iolinked · chapter 15 — the checkpoints6 steps
$ sections covered in Files are syscalls — the file descriptor
01Why disk at all: the volatile / non-volatile split
02open() is a trap into the kernel
03The fd table, and why 0, 1, 2 are already taken
04read and write copy bytes across the ring boundary
05close() and the context manager that guarantees it
06Text mode is a codec layer bolted onto a byte stream

01Why disk at all: the volatile / non-volatile split

Let's start with a small cruelty you've lived with since your very first program. You probably never said it out loud: every script you have ever written forgot everything the instant it ended. That list you spent an hour building, a running total, a game's high score — all gone the moment the process exits, as if it had never existed. We tend to shrug it off. "That's just how programs work." But watch closely, because that shrug hides a real physical fact. Until you feel why memory forgets, open() looks like an arbitrary API. It is really something else: the only door out of a burning building.

So look at where your data actually lives. In Volume 1 every object sat in DRAM — dynamic random-access memory, the main memory of your machine. Here's what DRAM actually is under the name. It stores each single bit as a puff of electric charge on a microscopic capacitor, gated by one transistor. Engineers call it the 1T1C cell: one transistor and one capacitor per bit. And here's the fatal part. That capacitor is tiny, a few femtofarads, and it leaks. Charge drains through the transistor's imperfect off-state in a matter of milliseconds. Leave a stored 1 alone and it decays into an unreadable smear in well under a tenth of a second.

So DRAM cheats to stay alive. The memory controller marches through every row of every chip roughly every 64 milliseconds and performs a refresh. It reads each row and immediately writes it back, re-pouring charge into buckets that are always leaking. Your "static" variable is in fact a frantically re-topped-up puddle. This is not a metaphor. It is happening billions of times a second in the machine reading this sentence. Now cut the power. Refresh stops within a single cycle, and every capacitor drains to the noise floor. The bits are not "lost by the operating system" or "cleared on shutdown." They are physically gone, because the physics that held them needed a power supply, and that supply is gone. This is what the word volatile means, all the way down.

VOLATILE IS A PROPERTY OF THE HARDWARE, NOT THE SOFTWARE
Nothing Python does — not a global, not a module, not the cleverest cache — can make a value survive power loss while it sits in DRAM. The forgetting is soldered into the silicon. Persistence is a request you must make to different hardware entirely.

That different hardware exists, and it works by a completely different trick. It stores state in a form that survives with zero power. A NAND flash cell traps a clump of electrons on a floating gate — a scrap of conductor completely sealed behind an oxide insulator. Those trapped electrons shift the transistor's threshold voltage, and the oxide wall holds them in place for years with nothing plugged in. A spinning hard disk goes older still. It stores each bit as the magnetization direction of a grain of ferromagnetic material on a platter. A magnetized grain simply stays magnetized when the lights go out. Flash and disk are non-volatile. Pull the plug and they lose nothing.

This is the axiom the entire chapter — the entire volume — rests on. There is a hard line running through your computer's hardware. On one side sits fast volatile silicon that forgets the instant it is unpowered, and on the other, slower non-volatile media that remember without power. A "file" is nothing more grand than a byte that has crossed to the remembering side. That is the whole concept. The reason open() exists, the reason files exist as an idea, is simple: some hardware can hold a bit without being fed, and RAM cannot.

THE PERSISTENCE BOUNDARYVOLATILE — DRAM1T1C cell: one transistor, one capacitorcharge = 1leaks constantlyrefresh re-topsevery ~64 msPOWER OFFempty — no refreshbit = ?NON-VOLATILE — FLASH / DISKflash: charge sealed in a floating gatecontrol gatetrapped e-oxide wall = no leakdisk: a magnetized grain holds a directionN to S = 1POWER OFFunchangedbit = 1
Fig — A file is a byte that has crossed to the right side: DRAM forgets at power-off, flash and disk remember.

You can feel the split with three lines of code: write a program that counts, and run it twice:

counter.pypython
# counter.py — run me twice. I will always say 1.
count = 0
count = count + 1
print(count)            # 1 ... and 1 again on the next run, and forever

It restarts at zero every time because count lives in DRAM, and DRAM forgot it the microsecond the process died. Now change one thing. Read the previous value from a file at the top, and write the new one back at the bottom. Suddenly the count climbs across separate runs: 1, 2, 3, surviving the death of the program each time. Nothing about the arithmetic changed. What changed is that one number physically left volatile silicon and landed on the remembering side. That crossing is what the rest of this chapter takes apart, mechanism by mechanism.

Wait — if DRAM forgets in milliseconds, how does a computer even boot before the disk driver loads? It uses a different kind of memory: the firmware lives in flash-based ROM that is non-volatile, so there is always some remembering hardware awake to bootstrap the forgetful hardware. The volatile/non-volatile split isn't a Python detail — it's the seam your whole machine is built along.
THE ONE IDEA TO CARRY FORWARD
To outlive your process, a byte must physically leave volatile silicon and land on non-volatile media. A file is the name we give a byte that has made that crossing. Everything else in this chapter — descriptors, syscalls, buffering — is just the machinery for making the crossing safely.

So a byte has to reach non-volatile hardware. But your Python process is a plain user program with no wire to the disk — if it tried to command the drive directly, the CPU would fault. So how does one innocent line, open('song.txt'), reach a device you are forbidden to touch? →

02open() is a trap into the kernel

You now know a byte has to reach non-volatile hardware to survive. But sit with an uncomfortable fact: your Python process is not allowed to touch that hardware. Your code cannot issue a command to the SATA or NVMe controller, poke the disk's registers, or nudge the spinning platter. Suppose it tried, and the CPU, while running your bytecode, attempted the privileged instruction that talks to a device controller. The processor would raise a fault, and the operating system would kill your program on the spot. So we have a paradox. One line, open('song.txt'), clearly does reach the disk. How, if your code is forbidden from doing so directly?

The answer lives in the CPU itself, in a mechanism called privilege rings. The processor is always running at some privilege level. Your program, like every ordinary application, runs in ring 3, the unprivileged, fenced-in level. The operating system kernel runs in ring 0, with full authority over the machine. Certain instructions are privileged: talking to device controllers, remapping the page tables that define memory, touching I/O ports. Attempt any of them from ring 3 and the hardware itself slams the door. You get a fault, not a warning. This is enforced by the silicon, not by politeness. By design, a user process has no instruction that reaches the disk.

WHY THE RINGS EXIST AT ALL
If every program could command the disk and rewrite memory maps directly, one buggy line in one app could corrupt every other program and the OS with it. The ring boundary is a hardware-enforced wall that lets thousands of untrusted programs share one machine without being able to wreck each other. Your "you can't touch the disk" is really "you can't touch anyone else's disk data either."

So how do you ever get anything done? Through the one sanctioned door in the wall: a syscall. A syscall is a special CPU instruction — literally syscall on x86-64, svc on ARM64 — that does something no ordinary instruction can. It atomically switches the CPU into ring 0 and jumps to a single fixed entry point inside the kernel. The hardware was pre-told about that entry point at boot. You don't get to choose where in the kernel you land. You get exactly one guarded gate. Which service you're asking for is selected by a number you place in a register beforehand. On Linux x86-64, register rax holds the syscall number (openat is number 257). This controlled, one-way crossing is called a trap. The program traps into the kernel, the kernel does the privileged work on your behalf, and control traps back out to ring 3.

USER SPACE — ring 3 (your process)fd = open('song.txt')no user instruction reaches thedisk — the gate is the only way.GUARDEDGATEsyscall instruction — CPU flips to ring 0fd = 3KERNEL SPACE — ring 0 (privileged)VFSfilesystemdriverblock devicedriverdiskcontrollerkernel returns the lowest free fd number to the caller
Fig — open() is a controlled trap: user code flips to ring 0, the kernel does the disk work, and only a small integer comes back.

Follow one open through the gate. Your ring-3 code executes the syscall instruction, and the CPU flips to ring 0 and lands at the kernel entry point, now running with full privilege. It walks the filesystem to find song.txt, talks through the block-device driver to the actual disk controller, and sets up its bookkeeping for this newly opened file. Then it hands back a single small non-negative integer. The CPU flips back to ring 3, and your program resumes with that integer in hand. That integer is the file descriptor — the kernel's receipt, the handle you will use for every later read and write. It is not the file, but a claim ticket the kernel gave you.

CPython's friendly open() is many layers of Python and C sitting on top of exactly this syscall. To see the raw thing with nothing dressed on it, use os.open. That thin wrapper does little more than the trap and hands you the number back:

raw_open.pypython
import os
fd = os.open("song.txt", os.O_RDONLY)
print(fd)          # 3 — a bare integer. Not an object. The kernel's receipt.

There it is: 3. Not a file object, not a path, not a stream — an int. That is the correction this section exists to make. open() is not "Python opening a file." It is Python asking the kernel, across a hardware-enforced mode switch, to open a file on Python's behalf and to hand back a handle. The friendly f = open(...) object you're used to has that same integer buried inside it; call f.fileno() and it will show you the very number the kernel returned. Everything else is wrapping paper around a receipt.

SYNTAX · with open(…) — six modes, six promisesthe line you will type a thousand times, and what each letter actually commits to
with open(path, "r", encoding="utf-8") as f: — read text. The file must already exist. data = f.read() with open(path, "w", encoding="utf-8") as f: — write text. TRUNCATES to empty first. f.write(text) with open(path, "a", encoding="utf-8") as f: — append. Every write lands at the end. f.write(line) with open(path, "x", encoding="utf-8") as f: — exclusive create. Refuses to clobber. f.write(text) with open(path, "rb") as f: — the b suffix: bytes. No codec, no blob = f.read()   newline translation, nothing between   you and the descriptor. with open(path, "r+", encoding="utf-8") as f: — the + suffix: read AND write, no truncate
"r"Read, and the file must exist. A missing path raises FileNotFoundError before your first line of logic runs. This is the defaultopen(path) means open(path, "r").
"w"Create it if absent; if present, truncate it to zero bytes immediately. The destruction happens at open time, not at write time — open a file for writing and its old contents are already gone.
"a"Append. The kernel forces every write to the current end of file, so nothing already there can be overwritten. Even seek(0) cannot pull a write back to the front.
"x"Exclusive create: succeed only if the file does not exist, else FileExistsError. This is the atomic "don't clobber my backup" mode, decided inside the kernel with no race window.
the b suffixDrop the TextIOWrapper. You get bytes in and bytes out, exactly what the descriptor carries. No encoding= is allowed, because there is no codec to configure.
the + suffixAdd the missing half: "r+" is read-write with no truncation, "w+" truncates first, "a+" reads but still appends. One descriptor, one shared position.
withNot politeness. with is what fires __exit__ — flush the buffer, hand the fd slot back — on every way out of the block, including the exception you did not plan for.
you type
# modes.py -- one call, six contracts. The mode letter is the whole promise.
from pathlib import Path

def show(path):                                  # read helper, written the safe way
    with open(path, "r", encoding="utf-8") as f:
        return repr(f.read())

Path("shelf.txt").unlink(missing_ok=True)        # start from an empty shelf

# 'w' - create if absent, TRUNCATE to zero if present, then write
with open("shelf.txt", "w", encoding="utf-8") as f:
    f.write("Blinding Lights\n")
print("after 'w' :", show("shelf.txt"))

# 'a' - append: every write lands at the end, nothing already there is touched
with open("shelf.txt", "a", encoding="utf-8") as f:
    f.write("Titanium\n")
print("after 'a' :", show("shelf.txt"))

# 'w' a second time - the truncation everyone forgets, felt once
with open("shelf.txt", "w", encoding="utf-8") as f:
    f.write("Levels\n")
print("after 'w' :", show("shelf.txt"), "<- the first two songs are gone")

# 'x' - exclusive create: refuses to clobber a file that already exists
try:
    with open("shelf.txt", "x", encoding="utf-8") as f:
        f.write("never gets here")
except FileExistsError as e:
    print("mode 'x'  :", type(e).__name__, "- it will not overwrite an existing file")

# 'r' - read, and the file must already exist
try:
    with open("missing.txt", "r", encoding="utf-8") as f:
        f.read()
except FileNotFoundError as e:
    print("mode 'r'  :", type(e).__name__, "- errno", e.errno)

# 'b' suffix - no codec, no newline translation. Bytes in, bytes out.
with open("cover.bin", "wb") as f:
    n = f.write(b"ID3\x03\x00")
with open("cover.bin", "rb") as f:
    print("mode 'wb' :", n, "bytes written | mode 'rb' :", f.read())

# '+' suffix - read AND write on one descriptor, without truncating
with open("shelf.txt", "r+", encoding="utf-8") as f:
    print("mode 'r+' :", repr(f.read()), "read first...")
    f.write("bad guy\n")                         # ...then write from where reading stopped
print("after 'r+':", show("shelf.txt"))
you see
after 'w' : 'Blinding Lights\n'
after 'a' : 'Blinding Lights\nTitanium\n'
after 'w' : 'Levels\n' <- the first two songs are gone
mode 'x'  : FileExistsError - it will not overwrite an existing file
mode 'r'  : FileNotFoundError - errno 2
mode 'wb' : 5 bytes written | mode 'rb' : b'ID3\x03\x00'
mode 'r+' : 'Levels\n' read first...
after 'r+': 'Levels\nbad guy\n'
where beginners trip
  • "w" truncates at open time. Open a file for writing and then crash before writing anything, and you have replaced it with an empty file.
  • The mode letters have no fixed order — "rb" and "br" are the same mode. Pick one spelling and stay with it.
  • Calling f.write() on a handle opened "r" does not fail silently; it raises io.UnsupportedOperation: not writable. The mode is enforced, not advisory.
  • In text mode encoding= is optional and that is the trap — leave it out and Python guesses from the platform. Write it every single time.
  • Text mode and "b" mode are the same descriptor with a different number of layers above it. That is section 06, and it is the whole difference between str and bytes.
Wait — if a syscall is just a numbered request, could you open a file with no import and no open at all — by invoking the syscall directly? On Linux, yes: os.system or ctypes can fire syscall 257 by hand and you'll get back your 3. The friendly open isn't doing anything magical Python can't do nakedly — it's just the polite front desk for a trap you're always free to walk up to yourself.

The kernel handed you the integer 3 — never 0, 1, or 2. That's not luck and it's not random. Why does counting start at 3, and where does the number even live? →

SYNTAX · pathlib — a path is an object, not glued-together stringthe modern spelling: the / operator, the four name parts, and two one-call round trips
from pathlib import Path p = Path("data") / "songs.txt" — / joins. You never type a separator. p.parent p.name p.stem p.suffix — the four parts of a name p.exists() p.is_file() p.is_dir() — ask before you assume p.parent.mkdir(parents=True, exist_ok=True) — make the folder, idempotently p.write_text(text, encoding="utf-8") — open + write + close, one call p.read_text(encoding="utf-8") — open + read + close, one call p.write_bytes(blob) p.read_bytes() — the same pair, with no codec for child in p.parent.iterdir(): — everything in that directory p.parent.glob("*.txt") — everything matching a pattern os.path.join("data", "songs.txt") — the older spelling, still everywhere
Path(…)A path object, not a string. It knows its own parts, can test itself against the filesystem, and prints with the separator your OS uses.
the / operatorPath("data") / "songs.txt" is __truediv__ from chapter 12, put to work. No "data" + "/" + name, so no doubled or missing separators, ever.
the four parts.parent is the folder, .name the full songs.txt, .stem the songs, .suffix the .txt. Chapter 2's string surgery, already done for you.
read_text / write_textThe whole-file shortcut: it opens, moves the bytes, and closes, in one call. Perfect for small files. It gives you no handle, so there is nothing to stream and nothing to leak.
read_bytes / write_bytesThe same two calls with the codec removed. Use them the moment the payload is not text — an image, a zip, a backup copy you must not touch.
iterdir / globiterdir() yields every entry in a directory; glob("*.txt") yields the ones matching a pattern. Both are generators (chapter 10), so a huge folder does not become a huge list.
os.pathThe 1990s spelling: strings in, strings out. It still works, it is still in half the code you will read, and open() accepts either. Read it fluently; write Path.
you type
# paths.py -- a path is an object, not a string you glue together
from pathlib import Path
import os

songs = Path("data") / "songs.txt"               # the / operator joins, OS-correctly
print("path      :", songs)
print("parts     :", songs.parent, "|", songs.name, "|", songs.stem, "|", songs.suffix)

songs.parent.mkdir(parents=True, exist_ok=True)  # make data/ if it isn't there yet
print("exists?   :", songs.exists(), "before writing")

songs.write_text("Blinding Lights\nTitanium\n", encoding="utf-8")   # open+write+close
print("exists?   :", songs.exists(), "| is_file:", songs.is_file())
print("read_text :", repr(songs.read_text(encoding="utf-8")))

(songs.parent / "cover.bin").write_bytes(b"ID3\x03\x00")            # the bytes twin
print("read_bytes:", (songs.parent / "cover.bin").read_bytes())

for p in sorted(songs.parent.iterdir()):         # what is in this directory
    print("  entry   :", p.name, "| suffix", p.suffix or "(none)")
print("glob *.txt:", [p.name for p in sorted(songs.parent.glob("*.txt"))])

# the old spelling, for when you read someone else's code
legacy = os.path.join("data", "songs.txt")
print("os.path   :", legacy, "| same file:", os.path.exists(legacy))

with open(songs, "r", encoding="utf-8") as f:    # open() accepts a Path directly
    print("interop   :", f.readline().strip(), "<- open() takes a Path, no str() needed")
you see
path      : data\songs.txt
parts     : data | songs.txt | songs | .txt
exists?   : False before writing
exists?   : True | is_file: True
read_text : 'Blinding Lights\nTitanium\n'
read_bytes: b'ID3\x03\x00'
  entry   : cover.bin | suffix .bin
  entry   : songs.txt | suffix .txt
glob *.txt: ['songs.txt']
os.path   : data\songs.txt | same file: True
interop   : Blinding Lights <- open() takes a Path, no str() needed
where beginners trip
  • Path("data") / "songs.txt" only works because the left side is a Path. "data" / Path("songs.txt") works too, but "a" / "b" is a TypeError.
  • This capture ran on Windows, so the path printed as data\songs.txt. On Linux and macOS the identical program prints data/songs.txtthat is what the object buys you.
  • mkdir() without exist_ok=True raises FileExistsError on the second run; without parents=True it refuses to create a missing grandparent.
  • p.exists() is a snapshot, not a lock. Between the check and your open() another process can delete the file. When it matters, use mode "x" or just try and catch.
  • write_text takes encoding= too, and needs it just as much. The one-call convenience does not exempt you from naming the codec.

03The fd table, and why 0, 1, 2 are already taken

Open your first file and you get descriptor 3. Open one in a brand-new program tomorrow, and it's still 3. Never 0, never 1, never 2. That is astonishingly consistent for something that looks arbitrary. Consistency is always a clue that a structure is doing the choosing, and there is one here. And if you don't understand the array these integers index into, then leaks, reuse, and "why did I just read the wrong file?" will confuse you for the rest of your career. So let's make the descriptor concrete.

Every process has its own private file-descriptor table — an array living inside the kernel, hanging off the kernel's bookkeeping struct for your process. It is indexed by the descriptor integer. Entry i doesn't hold the file itself. It holds a pointer to a kernel open-file description — a small record of the current read/write offset, the flags you opened with, and a link to the underlying file on disk (its inode). The descriptor you hold in Python is just the index. All the real state lives on the kernel's side of the wall. This is why the descriptor is a mere integer. It is an array subscript, and the array isn't yours to see.

THE ONE IDEA TO CARRY FORWARD
A file descriptor is not a token or a pointer to your file. It is an index into a small private array the kernel keeps for your process. The number is yours; the thing it points at is the kernel's. Once you see it as an array subscript, every strange behaviour — reuse, leaks, the ceiling — stops being mysterious.

Now the mystery of 0, 1, 2. When the shell (or whatever launched you) starts your process, it does not hand you an empty table. It pre-wires the first three slots. Index 0 is standard input, index 1 is standard output, and index 2 is standard error. Each already points at your terminal, or wherever they were redirected. This is why print reaches your screen although you never opened anything. print ultimately writes to descriptor 1, which was bound to your terminal before your first line ran. You inherited a working output before you did anything at all.

And here is the rule that ties it together: open always installs the new file at the lowest free index in the table. With 0, 1, and 2 already occupied, your first open can only land at 3. The next at 4. The next at 5. There is no randomness — it is just "smallest empty slot," every time.

the_table.pypython
import os, sys
print(sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno())  # 0 1 2
a = os.open("song.txt", os.O_RDONLY)
b = os.open("song.txt", os.O_RDONLY)
print(a, b)      # 3 4 — the two lowest free slots, in order
PROCESS fd TABLE (private to this PID)[0]stdin → terminal[1]stdout → terminal[2]stderr → terminal[3]open file descr. → inode[4]empty[5]emptyinode'song.txt'open('song.txt')lowest freeindex → 3capacity =ulimit -n(e.g. 1024)same number, different table:PID 100[3]a.logPID 205[3]b.pngfds are not global.
Fig — An fd is just the lowest free index into a table your process owns alone; two programs both use 3 for unrelated files.

Two consequences fall straight out of "it's a private, finite array." First, the table is per-process. Your descriptor 3 and another program's descriptor 3 index two entirely different arrays, pointing at two entirely different files. Descriptors are not global names. A 3 only means anything inside the process that owns the table. Second, the array is finite. Its capacity is capped by a limit named RLIMIT_NOFILE, the thing your shell's ulimit -n reports, often 1024 by default. You may hold only that many open descriptors at once.

That ceiling is not theoretical. Open files in a loop and never close them, and you watch the descriptors climb: 3, 4, 5, …, 1021, 1022, 1023. Then the program dies with OSError: [Errno 24] Too many open files. That is the finite table felt as a crash, and a leaked descriptor is a slot you claimed and never gave back.

The flip side of "lowest free index" is reuse. Close descriptor 3 and its slot goes empty again, so the very next open hands you 3 back, not 5. This is the source of one of the nastiest bugs in systems programming. You close a file, keep a copy of the old number 3 lying around, and later something else opens a different file that also gets 3. Now your stale 3 silently reads the wrong file. The kernel is perfectly happy to comply, because to it your 3 is a valid index into a valid slot. The number was reclaimed. Your memory of what it meant was not.

⚠ MOST BEGINNERS THINK…one file, opened twice — and two cursors come back
You just watched the_table.py open one path twice and get back 3 and 4. Two slots, fine. But surely both slots lead to the same open file — so whatever you read through one, the other has moved past too, the way two names can point at one list back in chapter 3. Two tickets to one coat.
TYPE THIS — 10 SECONDS
>>> from pathlib import Path
>>> Path("song.txt").write_text("My Favorite Song", encoding="utf-8")
>>> a = open("song.txt", encoding="utf-8")   # the same file...
>>> b = open("song.txt", encoding="utf-8")   # ...opened a second time
>>> a.read()                                 # a drains the whole file
>>> b.read()                                 # and b? untouched by that
16
'My Favorite Song'
'My Favorite Song'
If both slots led to one open file, b would have come back empty, because a had already read to the end. It came back full — so each open() call built its own record, carrying its own position. That is exactly the record this section opened with: the offset, the flags, the link to the file on disk. One thing on the platter; two independent tickets to it, each remembering where it is standing. One refinement, so you do not over-learn this: having two descriptors is not what makes two positions. os.dup(a.fileno()) hands you a second number that steers the same record — read through either and both move — so it is open() that builds a new position, never the descriptor by itself.
Wait — if descriptor 1 is just an index bound to your terminal, can you make print write to a file without changing a single print call? Yes — that's exactly what shell redirection (python app.py > out.txt) does. The shell rewires slot 1 to a file before your program starts. print still innocently writes to descriptor 1; it just has no idea the slot now leads to disk instead of glass.

You hold descriptor 3. Now you actually want the file's bytes inside a Python variable — but your memory and the disk sit on opposite sides of the ring wall. How do bytes physically cross, and does the kernel have any idea what they mean? →

04read and write copy bytes across the ring boundary

You hold descriptor 3. You want two things: the file's contents pulled into a Python variable, and your own bytes pushed out onto the disk. But recall where these two live. Your process's memory sits in ring-3 user space; the disk and the kernel's buffers sit behind the ring-0 wall. They are different memory that the CPU deliberately keeps apart. So the real question of this section is physical: when data moves between your variable and the file, what actually happens to it? And a quieter unease sits underneath — when you write the string "My Favorite Song", does the kernel understand it's a song title?

Both movements are syscalls, each a trap into ring 0 exactly like open was. read(fd, n) asks the kernel to copy up to n bytes from its buffers for that file into a buffer in your process's address space. write(fd, buf) copies the other direction. It moves bytes from your memory into the kernel's buffers, and the kernel later flushes them to the physical device. Notice the load-bearing word in both sentences: copy. Your user pages and the kernel's buffers are separate memory the CPU won't let mix. So the kernel must physically move the bytes across the boundary — a real memcpy, plus the cost of the mode switch. That double cost is a trap and a copy on every call. It is the entire reason I/O is expensive, and the entire reason we buffer instead of doing a syscall per byte.

SYNTAX · reading — four ways in, and the position that moves under you.read() · .read(n) · .readline()/.readlines() · and the loop you should actually write
with open(path, "r", encoding="utf-8") as f: whole = f.read() — the WHOLE file as one str. Small files only. chunk = f.read(4096) — at most n characters, from where you are now line = f.readline() — one line — and the trailing newline comes too lines = f.readlines() — a list of lines, every newline still attached with open(path, "r", encoding="utf-8") as f: for line in f: — THE idiomatic loop: lazy, one line at a time, use(line.rstrip("\n"))   flat memory whatever the file weighs f.tell() — where the position sits right now f.seek(0) — put it back at the start text.splitlines() — lines with the newlines already gone
.read()Everything, as a single str. Simple, and a loaded gun on a large file — a 4 GB log becomes a 4 GB string in RAM, and chapter 11 already showed you what that costs.
.read(n)At most n characters, starting from the current position. The value you got back moved the position, which is why the second call continues instead of repeating.
.readline()One line, newline included. Returns "" — empty string, not None — at end of file, which is how you know you are done.
.readlines()Every remaining line, as a list. Same memory warning as .read(), plus one list object per line. Use it when you genuinely want the whole list.
for line in fThe file object is its own iterator (chapter 10). Python asks it for the next line and it fetches from its buffer, doing a real read syscall only when that buffer runs dry.
.rstrip("\n")The newline is genuinely part of the string. .rstrip("\n") removes only that; bare .strip() also eats leading and trailing spaces, which is sometimes the bug.
seek / tellThe position is state living in the open-file entry the kernel made for you in section 02. seek(0) rewinds it; nothing about the file on disk changes.
you type
# reading.py -- one file, four ways in, and the position that moves under you
from pathlib import Path

Path("playlist.txt").write_text(
    "Blinding Lights\nTitanium\nLevels\nbad guy\n", encoding="utf-8")

# 1 - .read() : the whole file as one str. Fine for small files.
with open("playlist.txt", "r", encoding="utf-8") as f:
    whole = f.read()
print("read()      :", repr(whole))
print("             ", len(whole), "characters, newlines and all")

# 2 - .read(n) : at most n characters, from wherever the position is now
with open("playlist.txt", "r", encoding="utf-8") as f:
    print("read(8)     :", repr(f.read(8)))
    print("read(8) x2  :", repr(f.read(8)), "<- it carried on from where it stopped")

# 3 - .readline() / .readlines() : the newline comes along for the ride
with open("playlist.txt", "r", encoding="utf-8") as f:
    print("readline()  :", repr(f.readline()))
    print("readlines() :", f.readlines(), "<- the rest, as a list")

# 4 - iterate the file object : THE idiomatic loop. One line at a time, lazily.
with open("playlist.txt", "r", encoding="utf-8") as f:
    for n, line in enumerate(f, start=1):
        clean = line.rstrip("\n")              # the newline is IN the string until you strip it
        print("  line", n, "  :", repr(line), "->", repr(clean))

# the trap: the position sits at the end now, so a second read gives you nothing
with open("playlist.txt", "r", encoding="utf-8") as f:
    first = f.read()
    second = f.read()
print("second read :", repr(second), "<- empty. The file is fine; you are at the end.")

with open("playlist.txt", "r", encoding="utf-8") as f:
    f.read()
    f.seek(0)                                  # rewind the position by hand
    print("after seek(0):", repr(f.read(15)))

# .splitlines() : the same lines with the newlines already gone
print("splitlines():", whole.splitlines())
you see
read()      : 'Blinding Lights\nTitanium\nLevels\nbad guy\n'
              40 characters, newlines and all
read(8)     : 'Blinding'
read(8) x2  : ' Lights\n' <- it carried on from where it stopped
readline()  : 'Blinding Lights\n'
readlines() : ['Titanium\n', 'Levels\n', 'bad guy\n'] <- the rest, as a list
  line 1   : 'Blinding Lights\n' -> 'Blinding Lights'
  line 2   : 'Titanium\n' -> 'Titanium'
  line 3   : 'Levels\n' -> 'Levels'
  line 4   : 'bad guy\n' -> 'bad guy'
second read : '' <- empty. The file is fine; you are at the end.
after seek(0): 'Blinding Lights'
splitlines(): ['Blinding Lights', 'Titanium', 'Levels', 'bad guy']
where beginners trip
  • Read once and the position is at the end; a second .read() returns ''. The file is fine — you moved. f.seek(0) puts it back.
  • The newline is inside the string, so line == "Titanium" is False while line == "Titanium\n" is True. This is the single most common file bug there is.
  • for line in f does not read the file line by line off the disk. It reads it in buffered blocks and hands you lines — the syscall tax figure above is exactly why.
  • In text mode read(n) counts characters, not bytes. One emoji is one character and four bytes, so read(1) can move the byte position by four.
  • .readlines() keeps the newlines; text.splitlines() drops them. Reach for the one whose output you will not immediately have to clean up.
THIS IS WHY BUFFERING EXISTS
If every character cost you a trap into ring 0 and a byte-copy across the wall, printing a paragraph would fire hundreds of syscalls. So Python (and the kernel) gather bytes into a buffer and cross the boundary once for the whole batch. Buffering isn't an optimization detail — it's the direct response to "crossing the ring wall is not free."
Interactive · the syscall taxdrag: bytes moved per read() call
read() a 1 MiB file — each call is one round tripacross the ring-0 wall. How big a bite does each take? 64 KiB per read() TRIPS THROUGH THE ring-0 GATE 16 TIME JUST CROSSING · ~1 µs / trip ~16 µs ≈ the file in 16 easy gulps SYSCALL COST — log scale, longer & redder = more crossings one gulp (cheap) 1,048,576 crossings (dear) 64 KiB per read() → 16 trips across the ring-0 wall — practically free. Python's BufferedReader defaults to ~8 KiB per real read — parked near the cheap end, on purpose.
64 KiB
Drag left toward 1 byte. The file never changes — it is 1 MiB the whole time — yet the cost explodes from 16 trips across the wall to 1,048,576. Each trip is a trap into ring 0 and a copy back, ~1 µs of pure overhead, so a byte-at-a-time read spends about a full second doing nothing but crossing a wall it could have crossed 16 times. That gap — ~65,000× — is the whole reason buffering exists.
Fig — One number sets the toll. 1 byte per read() = 1,048,576 crossings (~1 s); 64 KiB = 16 crossings (~16 µs) — identical bytes, ~65,000× the cost.

Now the crucial part, the one that reframes what a file is. The payload is always raw bytes — an array of integers from 0 to 255, nothing more. The kernel has zero notion that your bytes spell a song title, or encode a JSON object, or form a JPEG. When you write b"My Favorite Song", the kernel sees the sequence 77, 121, 32, 70, 97, 118, … and faithfully moves those numbers. It does not see "My Fav…". It cannot. Meaning is not something the kernel stores or checks. Meaning is imposed entirely by your program, above the wall. The file on disk is just a byte sequence. The fact that it "is" a song title or a picture is an agreement between the program that wrote it and the program that reads it. The kernel is a neutral courier of opaque numbers.

USER SPACE — your process memory (buffer)77121327097118...'M' 'y' ' ' 'F' 'a' 'v' — you see the textTRAP GATEwrite(3, buf)read(3, n)KERNEL — page-cache buffers771213270I see: 77, 121, 32, 70, 97, 118 ...I do NOT see: "My Fav..."each arrow = a mode switch AND a memory copy
Fig — read and write shuttle raw integers across the boundary; the kernel never knows they spell text.
SYNTAX · writing — three ways out, and the newline nobody adds for you.write() returns a count · .writelines() adds nothing · print(file=f) adds it for you
with open(path, "w", encoding="utf-8") as f: n = f.write(text) — returns a COUNT. Adds nothing you did not type. f.write(title + "\n") — the newline is YOUR job f.writelines(rows) — writes them all — and STILL adds no newlines print(title, file=f) — the one call that DOES add the newline f.write("\n".join(rows) + "\n") — one join, one write: the fast spelling "w" — truncates first: whatever was there is gone "a" — appends: every write is forced to the end
.write(s)Returns the number written — characters in text mode, bytes in binary. It is the same count the write syscall returns, surfacing one layer up.
the newlineNothing adds it for you. Two .write() calls with no "\n" produce one run-on line, and the file looks corrupt when it is merely faithful.
.writelines(seq)A misleading name: it writes the strings in the sequence and adds no line endings at all. It is exactly for s in seq: f.write(s), no more.
print(…, file=f)The same print from chapter 1, aimed at a file instead of stdout. It brings its sep and its end="\n" along, which is why the newline appears.
join then writeOne string built in RAM, one call out. Fewer trips through the buffering machinery than a write per row — the same lesson as the syscall-tax figure, one layer higher.
"w" vs "a""w" starts from an empty file every run; "a" starts from whatever last time left. A log wants "a". A snapshot wants "w".
you type
# writing.py -- four ways out, and the newline nobody adds for you
from pathlib import Path

Path("out.txt").unlink(missing_ok=True)

def show(path):
    with open(path, "r", encoding="utf-8") as f:
        return repr(f.read())

# 1 - .write(s) : returns a COUNT, and adds nothing you did not type
with open("out.txt", "w", encoding="utf-8") as f:
    n1 = f.write("Blinding Lights")
    n2 = f.write("Titanium")
print("write() ->", n1, n2, "characters |", show("out.txt"), "<- one run-on line")

# 2 - the fix: put the newline in yourself
with open("out.txt", "w", encoding="utf-8") as f:
    f.write("Blinding Lights\n")
    f.write("Titanium\n")
print("with \\n  ->", show("out.txt"))

# 3 - .writelines(seq) : writes them all, and STILL adds no newlines
rows = ["Levels", "bad guy"]
with open("out.txt", "w", encoding="utf-8") as f:
    f.writelines(rows)
print("writelines->", show("out.txt"), "<- the trap: no separators at all")

with open("out.txt", "w", encoding="utf-8") as f:
    f.writelines(line + "\n" for line in rows)     # you supply the newlines
print("writelines->", show("out.txt"))

# 4 - print(..., file=f) : the one call that DOES add the newline
with open("out.txt", "w", encoding="utf-8") as f:
    for title in ("Levels", "bad guy"):
        print(title, file=f)
print("print(file=)->", show("out.txt"))

# 5 - one join, one write: the fastest spelling for a list you already have
with open("out.txt", "w", encoding="utf-8") as f:
    f.write("\n".join(rows) + "\n")
print("join+write ->", show("out.txt"))

# 'w' TRUNCATES; 'a' APPENDS. The same two lines, two different files.
with open("out.txt", "w", encoding="utf-8") as f:
    f.write("Titanium\n")
print("'w' again  ->", show("out.txt"), "<- everything before it is gone")

with open("out.txt", "a", encoding="utf-8") as f:
    f.write("Levels\n")
print("'a'        ->", show("out.txt"), "<- added at the end instead")
you see
write() -> 15 8 characters | 'Blinding LightsTitanium' <- one run-on line
with \n  -> 'Blinding Lights\nTitanium\n'
writelines-> 'Levelsbad guy' <- the trap: no separators at all
writelines-> 'Levels\nbad guy\n'
print(file=)-> 'Levels\nbad guy\n'
join+write -> 'Levels\nbad guy\n'
'w' again  -> 'Titanium\n' <- everything before it is gone
'a'        -> 'Titanium\nLevels\n' <- added at the end instead
where beginners trip
  • .writelines() is the one whose name lies. It does not write lines; it writes strings, back to back. You supply the "\n".
  • f.write() returning 15 is not a success flag — it is a count, exactly like the write syscall you just watched return 16.
  • Opening with "w" to "update" a file deletes it first. To change part of a file you read it, edit in memory, and write it back — or open "r+" and seek.
  • In "a" mode seek() cannot save you: the kernel repositions to the end before every write, so the data still lands at the bottom.
  • Your bytes are in a buffer, not on the platter, until close or flush. That gap is real, it can lose data, and it is the whole of chapter 16.

There is one more truth the friendly API hides, and it bites everyone eventually. These syscalls return a count. write returns how many bytes the kernel actually accepted; read returns how many it actually delivered — and that can be fewer than you asked for. Ask read for 4096 bytes near the end of a file and you'll get only the handful that remain. Read from a pipe or a socket and you may get a trickle. This is called a short read (and its sibling, the short write), and assuming a single call moved everything is a classic, painful bug.

bytes_across.pypython
import os
fd = os.open("song.txt", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
n = os.write(fd, b"My Favorite Song")
print(n)              # 16 — the count of bytes accepted, not True, not None
os.close(fd)

fd = os.open("song.txt", os.O_RDONLY)
print(os.read(fd, 4))    # b'My F' — only 4 bytes; a deliberate short read

Look at what write handed back: 16, not True. It is telling you it moved sixteen bytes. You're expected to check that, because in the general case it might have moved fewer, and you'd need to loop and send the rest. And read(fd, 4) gave you b'My F' — four raw bytes, prefixed b for bytes, because that is genuinely all the kernel deals in. Your program is the thing that decides those four numbers are letters.

✗ The myth

read gets my text and write saves my text; the file understands what I stored in it.

✓ The reality

read and write are byte-copy syscalls across a hardware wall, each returning a count. The kernel moves opaque numbers and understands none of it. "Text," "JSON," "image" are meanings your program imposes above the wall — the file is just bytes that agreed to remember.

Wait — if the kernel only moves opaque bytes, how does your operating system "know" a file is a photo and show a thumbnail? It doesn't — a program above the kernel (your file browser) peeks at the first few bytes, sees a known signature like the PNG header, and decides to treat it as an image. That guess is called a magic number, and it lives entirely in user space. The kernel never looked.

You saw a leak crash a program by never closing. So closing matters — but you can't just add a close() at the end and relax, because what if the line before it raises? →

TYPE THIS · save it, then run itwrite_then_read.py — one file out, the same file back in three ways
# write_then_read.py -- one file out, the same file back in three ways
from pathlib import Path

TRACKS = ["Blinding Lights", "Titanium", "Levels", "bad guy"]
PATH = Path("playlist.txt")

# ---------- out: one with-block, one line per track ----------
with open(PATH, "w", encoding="utf-8") as f:
    for title in TRACKS:
        f.write(title + "\n")                  # you type the newline; write never adds it
print("wrote", len(TRACKS), "tracks to", PATH.name)
print()

# ---------- way 1: .read() -- the whole file as ONE string ----------
with open(PATH, "r", encoding="utf-8") as f:
    blob = f.read()
print("1) read()       ->", repr(blob))
print("   type/len     ->", type(blob).__name__, len(blob), "characters")
print()

# ---------- way 2: .readlines() -- a LIST of strings, newlines kept ----------
with open(PATH, "r", encoding="utf-8") as f:
    lines = f.readlines()
print("2) readlines()  ->", lines)
print("   stripped     ->", [ln.rstrip("\n") for ln in lines])
print()

# ---------- way 3: iterate the file object -- the idiomatic loop ----------
print("3) for line in f:")
with open(PATH, "r", encoding="utf-8") as f:
    for n, line in enumerate(f, start=1):
        print(f"     {n}. {line.rstrip()}")
print()

# the file object is closed on the way out of every block above
print("closed after the with-block:", f.closed)
print("same bytes, three shapes:", repr(blob) == repr("".join(lines)))
wrote 4 tracks to playlist.txt

1) read()       -> 'Blinding Lights\nTitanium\nLevels\nbad guy\n'
   type/len     -> str 40 characters

2) readlines()  -> ['Blinding Lights\n', 'Titanium\n', 'Levels\n', 'bad guy\n']
   stripped     -> ['Blinding Lights', 'Titanium', 'Levels', 'bad guy']

3) for line in f:
     1. Blinding Lights
     2. Titanium
     3. Levels
     4. bad guy

closed after the with-block: True
same bytes, three shapes: True
Save it as write_then_read.py and run it. This is the whole round trip in one screen: four strings leave your process, land on hardware that remembers, and come back as three different Python shapes. Notice what the writing half does not do — f.write(title) would give you one run-on line, so the + "\n" is not decoration, it is the record separator you are choosing. Now the reading half, the same bytes read three ways. .read() hands back one str of 40 characters with the newlines living inside it. .readlines() hands back a list of four strings, each still carrying its newline — which is why the stripped version is printed next to it, so you can see exactly what .rstrip() removed. And for line in f hands you one line at a time and never holds more than a buffer, which is the only one of the three you can safely point at a four-gigabyte log. The last two lines are the proof: f.closed is True because __exit__ ran, and joining the list reconstructs the blob character for character. Same bytes, three shapes, one file. Two things to try. Delete the + "\n" and run it again — watch all three readers agree that the file is now a single 36-character line. Then swap way 3 for for line in f.readlines(): and notice it still prints the same thing, while quietly loading the entire file into RAM first. That difference is invisible today and fatal at scale.

05close() and the context manager that guarantees it

The last section left you with a warning felt as a crash. Leak descriptors and you eventually slam into the finite table and die, so closing matters. The naive fix is obvious: put a close() at the end and move on. But that fix has a hole you can drive a truck through, and every teacher who ever nagged you about with open(...) was quietly pointing at it. What if a line between the open and the close raises an exception? Then execution leaps over your close(), and the descriptor leaks. Worse, the bytes you thought you'd saved may never reach the disk at all. This section is about making cleanup a guarantee instead of a hope.

First, what close(fd) actually does. It is itself a syscall. It returns your descriptor's slot to the process's fd table, freeing that index for reuse — exactly the reclamation we saw in section 3. It also tells the kernel you're finished with this open-file description. So the kernel can drop its reference, and when the last reference falls, release the underlying bookkeeping. Two separate things depend on close running: the finite descriptor table (unclosed descriptors leak until ulimit stops you) and durability. That second one is subtle. Your writes usually don't go straight to the platter. They sit in the kernel's buffers, and above that, in Python's own userspace buffer. Closing flushes Python's buffer down through the write syscall and hands clean state to the kernel. Skip the close and your final writes may still be sitting in a buffer that dies with the process.

CLOSE FLUSHES TO THE KERNEL — NOT TO THE PLATTER
There is a layer even below close. close pushes Python's buffer into the kernel's buffers, but the kernel may still hold your bytes in memory and write them to the physical disk seconds later. Only os.fsync(fd) forces the kernel all the way down to the hardware. So "I closed the file" means "the kernel has my bytes," not yet "the platter has my bytes." Worth knowing before you trust a file after a power cut.

Now the hole — consider the honest, well-meaning version:

the_leak.pypython
f = open("song.txt", "w")
f.write("My Favorite Song")
risky()               # if this raises, control jumps AWAY...
f.close()             # ...and this line never runs. Leak + unflushed data.

If risky() raises, the exception propagates up and out of this block, sailing straight past f.close(). The descriptor leaks, and the buffered write may be lost. And this isn't an exotic edge case — anything can raise: a bad value, a full disk, a keyboard interrupt. A cleanup line you merely hope executes is not cleanup.

The context manager closes the hole structurally, so write it the way you've been told to:

the_guarantee.pypython
with open("song.txt", "w") as f:
    f.write("My Favorite Song")
    risky()           # even if THIS raises...
# ...__exit__ still fires here — buffer flushed, fd slot freed — then the
# exception keeps propagating. Cleanup happened on the way out regardless.

Here is the actual mechanism, no magic. with open(...) as f binds the file object to f. More importantly, the with statement guarantees that f.__exit__() is called when the block ends — normally OR via an exception. The file object's __exit__ method calls f.close(), and that's the whole trick: __exit__ runs on every path out of the block. So a crash mid-write still flushes the buffer and still frees the descriptor before the exception continues on its way. with is treated as "law" not because teachers love tidiness, but because it moves cleanup from a line you hope executes to a guarantee the language enforces on every exit path.

with open('song.txt','w') as f:f.write(...)block finishesnormallyexception raisedmid-write__exit__() runs → f.close()flush Python buffer → kernel drops ref → fd slot freedboth ways out reach close.CONTRAST — bare open, no with:f = open(...)f.close()never reachedfd LEAKED + bufferunflushed
Fig — with makes close happen on every way out; a bare open leaks the fd the moment an exception skips past close.

You can prove __exit__ fires under an exception with your own eyes. Write a tiny class with __enter__ and __exit__ that prints when it exits. Wrap a deliberate crash in it, and watch EXIT called appear even as the traceback prints. Cleanup ran on the way out, and only then did the exception surface. Do the same with a real file and reopen it afterward. The bytes are there, flushed by the close that __exit__ guaranteed.

ERROR CLINICyou will meet these — decoded
Traceback (most recent call last):
  File "save_playlist.py", line 3, in <module>
    with open("data/playlist.txt", "w", encoding="utf-8") as f:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'data/playlist.txt'
Mode "w" creates the file, and only the file. Before the kernel can create anything it has to walk the path one component at a time, and it stops dead at the first component that is not there — data/ does not exist, so there is nowhere to put playlist.txt. There is no "make the folders on the way" flag to pass; that would be open inventing directories you never asked for. And errno 2 here is the same ENOENT you get from a missing file in mode "r": the mode letter decides what happens to the file at the end of the path — it was never a licence for the walk that gets there.
Path("data").mkdir(parents=True, exist_ok=True) first — idempotent, so it is safe on every run
Traceback (most recent call last):
  File "loader.py", line 4, in <module>
OSError: [Errno 24] Too many open files: 'note.txt'
Read what is missing from that traceback: the source line. Python normally prints the offending line by going back and reading your .py file off the disk while it builds the report — and there was not one descriptor left to read it with. The failure was total enough to swallow its own explanation; free twenty slots before letting it raise and the line and its caret come straight back. Errno 24 is EMFILE, and the ceiling belongs to a table outside Python — but which table depends on the machine, so let us be exact rather than tidy. This capture is Windows, where the descriptors open() hands you are slots in the C runtime's own array, capped at 8,192: the run died after 8,189 opens with the last slot at fd 8191, and 8,189 plus the 0, 1, 2 you started with is exactly 8,192. Windows has no RLIMIT_NOFILE at all — Python's resource module does not even exist there. On Linux it is the other way round: the ceiling is the kernel's RLIMIT_NOFILE, the one ulimit -n reports, commonly the 1,024 section 03 quoted. Two different tables, one lesson — raising the ceiling is not the repair. A loop that takes a slot and never gives one back will find whatever ceiling you set for it.
put the open inside a with-block in the loop — then one slot is borrowed and returned per iteration, not N slots hoarded
Traceback (most recent call last):
  File "save_note.py", line 4, in <module>
    f.write("Titanium\n")
ValueError: I/O operation on closed file.
Nothing is broken and nothing is missing. The with block simply ended one line earlier — and ending it is closing it: __exit__ flushed the buffer and handed the descriptor back to the table. What confuses everyone is that the name f outlives the block, because Python has no block scope, so the object is still sitting right there, perfectly willing to be called. It just has nothing left to write through. Notice too that it is a ValueError and not an OSError: Python refused above the wall, on its own side, so no syscall was ever attempted.
keep every write inside the block; if more has to go in later, reopen in mode "a"
TYPE THIS · save it, then run it TWICEappend_log.py — mode 'a', and a file that grows across two separate processes
# append_log.py -- 'a' never truncates. Run it twice and watch the file grow.
from datetime import datetime
from pathlib import Path

LOG = Path("plays.log")
PLAYED = ["Blinding Lights", "Titanium"]

stamp = datetime.now().strftime("%H:%M:%S")

with open(LOG, "a", encoding="utf-8") as f:      # 'a' -> create if absent, else seek to the end
    for title in PLAYED:
        f.write(f"{stamp}  played  {title}\n")

with open(LOG, "r", encoding="utf-8") as f:      # read the whole log back, every run
    lines = f.readlines()

print(f"--- {LOG.name} now holds {len(lines)} line(s) ---")
for line in lines:
    print(line.rstrip())
$ python append_log.py
--- plays.log now holds 2 line(s) ---
02:50:58  played  Blinding Lights
02:50:58  played  Titanium

$ python append_log.py          # a few seconds later, a NEW process
--- plays.log now holds 4 line(s) ---
02:50:58  played  Blinding Lights
02:50:58  played  Titanium
02:51:03  played  Blinding Lights
02:51:03  played  Titanium
Save it as append_log.py and run it twice, a few seconds apart. The output above is both runs, one after the other, with nothing edited between them. The first run creates plays.log and reports two lines. The second run reports four — and the first two still carry the earlier timestamp. Nothing in this program remembers anything: the process from run 1 is gone, its DRAM long since handed to something else. The memory is the file. That is the entire point of mode "a". On open the kernel sets the position to the end of the file and, on every write, forces it back there — so two runs, or two processes running at once, cannot overwrite each other's lines. Change one character to feel it: make it "w", run twice, and the log is permanently two lines long, because every run truncates the file before writing a thing. Then try the honest experiment: run it, and while it is still your own file, open plays.log in a text editor. The lines are there, in plain UTF-8, on hardware that does not need power to hold them. Your program ended; its data did not. That is the first time in this course that has ever been true.
Wait — if __exit__ always runs, does that mean with swallows my exception and hides the crash? No — the file object's __exit__ returns a falsy value, which tells Python "I did my cleanup; now let the exception keep going." A context manager can only suppress an exception by explicitly returning True. So with open(...) gives you the flush-and-close for free and still lets the error reach you loud and clear.

All chapter you've been told the file is always bytes — yet open('song.txt') hands you a str, while open('song.txt','rb') hands you bytes. Same descriptor, same disk, same byte-only kernel. So where do the strings come from? →

NOW WRITE IT YOURSELFfix the forgotten close — the loop that quietly eats the descriptor table
Here is a loader a colleague swears is fine, because it has never failed on his laptop: def load(paths): rows = [] ; for p in paths: f = open(p) ; rows.append(f.read()) ; return rows. No close(). No with. It reads three files today and it will read three thousand in production, where it will die with OSError: [Errno 24] Too many open files — the finite table from section 03, felt as a crash. Your job has three parts. First, make the leak visible. Rewrite the loader so it keeps each file object in a list and records f.fileno() alongside the text. Print the descriptors. You should watch them climb — three opens, three different slots, none given back. Then print [h.closed for h in handles] and confirm all three are False. Second, fix it with with. Same loop, same reads, one structural change. Print the descriptors again: they should now be the same number every iteration, because each block hands the slot back before the next one asks for it. Third, prove why it matters. Write a version that raises a ValueError halfway through the loop, once with a bare open()/close() pair and once inside a with. Collect .closed for every handle you opened. The bare version leaves one open; the with version does not. One hint and no more: if you do not keep the file objects in a list, CPython's reference counting will close them for you the moment the name f is rebound — and the leak will hide. That accident is precisely why this bug reaches production.
show the solution
# fix_the_forgotten_close.py -- the same job twice: once leaking, once guaranteed
from pathlib import Path

for i in range(1, 4):                                    # three little files to read
    Path(f"track{i}.txt").write_text(f"track {i}\n", encoding="utf-8")

PATHS = [f"track{i}.txt" for i in range(1, 4)]


# ---------- the program as found: opens, reads, never closes ----------
def load_leaky(paths):
    handles, rows = [], []
    for p in paths:
        f = open(p, "r", encoding="utf-8")               # no close(), no with
        handles.append(f)                                # the object stays alive...
        rows.append((f.fileno(), f.read().strip()))      # ...so the fd stays claimed
    return rows, handles


rows, handles = load_leaky(PATHS)
print("leaky :", rows)
print("        closed?", [h.closed for h in handles], "- three slots taken, none given back")
for h in handles:
    h.close()


# ---------- the repair: with, so __exit__ closes on every exit path ----------
def load_safe(paths):
    handles, rows = [], []
    for p in paths:
        with open(p, "r", encoding="utf-8") as f:
            handles.append(f)
            rows.append((f.fileno(), f.read().strip()))
    return rows, handles


rows, handles = load_safe(PATHS)
print("safe  :", rows)
print("        closed?", [h.closed for h in handles], "- one slot, reused, freed each time")


# ---------- and the reason it matters: an exception mid-loop ----------
def load_until_crash(paths, use_with):
    handles = []
    try:
        for p in paths:
            if use_with:
                with open(p, "r", encoding="utf-8") as f:
                    handles.append(f)
                    if p.endswith("2.txt"):
                        raise ValueError("bad row")
            else:
                f = open(p, "r", encoding="utf-8")
                handles.append(f)
                if p.endswith("2.txt"):
                    raise ValueError("bad row")          # jumps clean over the close below
                f.close()
    except ValueError:
        pass
    return [h.closed for h in handles]


print("crash, bare open():", load_until_crash(PATHS, False), "<- False = still open, leaked")
print("crash, with-block :", load_until_crash(PATHS, True), "<- closed anyway, on the way out")

# ------------- what it prints -------------
leaky : [(3, 'track 1'), (4, 'track 2'), (5, 'track 3')]
        closed? [False, False, False] - three slots taken, none given back
safe  : [(3, 'track 1'), (3, 'track 2'), (3, 'track 3')]
        closed? [True, True, True] - one slot, reused, freed each time
crash, bare open(): [True, False] <- False = still open, leaked
crash, with-block : [True, True] <- closed anyway, on the way out

06Text mode is a codec layer bolted onto a byte stream

Something in this chapter should have been quietly bothering you. Five sections of "the file is always bytes, the kernel only moves numbers." And yet the very first thing you ever did, open('song.txt'), handed you a str. Open the same file with 'rb' and you get bytes instead. But it is the same descriptor, the same disk, the same kernel that traffics only in numbers from 0 to 255. The bytes on the platter did not change. So where did the string come from? Something between the kernel and your variable is manufacturing str out of bytes. Until you can point at exactly which layer does it, the b'' prefix and every "works on my machine" encoding bug will feel like black magic.

Underneath every file mode there is one descriptor delivering raw bytes from the kernel. That is the ground truth, and it never changes. What changes is how many layers Python stacks on top of it. Open in binary mode — 'rb' or 'wb' — and Python gives you that byte stream almost bare: a BufferedReader or BufferedWriter sitting directly over the descriptor. read returns bytes, and write demands bytes. No interpretation happens, because none was asked for. This is the honest view of the file: exactly what the kernel moved.

Text mode — plain 'r' or 'w' — wraps that same buffered byte stream in one more object: an io.TextIOWrapper. And that wrapper is a codec layer, a two-way translator. On the way in, it decodes incoming bytes into a str using an encoding. On the way out, it encodes your str back into bytes before they ever reach the buffer and the write syscall. It also quietly translates newlines (\r\n on Windows ⇆ \n) so your text looks the same everywhere. The consequence is the sentence that dissolves the whole mystery: a str never touches the disk and never crosses the syscall boundary. Bytes do. The str exists only above the TextIOWrapper, conjured by the codec.

THE ONE IDEA TO CARRY FORWARD
Text mode is not a different kind of file. It is the one true byte stream with a decode/encode layer bolted on top. Bottom to top the stack is: disk → kernel page cache → fd (raw bytes cross the wall here) → BufferedReader (still bytes, batching to cut syscalls) → TextIOWrapper (bytes ⇄ str codec, text mode only) → your str. Only that top layer speaks strings, and it's optional.
DISKmagnetization / trapped chargekernel page cachebytesprivilege boundary —the only crossingis a syscallfd 3 — raw bytes cross hereBufferedReader / Writerstill bytes — batches to cut syscalls'rb' → bytesb'\x4d\x79...'TextIOWrapperdecode bytes→str / encode str→bytes + newline'r' → str'My...'text mode = this one bolted-on codecChapter 17 is what lives inside it.
Fig — Every layer up to the fd traffics in bytes; only the optional TextIOWrapper codec turns them into str.
NOW WRITE IT YOURSELFcount the lines and the words — chapter 2's Word Count, now aimed at a real file
In chapter 2 you counted the words in a string that you had typed into the program yourself. The string was already in DRAM; nothing crossed a boundary. Do it again now, from a file. First, write the file. Three lines of your own prose, any content, saved with Path("notes.txt").write_text(..., encoding="utf-8"). Second, count it the streaming way. Open it once, loop with for line in f:, and keep three running totals: lines, words, characters. Words come from line.split() — the same chapter 2 method, splitting on any run of whitespace. Do not call .read() and do not call .readlines(): the point of this exercise is that your memory use must not depend on the size of the file. Third, add the frequency table. Feed a collections.Counter the lowercased, punctuation-stripped words and print .most_common(3). Then cross-check yourself: read the whole file once with read_text() and confirm len(text.splitlines()), len(text.split()) and len(text) match the three numbers you counted the long way. If they disagree, the usual culprit is the newline — len(line) counts it, and .split() does not. One thing to notice when you are done: the character count includes one "\n" per line, and that is a count of characters, not of bytes on the disk. Section 06 is about to make that distinction expensive.
show the solution
# count_file.py -- Word Count from chapter 2, now pointed at a real file
from collections import Counter
from pathlib import Path

SOURCE = Path("notes.txt")
SOURCE.write_text(
    "the jukebox plays a track and then it plays the next track\n"
    "every track is one line and every line is one track\n"
    "the file remembers what the process forgot\n",
    encoding="utf-8",
)

lines = words = chars = 0
tally = Counter()

with open(SOURCE, "r", encoding="utf-8") as f:
    for line in f:                                   # one line at a time: memory stays flat
        lines += 1
        chars += len(line)
        pieces = line.split()                        # split() on whitespace, ch 2
        words += len(pieces)
        tally.update(w.strip(".,!?'").lower() for w in pieces)

print(f"{SOURCE.name}: {lines} lines, {words} words, {chars} characters")
print("top 3      :", tally.most_common(3))

# the one-liner, for a file you already know is small
text = SOURCE.read_text(encoding="utf-8")
print("cross-check:", len(text.splitlines()), len(text.split()), len(text))

# ------------- what it prints -------------
notes.txt: 3 lines, 30 words, 154 characters
top 3      : [('the', 4), ('track', 4), ('plays', 2)]
cross-check: 3 30 154

See both branches at once, where the bytes on disk are identical and only the number of layers differs:

two_views.pypython
open("song.txt", "rb").read()   # b'My Favorite Song' — bytes, raw off the fd
open("song.txt", "r").read()    # 'My Favorite Song'  — str, decoded by the codec layer

The gap between them widens the moment a character needs more than one byte. Save a file containing a musical note or an emoji, and count. In 'rb' mode the length is larger than the number of visible characters. That one glyph is several bytes on disk (three, for , in UTF-8). Read the same file in 'r' mode and the length equals the character count exactly, because the TextIOWrapper consumed those several bytes and produced one str element. That collapse — many bytes into one character — is the decode step. It happens at a layer you can now name and point to.

Which raises the sharpest, most practical point of the section: which encoding does text mode use? By default, whatever your platform's locale says. That's often UTF-8 on modern systems, but not always. And that "not always" is the source of a thousand "but it worked on my laptop" bugs. A file written as UTF-8 on your machine can be read with a different default encoding on a colleague's. It can decode into garbage or raise outright. The codec layer is powerful, but its default is a guess made by the platform. And guesses are where correctness goes to die.

SYNTAX · encoding= — the argument you should never leave outone keyword that turns "works on my machine" into a program that works everywhere
with open(path, "r", encoding="utf-8") as f: — say it. Every open, every time. with open(path, "w", encoding="utf-8") as f: — writing needs it just as much with open(path) as f: — NO encoding: the platform guesses.   UTF-8 on Linux, often cp1252 on Windows. with open(path, encoding="utf-8", errors="strict") — default: raise on a bad byte with open(path, encoding="utf-8", errors="replace") — substitute U+FFFD, keep going with open(path, encoding="utf-8", errors="ignore") — drop it silently. Rarely right. with open(path, "rb") as f: — no codec at all, so no argument exists
encoding="utf-8"The codec the TextIOWrapper uses to turn bytes into str on the way up, and str back into bytes on the way down. Naming it makes the layer deterministic on every machine.
leaving it outPython falls back to the platform default. On modern Linux and macOS that is UTF-8; on many Windows installs it is cp1252, a 256-character codec that cannot spell most of Unicode.
errors="strict"The default, and the honest one: an undecodable byte raises UnicodeDecodeError, telling you the byte offset and the reason instead of handing you quiet nonsense.
errors="replace"Every byte the codec cannot read becomes U+FFFD, the replacement character. The data is still damaged — but the damage is now visible in the output instead of hidden.
a wrong-but-legal codeclatin-1 maps all 256 byte values to characters, so it never raises. It will read anything and quietly give you the wrong text. Silence is not correctness.
"rb"No codec, so encoding= is not even accepted. You get the descriptor's bytes untouched — the right choice for anything that is not text.
you type
# encodings.py -- the argument you should never leave out
import locale
from pathlib import Path

print("this machine's text default:", locale.getpreferredencoding(False))

p = Path("title.txt")
with open(p, "w", encoding="utf-8") as f:            # say it. Every time.
    f.write("Café Bleu ♦".replace("é", "é"))

print("on disk      :", p.read_bytes())
print("len in bytes :", len(p.read_bytes()), "| len in characters:", len(p.read_text(encoding='utf-8')))

with open(p, "r", encoding="utf-8") as f:            # the right codec: the text comes back
    print("utf-8        :", repr(f.read()))

with open(p, "r", encoding="latin-1") as f:          # a WRONG codec that never complains
    print("latin-1      :", repr(f.read()), "<- no error. Just wrong.")

try:
    with open(p, "r", encoding="ascii") as f:        # a wrong codec that does complain
        f.read()
except UnicodeDecodeError as e:
    print("ascii        : UnicodeDecodeError at byte", e.start, "-", e.reason)

with open(p, "r", encoding="ascii", errors="replace") as f:
    print("ascii+replace:", repr(f.read()), "<- damage made visible, not silent")

with open(p, "rb") as f:                             # no codec at all: the kernel's own truth
    print("rb           :", f.read())

# the bug this argument prevents, caught in the act
try:
    with open("guessed.txt", "w") as f:              # no encoding= -> the platform guesses
        f.write("Café Bleu ♦".replace("é", "é"))
    print("no encoding= : wrote it, using", locale.getpreferredencoding(False))
    print("no encoding= : reads back as", repr(Path("guessed.txt").read_text(encoding="utf-8")))
except UnicodeEncodeError as e:
    print("no encoding= : UnicodeEncodeError -", e.reason, "- codec", e.encoding, "cannot spell", repr(e.object[e.start:e.end]))
you see
this machine's text default: cp1252
on disk      : b'Caf\xc3\xa9 Bleu \xe2\x99\xa6'
len in bytes : 14 | len in characters: 11
utf-8        : 'Café Bleu ♦'
latin-1      : 'Café Bleu â\x99¦' <- no error. Just wrong.
ascii        : UnicodeDecodeError at byte 3 - ordinal not in range(128)
ascii+replace: 'Caf�� Bleu ���' <- damage made visible, not silent
rb           : b'Caf\xc3\xa9 Bleu \xe2\x99\xa6'
no encoding= : UnicodeEncodeError - character maps to <undefined> - codec charmap cannot spell '♦'
where beginners trip
  • This capture ran on a Windows box whose text default is cp1252. On a UTF-8 machine the final line succeeds instead of raising — the same program, two behaviours. That gap is the bug encoding= removes.
  • A wrong codec usually does not crash. latin-1 read our file without a murmur and returned 'Café Bleu â\x99¦'. Mojibake is a silent failure.
  • len() in bytes and len() in characters are different numbers for the same file — 14 against 11 here. Chapter 17 takes that apart byte by byte.
  • The error travels the other way too: writing a character your platform codec cannot spell raises UnicodeEncodeError at write time, not at open time.
  • errors="ignore" deletes data and tells nobody. Reach for it only when you have decided, deliberately, that some bytes are worth losing.
ALWAYS PIN THE ENCODING IN TEXT MODE
If you want the kernel's raw truth, open 'rb' and deal in bytes. If you want a convenient str, open text mode with the encoding named: open(path, encoding="utf-8"). That one keyword takes the decode off the platform's whim and pins it to a choice you control — the difference between code that works everywhere and code that works "on my machine."

And because the str only exists if the codec succeeds, you can watch the whole illusion refuse to form. Write bytes that are not valid UTF-8 in 'wb' mode. Then try to read that file in 'r' with encoding="utf-8", and you get a UnicodeDecodeError. Not corrupted text — no text at all, because the decoder could not build a legal str from those bytes. That error is the codec layer telling you, plainly, that the string was never real disk data. It was always something the top layer manufactured, and manufacturing can fail.

NOW WRITE IT YOURSELFwrite a backup copy — a .bak twin that is byte-for-byte the original
Every editor you have ever used does this, and it is about fifteen lines. Copy a file to a second file whose name has .bak on the end, and prove the copy is exact. First, get the name right. Given src = Path("playlist.txt"), build playlist.txt.bak. Careful: src.with_suffix(".bak") replaces the suffix and gives you playlist.bak, which loses the original extension. You want src.with_suffix(src.suffix + ".bak"). Second, copy in binary, in chunks. Open the source "rb" and the target "wb" — both in one with statement, separated by a comma. Loop: read a block of 64 KiB, stop when read() returns b"", and write what you got. Binary matters here. A backup must be a copy, not a translation: no codec that could fail on a byte it dislikes, no newline conversion that would change the file's size on Windows. And the chunk loop matters because a backup tool must work on a file bigger than your RAM. Third, prove it and protect it. Compare src.stat().st_size with the backup's, compare read_bytes() against read_bytes(), then read the backup back and print its contents — do not claim the copy worked, show it. Finally, try to create the same backup a second time with mode "x" and catch the FileExistsError. A backup you silently overwrite is not a backup. One hint and no more: with open(a) as f1, open(b) as f2: is one statement with two context managers, and both get closed on the way out, in reverse order.
show the solution
# backup.py -- copy a file to a .bak twin, byte for byte, in chunks
from pathlib import Path

src = Path("playlist.txt")
src.write_bytes(b"Blinding Lights|The Weeknd|200\nTitanium|David Guetta|245\n")

bak = src.with_suffix(src.suffix + ".bak")            # playlist.txt -> playlist.txt.bak
bak.unlink(missing_ok=True)

CHUNK = 64 * 1024                                     # one syscall per chunk, not per byte
copied = 0
with open(src, "rb") as fin, open(bak, "wb") as fout:  # 'b': no codec, nothing translated
    while True:
        block = fin.read(CHUNK)
        if not block:                                  # b"" means end of file
            break
        copied += fout.write(block)

print("source    :", src.name, "|", src.stat().st_size, "bytes")
print("backup    :", bak.name, "|", bak.stat().st_size, "bytes |", copied, "copied")
print("identical :", src.read_bytes() == bak.read_bytes())

print("--- backup contents, read back ---")
with open(bak, "r", encoding="utf-8") as f:            # a second read proves it, we don't claim it
    print(f.read(), end="")
print("----------------------------------")

# never silently clobber a backup you might need: 'x' refuses
try:
    with open(bak, "x", encoding="utf-8") as f:
        f.write("clobbered")
except FileExistsError:
    print("second run:", bak.name, "already exists - 'x' refused to overwrite it")

# ------------- what it prints -------------
source    : playlist.txt | 57 bytes
backup    : playlist.txt.bak | 57 bytes | 57 copied
identical : True
--- backup contents, read back ---
Blinding Lights|The Weeknd|200
Titanium|David Guetta|245
----------------------------------
second run: playlist.txt.bak already exists - 'x' refused to overwrite it

✗ The myth

"Text files and binary files are two different kinds of file, and Python reads each in its own way."

✓ The reality

There is one kind of file — bytes on disk. "Binary mode" hands you those bytes. "Text mode" bolts a decode/encode layer on top and manufactures a str from the same bytes. The file never changed; only the number of layers between you and the descriptor did.

Wait — if the str is manufactured above the wall, does encoding cost real work every time you read a text file? Yes — every character you read in text mode is decoded, and every character you write is encoded, on the fly. For huge files or tight loops, opening 'rb' and handling bytes yourself can be meaningfully faster, precisely because you skip the codec layer entirely. Convenience has a price, and now you know exactly which layer charges it.

You've crossed the first boundary: bytes leaving your process, over the ring wall, onto remembering hardware, and back again — with a codec layer deciding whether they come home as bytes or as text. That codec is a whole world of its own. Chapter 17 climbs inside it: what an encoding actually is, why UTF-8 won, and how a handful of bytes agree to mean a single character. →

SAY IT BACKthe chapter in six breaths
  1. RAM forgets because the charge physically leaks away — a 1T1C cell has to be re-poured every 64 ms or it decays into noise — so a file is nothing grander than a byte that crossed to hardware which remembers with the power off.
  2. open() is a trap into the kernel: your ring-3 code cannot touch a disk controller at all, so one instruction flips the CPU to ring 0, the kernel does the forbidden work on your behalf, and what comes back through the gate is not a file but a small integer receipt.
  3. That integer is an index into a private, finite array0, 1 and 2 were wired to your terminal before your first line ran, every open takes the lowest free slot, and every close hands one back to be reused by the next file, which is why a stale number can quietly point somewhere new.
  4. read and write are byte-copy syscalls that return a count, never a promise: the kernel shifts opaque numbers across the wall, has no idea they spell a song title, and charges a toll for every crossing — which is the entire reason buffering exists rather than a syscall per byte.
  5. close frees the slot and flushes what Python was still holding, and with turns that from a line you hope runs into a guarantee on every exit path — including the one an exception takes on its way out.
  6. Text mode is not a different kind of file: bottom to top it is disk, page cache, the fd where bytes cross the wall, a buffer that batches them, and then one optional codec layer that manufactures the str — which is why the wrong encoding= can hand you confident nonsense instead of an error. Bytes are all that ever crossed.
You already owned the pieces: chapter 0 gave you the byte these syscalls shuttle; chapter 3 gave you the name that outlives the thing it pointed at, which is exactly why f is still there after the block closed it; chapter 10 gave you the iterator that for line in f quietly turns out to be; chapter 12 gave you the dunder the language calls on your behalf without being asked; and chapter 13 gave you both halves of section 05 at once — the exception that sails straight past a close() you merely hoped would run, and the with block, bookends and all, that closes the file anyway while that exception goes by. Chapter 15 added one new fact to all of it — a number, handed to you by the kernel.
TYPE THIS · the chapter's capstone — save it, then run itjukebox_store.py — chapter 14's playlist, now surviving the process that built it
# jukebox_store.py -- chapter 14's playlist, now surviving the process that made it
from dataclasses import dataclass
from pathlib import Path

DB = Path("jukebox.txt")
SEP = "|"


@dataclass
class Track:                                     # ch 12/14, unchanged
    title: str
    artist: str
    seconds: int
    plays: int = 0

    def __str__(self):
        minutes, secs = divmod(self.seconds, 60)
        return f"{self.title} - {self.artist} ({minutes}:{secs:02d})  played {self.plays}x"


def save(tracks, path=DB):
    """One track per line, fields joined by |. Returns how many lines landed."""
    with open(path, "w", encoding="utf-8") as f:          # 'w': this file IS the playlist now
        for t in tracks:
            f.write(SEP.join([t.title, t.artist, str(t.seconds), str(t.plays)]) + "\n")
    return len(tracks)


def load(path=DB):
    """Text back into Track objects. Bad lines are reported, never guessed at."""
    tracks = []
    if not path.exists():                                  # first ever run: nothing to load
        return tracks
    with open(path, "r", encoding="utf-8") as f:
        for lineno, raw in enumerate(f, start=1):
            line = raw.rstrip("\n")
            if not line:
                continue
            try:
                title, artist, seconds, plays = line.split(SEP)
                tracks.append(Track(title, artist, int(seconds), int(plays)))
            except ValueError as e:
                print(f"  line {lineno} skipped: {line!r} -> {e}")
    return tracks


# ---------- run 1: build it in RAM, play one, save it ----------
playlist = [
    Track("Blinding Lights", "The Weeknd", 200),
    Track("Titanium", "David Guetta", 245),
    Track("Levels", "Avicii", 203),
]
playlist[0].plays += 1                                     # somebody pressed play
print("in memory :", len(playlist), "tracks")
for t in playlist:
    print("   ", t)
print("saved     :", save(playlist), "lines ->", DB.name)

print("--- what is actually on disk ---")
with open(DB, "r", encoding="utf-8") as f:                 # read it back to SHOW it, not claim it
    print(f.read(), end="")
print("-------------------------------")

# ---------- the process ends here. Everything in RAM is gone. ----------
del playlist

# ---------- run 2: a fresh program, loading from the file ----------
restored = load()
print("loaded    :", len(restored), "tracks, rebuilt as", type(restored[0]).__name__, "objects")
for t in restored:
    print("   ", t)
print("plays kept:", restored[0].plays, "- the play we recorded survived the process")
print("round trip:", restored == [
    Track("Blinding Lights", "The Weeknd", 200, 1),
    Track("Titanium", "David Guetta", 245),
    Track("Levels", "Avicii", 203),
])

# ---------- the honest limit of a delimiter you chose yourself ----------
with open(DB, "a", encoding="utf-8") as f:
    f.write("Song|With|A|Pipe|In It|0\n")
print("re-loaded :", len(load()), "tracks")
in memory : 3 tracks
    Blinding Lights - The Weeknd (3:20)  played 1x
    Titanium - David Guetta (4:05)  played 0x
    Levels - Avicii (3:23)  played 0x
saved     : 3 lines -> jukebox.txt
--- what is actually on disk ---
Blinding Lights|The Weeknd|200|1
Titanium|David Guetta|245|0
Levels|Avicii|203|0
-------------------------------
loaded    : 3 tracks, rebuilt as Track objects
    Blinding Lights - The Weeknd (3:20)  played 1x
    Titanium - David Guetta (4:05)  played 0x
    Levels - Avicii (3:23)  played 0x
plays kept: 1 - the play we recorded survived the process
round trip: True
  line 4 skipped: 'Song|With|A|Pipe|In It|0' -> too many values to unpack (expected 4)
re-loaded : 3 tracks
Save it as jukebox_store.py and run it. This is the moment the course has been building toward since chapter 1, and it is worth naming plainly: the jukebox now remembers. Every playlist you have built in fourteen chapters existed only while the process did. Press play, increment plays, exit — and the count was gone, because it lived in DRAM and DRAM forgets. This program ends with the count still there. Look at the two functions, because they are the whole pattern and you will write them for the rest of your life. save() walks the objects and writes one line per track, fields joined by a delimiter you chose. load() reads them back with the idiomatic loop, splits each line on the same delimiter, and rebuilds real Track objectsint(seconds), not the string "200", because everything in a text file is text until you convert it. That asymmetry is the heart of serialization: going out you flatten structure into characters; coming back you have to rebuild it, and the file cannot help you. Notice the honesty in load(). It checks path.exists() first, so the very first run of a brand-new jukebox loads an empty list instead of crashing. It wraps the unpack in try, so a malformed line is reported by line number and skipped, never silently guessed at — chapter 13's discipline, aimed at data you did not type. And the last three lines are the deliberate failure. We append a title that contains the delimiter, reload, and the row is rejected: too many values to unpack. That is not a bug in the program, it is the honest limit of picking a separator character and hoping no data contains it. Real formats solve it by quoting or escaping — which is exactly what csv and json do, and exactly where chapter 18 begins. Two things to try. Delete jukebox.txt and run it again to watch the empty-first-run path work. Then run it twice without deleting, and add a plays += 1 before each save — and watch a number climb across separate lives of the program.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 15, in working code

Chapter 15 rips the lid off `open()`. Underneath every file object is a syscall — a trap into the kernel — and the kernel's reply is nothing but a small integer: the file descriptor. These twelve programs make that number visible. First we prove why files exist at all (RAM forgets the instant a process dies; the disk does not). Then we watch `os.open` hand back bare ints 3, 4, 5, watch a closed slot get reused, and confirm stdin/stdout/stderr really are 0, 1, 2. We move raw bytes across the boundary with `os.write`/`os.read` — seeing that a read reports what it actually got, not what you asked for — and finish with `with`, which guarantees the descriptor gets handed back even mid-crash, and the thin decode layer that turns those bytes into text. Every program is real and runnable; all output is kept ASCII so it is byte-for-byte identical on every machine.

RAM forgets, the disk remembers
Why files exist at all: a process's memory vanishes when it exits. Only bytes written to a descriptor outlive the run.
A descriptor is a small integer
os.open traps into the kernel and gets back a bare int. The kernel allocates the lowest free slot, reclaims it on close, and reuses it — and the three you start with are 0, 1, 2.
Reads and writes move bytes
os.write and os.read are syscalls that move raw bytes. A write reports how many bytes it moved; a read returns what it actually got — often less than you asked for.
with closes the descriptor for you
A with block guarantees the file is flushed and the fd handed back — even when the body crashes. Outside the block, the descriptor is already invalid.
end of chapter 15 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked