open() takes a mode string like "r" or "w". That string is doing a lot of work. It tells Python whether to read or write, whether to create the file if it doesn't exist, whether to wipe what's there, whether to treat the contents as text or raw bytes, and where the cursor should start. This lesson breaks the mode string apart, walks through every flag (r, w, a, x, b, t, +), shows the useful combinations, and covers three keyword arguments that change behavior just as much: encoding, newline, and buffering.
A mode string is a short combination of one-character flags. Order doesn't matter for the most part, and the defaults are quiet enough that beginners often miss what's actually happening.
The "r" here is short for "rt". Python fills in the t (text mode) for you, and that text mode defaults to your platform's preferred encoding. You also get a default buffer size and default newline handling. None of that is wrong, but it's worth knowing what was decided on your behalf.
Here are the building blocks of every mode string:
| Flag | Means |
|---|---|
r | Read. File must already exist. |
w | Write. Creates if missing, truncates if present. |
a | Append. Creates if missing, keeps existing data. |
x | Exclusive create. Fails if the file already exists. |
b | Binary mode. Reads and writes bytes, not str. |
t | Text mode. The default. Reads and writes str. |
+ | Update mode. Adds the missing direction (read or write) to whatever r, w, a, or x already gave you. |
You always pick exactly one of r, w, a, or x (these are the access modes). You optionally add b or t (these control the data type). You optionally add + to allow both reading and writing on the same file.
A few real combinations:
"r" is read-only text. The most common mode."w" is write-only text, truncating."a" is append-only text."rb" is read-only binary."r+" is read and write, file must exist, cursor at start."w+b" is read and write binary, truncates on open.The rest of this lesson is one section per flag, plus the combinations that come up often.
The single most important choice in a mode string is between text mode (t) and binary mode (b). Text mode is the default. You almost always want it for files you'd open in a normal editor: product lists, CSV exports, JSON, log lines. Binary mode is for files whose bytes have specific meanings: images, PDFs, compiled assets, anything where translating bytes to characters would corrupt the file.
In text mode, the file behaves like a stream of str values. Reads return str. Writes accept str. Python decodes bytes from the file using a chosen encoding when you read, and encodes str back to bytes when you write. It also translates line endings (more on that later).
In binary mode, the file is a stream of bytes. Reads return bytes. Writes accept bytes. No encoding happens, no newline translation, nothing. What's on disk is what you see.
The b prefix on the printed value tells you it's a bytes object, not a str. The characters look the same here because the text was plain ASCII. Save a string with an accent, like "café", and binary mode will show you the actual UTF-8 bytes (b'caf\xc3\xa9'), not the human-readable form.
You can't mix the two. Trying to write a str to a binary file, or bytes to a text file, raises TypeError:
The fix is either to switch to text mode or to encode the string first: f.write("hello".encode("utf-8")). We'll come back to encoding shortly.
A small binary example so you've seen one: reading the first few bytes of a file to check what kind of file it is. PNG images, for instance, always start with the same eight bytes.
That's the kind of thing binary mode is for. Reading product descriptions or order CSVs isn't. The chapter on the struct module covers the deeper binary parsing patterns; for this lesson it's enough that you can pick the right side of the text/binary split.
r"r" opens an existing file for reading. The cursor starts at byte 0. You can call read, readline, or iterate the file directly. You can't write to it.
The thing to watch for with "r" is that the file must already exist. Open a missing path and you get FileNotFoundError, not an empty file:
This is the right default. Reading a file that doesn't exist is almost always a bug, and silently returning an empty string would hide it. If you want "read the file if it's there, otherwise use a default," catch the FileNotFoundError or check os.path.exists first.
Trying to write to a file opened in "r" mode raises UnsupportedOperation, a subclass of ValueError:
w"w" opens a file for writing. If the file doesn't exist, Python creates it. If it does exist, Python truncates it to zero bytes the moment you call `open`, before you've written anything. That truncation is the part that bites people.
Now run that same open("orders.txt", "w", ...) again with no writes:
The file is empty. Opening with "w" wiped it, and we never wrote anything new. The previous orders are gone for good.
Cost: Truncation is essentially free at the syscall level. The hidden cost is data loss. Treat open(path, "w") as "I am about to overwrite whatever was here."
This is a common mistake when a beginner means "add a new order to the existing log" but writes:
What's wrong with this code?
Only the last order survived. Each call to log_order truncated the file before writing. To accumulate lines across calls, you need append mode ("a"), not write mode.
Fix:
The "a" mode keeps existing contents and writes new data at the end. We'll dig into it next.
a"a" opens a file for writing at the end. If the file doesn't exist, it's created. If it does, the cursor sits past the last byte and every write goes after the existing data. You can't accidentally overwrite what's already there with append mode, even if you call seek to move the cursor somewhere else (on most operating systems, writes in append mode are forced to the end as an atomic operation).
Append mode is the right choice for log files, audit trails, review streams, anything you want to keep extending without losing history. It's also concurrent-safer than "w" because multiple writers can append to the same file without trampling each other, as long as each write fits in one OS call.
One thing to know: "a" is write-only by default. You can't read from it. If you try, you get the same not readable error you'd see when reading from "w":
To read and append in the same handle, use "a+". We'll get to + shortly.
x"x" is the lesser-known cousin of "w". It opens the file for writing, but only if the file does not already exist. If the file is there, open raises FileExistsError immediately and refuses to do anything.
The first call creates the file. The second call sees that it exists and refuses, even though "x" is a write mode. This is the difference between "x" and "w": "w" would have silently wiped the existing file.
"x" is useful when you genuinely need to avoid clobbering existing data. A few real examples:
In all three cases, the alternative ("check if the file exists, then open with w") has a race condition: another process could create the file in the gap between the check and the open. "x" is a single operation at the OS level, so it doesn't have that gap.
Like "a", "x" is write-only by default. Pair it with + if you also need to read from the new file in the same session.
+ Flag: Adding the Missing DirectionBy themselves, r, w, a, and x give you one direction (read or write) plus a specific behavior around the file's existence and cursor. The + flag layers on the other direction without changing anything else. It does not change whether the file is truncated, whether it must exist, or where the cursor starts.
| Mode | Reads? | Writes? | Truncates on open? | Creates if missing? | Fails if file exists? | Cursor starts at |
|---|---|---|---|---|---|---|
r | yes | no | no | no | no | start |
w | no | yes | yes | yes | no | start |
a | no | yes | no | yes | no | end (writes only) |
x | no | yes | no | yes | yes | start |
r+ | yes | yes | no | no | no | start |
w+ | yes | yes | yes | yes | no | start |
a+ | yes | yes | no | yes | no | end (writes only) |
x+ | yes | yes | no | yes | yes | start |
Reading the table row by row makes the rules click: + only adds the missing direction. Everything else carries over.
r+: Read-Write on an Existing File"r+" opens an existing file for both reading and writing. The cursor starts at byte 0, and writes overwrite the bytes already there rather than inserting. If the file doesn't exist, you get FileNotFoundError, same as "r".
The write replaced the first line in place. Notice the new text was exactly the same length as the original. If you write a shorter string, you'll leave the old trailing characters behind. If you write a longer one, you'll trample into the next line. That's why r+ is mostly used for fixed-size record updates, not free-form edits.
w+: Read-Write That Truncates"w+" is "w" with reading added. The file is truncated on open, so you start with an empty file regardless of what was there. You can write, then seek back to the start, then read what you just wrote.
This pattern is useful for temporary scratch files: you want to build up some text, then read it back, and you don't care about preserving previous contents.
a+: Read-Write That Appends"a+" is "a" with reading added. The file is created if missing and never truncated. Writes always go to the end, even if you seek somewhere else first. Reads can happen anywhere, but the default cursor position right after opening varies by platform, so always call seek(0) before reading if you want to start at the top.
The write went to the end. The seek(0) then let us read the full file from the top. Skip the seek and the read would return an empty string on most systems, because the cursor was sitting past the last byte.
rb+, wb+, ab+All four + modes can be combined with b to get binary read-write. Most beginners won't need these often, but they show up when working with file formats that have headers you read first and then patch.
We patched the first two bytes without touching the rest of the file. This is exactly what r+ (or rb+ for binary) is designed for: small, targeted updates inside an existing file.
In text mode, Python has to translate between the str objects in your code and the bytes that actually live on disk. That translation is controlled by the encoding argument to open. Most of the time you want UTF-8.
The characters é, ü, and ã are not single bytes in UTF-8. They take two bytes each. If you open the same file in binary mode you can see this directly:
\xc3\xa9 is the UTF-8 encoding of é, \xc3\xbc is ü, and \xc3\xa3 is ã. Open the file with the wrong encoding and Python either misreads those bytes or refuses outright.
ASCII only knows the first 128 code points. The 0xc3 byte that starts é is outside that range, so the decoder gives up at position 13.
encoding=The default encoding when you don't pass one is "the platform's preferred locale encoding," available as locale.getpreferredencoding(). On macOS and most Linux systems that's UTF-8. On Windows it's traditionally been cp1252 (Western European) for many setups. So a file written on a Mac with é in it might be read incorrectly on a Windows machine that picked a different default.
This is a real-world source of bugs, common enough that Python added an opt-in UTF-8 mode (PEP 540) and started warning when encoding= is omitted. The fix is simple: pass encoding="utf-8" to every open call that opens a text file. It costs nothing, it makes the behavior platform-independent, and it documents your intent.
For binary mode, encoding is not allowed, because no translation happens.
errors= Controls What Happens on Bad BytesWhen the encoding can't decode some byte (or can't encode some character), the errors argument controls the response. The default is "strict", which raises UnicodeDecodeError (or UnicodeEncodeError). The two other common values are "replace" and "ignore".
"replace" substitutes a placeholder for each bad byte. "ignore" drops the bad bytes. Both are tools for processing messy input where crashing isn't acceptable, but neither is a substitute for picking the right encoding in the first place.
Cost: Decoding is fast for ASCII-heavy text but does have to scan every byte. For very large text files you read in full, the encode/decode pass is a real chunk of total time. Binary mode skips it entirely.
\n, \r\n, and the newline= ArgumentDifferent operating systems historically marked the end of a line differently. Unix-like systems (Linux, macOS) use a single \n (line feed). Windows uses \r\n (carriage return followed by line feed). Old classic Mac OS used \r alone. Python's text mode tries to hide this difference from you.
In text mode, by default:
\n, \r\n, or \r in the file is translated to \n in the string you receive.\n in your string is translated to os.linesep before it hits the disk (so \r\n on Windows, \n on Unix).That's why a piece of Python code that writes f.write("hello\n") produces a file that opens cleanly in Notepad on Windows and in cat on Linux. The translation happens for you.
The catch: this translation only happens in text mode. In binary mode, no translation happens at all. So this code behaves differently on Windows and Linux:
The string you wrote ("order-1\norder-2\n") is the same. What's on disk differs because text mode translated \n into the platform's preferred line ending.
newline= ArgumentTo control this translation, pass the newline argument to open. It accepts five values:
newline= value | Read behavior | Write behavior |
|---|---|---|
None (default) | Universal: \n, \r\n, \r all become \n | \n becomes os.linesep |
"" (empty) | Universal: same as None for line splitting | \n is written as-is, no translation |
"\n" | Only \n ends a line | \n is written as-is |
"\r" | Only \r ends a line | \n is written as \r |
"\r\n" | Only \r\n ends a line | \n is written as \r\n |
The two values that matter for everyday code are None (default, full translation) and "" (universal newlines on read, no translation on write).
The "" value is what the csv module documentation tells you to use, and it's worth understanding why. CSV files can legally contain embedded \r\n sequences inside quoted fields. If text mode translates those, the CSV parser sees corrupted data. Using newline="" keeps every newline byte intact on write and still handles all three line-ending styles on read.
The embedded newline inside the second product's name survived the round trip because newline="" told Python not to translate. The takeaway here is that the newline argument exists for exactly this kind of case.
For plain log files where you don't care about embedded newlines, the default is fine. Just be aware that you can't compare byte-for-byte across platforms unless you control the newline behavior explicitly.
Cost: Newline translation in text mode scans every character on the way in and out. It's cheap per character but adds up on very large files. Binary mode (which does no translation) is measurably faster for byte-perfect copies.
When you call f.write("hello"), the string doesn't necessarily go to disk right away. Python keeps a buffer in memory and flushes it when the buffer fills up, when you call f.flush(), or when the file is closed. Buffering is what makes file I/O fast: writing one byte at a time would mean one system call per byte, which is far slower than batching them.
The buffering argument to open controls the buffer size:
buffering= value | Meaning |
|---|---|
0 | No buffering. Allowed only in binary mode. |
1 | Line buffering. Flush after each \n. Text mode only. |
> 1 | Use that many bytes as the buffer size. |
| Negative (default) | Use the system default (usually around 8KB). |
The defaults are good for most cases. Two scenarios call for changing them:
Line buffering for interactive output. A log file you want to tail in real time should use buffering=1 so every line shows up as soon as it's written, instead of waiting for an 8KB buffer to fill.
Without line buffering, those two writes might sit in memory for a while before being flushed. With it, each line is durable as soon as the newline is written.
No buffering for binary files where each byte matters. Setting buffering=0 gives you an unbuffered binary file, where every write is a direct system call. This is rare in application code; you'd usually call f.flush() after important writes instead.
The other thing worth knowing is that Python flushes the buffer automatically when you close the file, including when a with block exits normally. So this works correctly even though no explicit flush is called:
The danger is when you skip with, forget to call close, and crash before Python's cleanup runs. In that case the buffered writes are lost. This is the underlying reason with is the right default for file handling.
The diagram traces what happens after each write. Python copies the data into an in-memory buffer. The buffer only drains to the OS when one of three things happens: the buffer is full, you explicitly call flush, or the file is closed. Anything that stops the program before the buffer drains loses those writes.
Every commonly-used combination of mode flags, with all the relevant behaviors in one place:
| Mode | Reads? | Writes? | Truncates on open? | Creates if missing? | Fails if exists? | Cursor at open | Data type |
|---|---|---|---|---|---|---|---|
r | yes | no | no | no | no | start | str |
rt | yes | no | no | no | no | start | str |
rb | yes | no | no | no | no | start | bytes |
w | no | yes | yes | yes | no | start | str |
wt | no | yes | yes | yes | no | start | str |
wb | no | yes | yes | yes | no | start | bytes |
a | no | yes | no | yes | no | end (writes) | str |
at | no | yes | no | yes | no | end (writes) | str |
ab | no | yes | no | yes | no | end (writes) | bytes |
x | no | yes | no | yes | yes | start | str |
xb | no | yes | no | yes | yes | start | bytes |
r+ | yes | yes | no | no | no | start | str |
rb+ | yes | yes | no | no | no | start | bytes |
w+ | yes | yes | yes | yes | no | start | str |
wb+ | yes | yes | yes | yes | no | start | bytes |
a+ | yes | yes | no | yes | no | end (writes) | str |
ab+ | yes | yes | no | yes | no | end (writes) | bytes |
x+ | yes | yes | no | yes | yes | start | str |
xb+ | yes | yes | no | yes | yes | start | bytes |
The patterns to keep in mind:
r, w, a, x to set existence and truncation behavior.b for bytes, leave it off (or write t) for str.+ to also get the missing direction. It doesn't change anything else.The decision flow shows the rough question to ask before picking a mode. Reading is "r". Writing splits into "I'm fine wiping the file" ("w"), "I want to fail if a file exists" ("x"), and "I want to extend an existing file" ("a"). Read-and-write splits the same way, with + added to each.
A few patterns that bite beginners enough times to be worth listing explicitly.
"w" When You Meant "a"This is the truncation bug from the append section, but it's the single most common file-mode mistake. Any loop or function that opens with "w" once per call will overwrite the file every call, so only the last write survives. If you want to accumulate, use "a".
encoding="utf-8"A program that runs fine on the author's Mac and breaks on a teammate's Windows machine often comes down to a missing encoding argument. The default is platform-dependent. Always pass encoding="utf-8" explicitly for text files unless you have a specific reason to use something else.
bytes in Text Mode (or str in Binary Mode)TypeError: write() argument must be str, not bytes and TypeError: a bytes-like object is required, not 'str' are the two faces of the same mistake. Pick the right mode for the data type you have. If you have a str and want to write bytes, call .encode("utf-8"). If you have bytes and want to write str, call .decode("utf-8").
"w" or "a"Plain "w" and "a" are write-only. Calling .read() on them raises UnsupportedOperation. If you need to read what you've written, either close the file and reopen it in "r", or use "w+" / "a+" to allow both directions.
"r+" to Insert"r+" overwrites existing bytes, it doesn't insert. There's no way at the OS level to insert bytes in the middle of a file without rewriting everything after. If you need to insert, read the whole file into memory, modify it there, and write it back.
"a+"The cursor position right after opening with "a+" is platform-dependent. On some systems it's at the end, on others it's at the start. Always call f.seek(0) before reading, or f.seek(0, 2) to be explicit about going to the end. (seek(0, 2) means "seek 0 bytes from the end of the file.")
Once you open a file in text mode, the bytes are decoded as soon as you read them. You can't undo that mid-stream. If a file has both text headers and binary content, open it in binary mode and decode the parts you need yourself.
What's wrong with this code?
The function meant to replace line 2 with a new review. Instead, it kept the original line and inserted the new one after it, and the result is now off by one. Two things went wrong. First, r+ overwrites bytes; it doesn't replace lines. The cursor moved past "notebook: great\n", so the write landed on "pen: poor\n" and overwrote those exact bytes, with leftover characters because the new text is longer. Second, even without that, "skip n lines and write" doesn't truncate or move the rest. To do an in-place line replacement, you generally read the whole file, modify the right line in memory, and write the file back from the start.
Fix:
Now the file is fully rewritten, and any change in line length is handled correctly.
10 quizzes