Visual walkthrough — File I - O — open modes (r, w, a, rb), read, readline, readlines, write
This is the visual companion to the parent topic. Read that first for the mode table; here we derive the behaviour from zero by tracking one arrow.
Step 1 — A file is a line of boxes, and the cursor is an arrow
WHAT. Forget "file" for a moment. A file on disk is nothing more than a row of tiny boxes, laid left to right. Each box holds one character — a letter, a space, or the invisible newline character we write as \n (the "go to next line" mark). The very edge past the last box is a special place called EOF — "End Of File."
WHY this picture. Almost every confusion in file I/O comes from imagining a file as "lines" or "words." At the machine level there are no lines — only a straight strip of boxes. If you can see the strip, you can predict everything.
PICTURE. Below, the word apple\nbanana\n is drawn as boxes. The \n after apple is a real box — it is not empty space, it is the character that ends the line. The yellow arrow is our star: the cursor. It sits between boxes and always points at the next box to be touched.

Step 2 — read() drags the cursor to the wall
WHAT. Calling f.read() with no number means: copy every box from the cursor all the way to EOF into one string, and leave the cursor sitting at EOF.
WHY this tool and not another. We use read() (the "slurp everything") when we genuinely want the whole remaining content as a single str — for example a small config file we'll parse in one go. It answers the question "give me all of what's left." The words what's left are the trap, and the picture shows why.
PICTURE. Watch the yellow cursor start at the far left (box 0). After f.read(), the blue bracket shows everything that got copied out — the entire strip — and the cursor has been dragged to the wall at EOF. It is now standing on the far side of the last box.

Term by term: f is the open file object (the thing holding the cursor). .read() is the verb "copy to EOF." whole is where the copied boxes land. The key side-effect — cursor now at EOF — is invisible in the code but loud in the picture.
Step 3 — WHY the second read() returns '' (the empty-string mystery)
WHAT. Immediately calling f.read() a second time returns '' — an empty string. Nothing.
WHY this happens. There is no second cursor to reset. The one cursor is still standing at EOF from Step 2. read() says "copy from the cursor to EOF" — but the cursor is already at EOF, so there are zero boxes between here and the wall. Zero boxes copied = the empty string. The file on disk is untouched and full; it only looks empty because you're reading from the wrong end.
PICTURE. The cursor sits pinned at EOF. The blue "copy region" has zero width — there is nothing between the arrow and the wall. That zero-width region is exactly ''.

Step 4 — readline() stops at the first \n and remembers
WHAT. f.readline() copies boxes from the cursor forward, but stops the instant it passes a \n — including that \n. It returns exactly one line, and leaves the cursor right after the newline it stopped on.
WHY this tool. We use readline() when we want the file one line at a time, and we rely on the cursor's memory: because the cursor stayed just past the last \n, the next readline() naturally continues on the next line. No bookkeeping, no index — the single cursor is the bookmark.
PICTURE. Three snapshots stacked. Call ①: cursor starts at box 0, reads through the first \n, returns 'apple\n', arrow now rests before b. Call ②: starts where ① stopped, returns 'banana\n'. The arrow's memory is the whole trick — trace it moving down the strip.

Each call returns a string that still carries its trailing \n (that box was copied too). If you want clean text, peel it with line.rstrip("\n").
Step 5 — readlines() photocopies every line into a list
WHAT. f.readlines() runs the Step-4 process over and over until EOF, packaging each line as a separate string inside one list. Each string keeps its \n.
WHY this tool — and its cost. It answers "give me all lines, already split." Convenient — but the picture shows the price: it drags the cursor to EOF (like read()) and builds a list holding every line in RAM at once. Great for a 20-line file, ruinous for a 10 GB log.
PICTURE. The strip on the left; the list of green boxes on the right, one line per slot, each ending in \n. The cursor ends at EOF — so, just like Step 3, calling readlines() again gives [].

Step 6 — write drops boxes at the cursor and adds nothing
WHAT. f.write(s) places the characters of s into boxes starting at the cursor, then slides the cursor past them. It appends no \n of its own.
WHY this matters. People expect write to behave like print, which secretly adds a newline. It doesn't. Three calls of f.write("hi") land as hihihi — one continuous strip — because each call only drops the boxes you literally handed it, then the cursor waits right there for the next drop.
PICTURE. Cursor at box 0. write("hi") fills boxes 0–1, arrow now before box 2. write("hi") again fills 2–3. No gaps, no line breaks — the boxes are flush. To get lines, you must supply the \n yourself.

Step 7 — w vs a: WHERE the cursor starts decides everything
WHAT. This is the whole difference between the two writing modes, and it is purely about the starting cursor and whether the boxes get wiped first.
w= truncate then start. The instant youopen(..., "w"), every box is deleted and the cursor sits at box 0 of an empty strip. Your old data is gone before you write a single character.a= keep then jump to the end.open(..., "a")leaves all boxes intact and drops the cursor at EOF, so everywritelands after the existing data.
WHY the split. The OS must know your intent up front (see the parent's "why modes exist"). "Overwrite from scratch" and "add to the end" need opposite starting cursors, so they are different modes.
PICTURE. Two strips. Top: w empties the boxes and parks the cursor at 0 — the red hatching is the erased data. Bottom: a keeps the boxes and parks the cursor at EOF (green), ready to extend.

Step 8 — The binary edge case: boxes of bytes, not letters
WHAT. With a b in the mode (rb, wb), the boxes no longer hold decoded characters — they hold raw bytes (numbers 0–255). No encoding is applied, and \n is not given any special "newline" treatment; it's just the byte value 10.
WHY a separate mode. A PNG image is not text. If you tried to read it in text mode, Python would try to decode the bytes as UTF-8 and crash on the first byte that isn't valid text. rb skips decoding entirely and hands you the exact bytes — essential for images, audio, zips. (Contrast Bytes vs str and Strings and Encoding (UTF-8).)
PICTURE. The same strip idea, but each box now shows a byte like \x89 instead of a letter. f.read(8) copies the first 8 byte-boxes — the PNG "magic number" that identifies the format.

The little b prefix on the result marks it as a bytes object, not a str. Same cursor, same forward-slide — only the contents of the boxes changed.
Step 9 — The polite friend: with closes the notebook
WHAT. with open(...) as f: opens the file, hands you f, and — no matter what happens inside, even an error — closes it when the block ends. Closing flushes any buffered writes to disk.
WHY. Writes often sit in a temporary buffer in RAM before hitting the slow disk. If you never close, those boxes may never land on disk, and the OS can run out of file handles. with guarantees the flush-and-close. See Context Managers (with statement) and, for when errors strike, Exceptions try-except.
PICTURE. A buffer (RAM boxes) feeding into disk boxes only at close-time — with is the arrow that guarantees the transfer.

The one-picture summary
Everything above is one cursor moving on one strip of boxes. This final figure lays all the verbs on the same strip: where each starts, where it stops, what it copies, and where it leaves the arrow.

Recall Feynman: the whole walkthrough in plain words
Picture a row of little boxes — that's the file — and one yellow arrow, the cursor, sitting before a box. read() copies every box from the arrow to the far wall and leaves the arrow stuck at the wall, which is why a second read() gives you nothing: there's nothing between the arrow and the wall anymore. readline() copies boxes only until it passes a \n, then stops and stays there, so the next call keeps going down the page — the arrow is a bookmark. readlines() does that over and over and hands back a list, but it hauls the whole file into memory. write just drops the exact boxes you gave it and adds no line break, so hi then hi becomes hihi. w shreds all the boxes and puts the arrow at the start; a keeps the boxes and puts the arrow at the end — that's the only difference. Add a b and the boxes hold raw bytes instead of letters, with no decoding. And with is the friend who always shuts the notebook and pushes your writes onto the disk when you leave. One arrow, one strip — that's all of file I/O.
Where does the cursor sit right after f.read()?
read() returns ''.Where does the cursor rest after f.readline()?
\n it stopped on, so the next call continues on the following line.State the only real difference between w and a.
w wipes the boxes and starts the cursor at position 0; a keeps the boxes and starts the cursor at EOF.Why use rb instead of r for a PNG?
rb hands back raw bytes with no decoding; text mode would try to decode bytes as UTF-8 and crash.