Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~28 min· topic 5 of 12

Topic 7.5

Reading and Writing Text Files

In one line

open() connects your program to a file. The mode says whether you read, write or append, the encoding says how characters become bytes (always pass encoding="utf-8"), and iterating over the file reads one line at a time without loading it all.

Think of it like this

A notebook on a desk. To read it you open it at the first page and go forward. To write with a fresh start you tear out all the old pages first. To append you open it at the end and add new lines. Which of these you intend must be said before you touch the notebook. In Python the notebook is a file, and the thing you say first is the mode.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

File object
What open() returns: a handle you use to read from or write to a file.
Mode
A short string saying what you plan to do: r read, w write (erases), a append, x create new, b binary.
Encoding
The rule for converting characters to bytes and back. UTF-8 is the standard choice.
Bytes
Raw numbers from 0 to 255. All files are made of bytes; text is bytes plus an encoding.
Buffer
A holding area in memory. Writes collect there and reach the disk later, on flush or close.
Newline
The invisible character that ends a line. Written as \n in Python.
Lazy reading
Reading a little at a time as needed, instead of loading everything.
Mojibake
Garbled text like é that appears when text is decoded with the wrong encoding.

Step by step

01Write a file

Mode "w" creates the file or empties it. write takes a string and does not add a line break, so we end each line with \n ourselves. Everything is inside with (Topic 7.4), so the file is closed (and the data saved) at the end of the block.

main.pywhole filepython
with open("shopping.txt", "w", encoding="utf-8") as f:
    f.write("milk\n")
    f.write("bread\n")
    print("eggs", file=f)       # print adds the newline

02Append without erasing

Mode "a" keeps what is there and adds at the end. Opening with "w" again would have deleted the three lines, which is a classic accident.

main.pywhole filepython
with open("shopping.txt", "a", encoding="utf-8") as f:
    f.write("rice\n")

03Read it back in three ways

read() gives one big string. readlines() gives a list of lines (each with its \n). Iterating gives one line at a time. For anything that might be big, iterate.

main.pywhole filepython
with open("shopping.txt", encoding="utf-8") as f:
    everything = f.read()          # one string

with open("shopping.txt", encoding="utf-8") as f:
    for line in f:                 # one line at a time
        print(line.rstrip("\n"))

04Text is bytes plus an encoding

.encode("utf-8") turns text into bytes, and .decode("utf-8") turns bytes back into text. A file opened in text mode does this for you. The word café is 4 characters but 5 bytes, because é needs two bytes in UTF-8. The b'...' form is how Python shows bytes, and \xc3\xa9 means the two byte values 195 and 169 written in hexadecimal (base 16).

main.pywhole filepython
data = "café".encode("utf-8")
print(len("café"), len(data), data)   # 4 5 b'caf\xc3\xa9'
print(data.decode("utf-8"))            # café
print(data.decode("latin-1"))          # café  (wrong encoding: mojibake)
Text is bytes plus an encodingdiagram
Rendering diagram…

05Newlines are translated for you

In text mode a Windows-style line ending in the file is read as plain \n. Pass newline="" to see the raw endings. For ordinary text files leave the default.

main.pywhole filepython
with open("win.txt", "w", encoding="utf-8", newline="") as f:
    f.write("a\r\nb\r\n")             # write raw Windows endings

with open("win.txt", encoding="utf-8") as f:
    print(repr(f.read()))             # 'a\nb\n'

with open("win.txt", encoding="utf-8", newline="") as f:
    print(repr(f.read()))             # 'a\r\nb\r\n'

06Big files: never read() what you can iterate

This counts the ERROR lines in a log file (a file where a program writes down what happened; the next example builds one so you can run it). for line in f keeps only the current line in memory. You can stop early with break when you have what you need. Compare this with f.read().splitlines(), which builds the whole file as one string and then a list of all the lines as well.

main.pywhole filepython
count = 0
with open("big.log", encoding="utf-8") as f:
    for line in f:
        if "ERROR" in line:
            count += 1
print(count)

Try it yourself

  1. 1

    Overwrite by accident

    In the first example change the second mode to "w" and run it. Which lines are left? Change it back to "a" and note the difference. Then try mode "x" on the very first open and run it twice. What does the second run say?

  2. 2

    Count bytes

    In the second example change the text to "hello", then to "नमस्ते". Compare the characters and bytes counts. How many bytes does one Hindi letter take in UTF-8?

  3. 3

    Find the line

    In the last example make the program print the line number (starting from 1) of the second ERROR line. Hint: use enumerate(f, start=1).

Code & diagrams

Write, append, read New tab

Change the second open from "a" to "w" and see what happens to the first lines.

Sign in to run this example in your browser.

Expected output

1 milk
2 bread
3 eggs
4 rice
['milk\n', 'bread\n', 'eggs\n', 'rice\n']
Characters, bytes and encodings New tab

The same bytes give different text under different encodings. That is why you always state the encoding.

Sign in to run this example in your browser.

Expected output

characters: 6 bytes: 9
bytes: b'caf\xc3\xa9 \xe2\x82\xb9'
decoded right: café ₹
decoded wrong: café ₹
file holds: b'caf\xc3\xa9 \xe2\x82\xb9'
Newlines and a failed decode New tab
Sign in to run this example in your browser.

Expected output

default : 'a\nb\n'
raw     : 'a\r\nb\r\n'
decode error: unexpected end of data at byte 3
with errors=replace: caf�
Process a file lazily and stop early New tab

Imagine 10 million lines instead of 2000: the loop still holds only one line at a time and stops at the first match.

Sign in to run this example in your browser.

Expected output

errors: 4
first: 0499 ERROR request handled
['0000 INFO request handled', '0001 INFO request handled', '0002 INFO request handled']

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Open a file that does not exist

Read a file that was never created.

main.pywhole filepython
with open("missing.txt", encoding="utf-8") as f:
    print(f.read())
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 1, in <module>
with open("missing.txt", encoding="utf-8") as f:
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 44] No such file or directory: 'missing.txt'

Break #2

Read bytes with the wrong encoding

Save text as Latin-1 and read it as UTF-8.

main.pywhole filepython
with open("old.txt", "wb") as f:
    f.write("café".encode("latin-1"))

with open("old.txt", encoding="utf-8") as f:
    print(f.read())
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 5, in <module>
print(f.read())
~~~~~~^^
File "<frozen codecs>", line 325, in decode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: unexpected end of data

Myth vs fact

Myth

encoding is optional because Python works it out.

Fact

Python does not detect the encoding. It uses a default that depends on the system (until UTF-8 became the default in 3.15), so the same code can read a file correctly on one machine and fail on another. State it.

Myth

f.write(42) writes the number 42.

Fact

write only takes strings and raises TypeError for anything else. Write str(42) or an f-string.

Myth

Mode "w" adds to the file.

Fact

"w" truncates (empties) the file the moment you open it, before you write anything. Use "a" to add.

Myth

Reading the whole file with read() is fine because computers have lots of memory.

Fact

A log or dataset can be larger than memory. Iterate by line (or by chunk) unless you know the file is small.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    File objects are layered: a raw binary stream, wrapped by a BufferedReader/BufferedWriter, wrapped by a TextIOWrapper that does the decoding and newline translation. open(..., "rb") returns the buffered layer without the text wrapper.

  • ▸

    Writes are buffered, so data can be lost on a crash until the buffer is flushed. f.flush() pushes it to the operating system, and os.fsync(f.fileno()) asks the system to write it to disk. For safe replacement of a file, write to a temporary file in the same folder and then os.replace it over the target.

  • ▸

    Use encoding="utf-8-sig" when reading files produced by some Windows programs (such as Excel's CSV export) that begin with a byte-order mark; plain utf-8 would leave an invisible \ufeff at the start of the first line.

  • ▸

    Large model datasets are usually .jsonl (one JSON object per line, Topic 7.7), precisely because that format can be streamed line by line with the loop on this page.

  • ▸

    To find every place a codebase relies on the locale default, run it with python -X warn_default_encoding (or set PYTHONWARNDEFAULTENCODING=1): since Python 3.10 each open() without encoding then emits an EncodingWarning. PYTHONUTF8=1 (or -X utf8) already switches the default to UTF-8 today, and encoding="locale" (3.10) says explicitly that you want the system encoding. Note that data in other files, such as a CSV from a colleague's Windows machine, may still be cp1252, so the default changing does not remove the need to know the real encoding of the input.

  • ▸

    Bad bytes do not have to be fatal or lossy: errors="surrogateescape" turns undecodable bytes into lone surrogate characters and turns them back into the same bytes on writing, so a tool can pass through file names or text it cannot fully decode without damaging it. Also, once you have used a text file as an iterator (for line in f), f.tell() raises OSError ("telling position disabled by next() call"): use readline() in a while loop if you need positions.

Remember this

  1. 1

    open(path, mode, encoding=...) returns a file object: a handle you use to read or write. The path is the file's name, such as "notes.txt", optionally with folders in front. A bare name means "in the folder where the program runs". The mode is a short string. "r" reads (the default; the file must exist). "w" writes and erases whatever the file had before, creating it if needed. "a" appends to the end. "x" creates a new file and fails with FileExistsError if it exists, which protects you from overwriting. Adding "+" allows both reading and writing, and "b" means binary (raw bytes) instead of text. The default is text ("t").

  2. 2

    Computers store only bytes: numbers from 0 to 255. An encoding is the agreement for turning characters into bytes and back. UTF-8 is the standard that can represent every character in every language and is used almost everywhere on the web. Plain English letters take one byte in UTF-8. Other characters take two to four (é takes two, ₹ takes three). If the writer and the reader use different encodings, you get garbled text (mojibake) or a UnicodeDecodeError.

  3. 3

    Always write encoding="utf-8" in open, for both reading and writing. Without it Python uses a default that depends on the operating system and settings (often something else on Windows). The same program then behaves differently on two machines. Python 3.15 makes UTF-8 the default text encoding, which will remove this trap in time. Code that spells out encoding="utf-8" is correct on every version and every machine, so keep writing it.

  4. 4

    To read: f.read() returns the whole file as one string, f.readline() returns one line, f.readlines() returns a list of lines. The best way to go through a file is to iterate over the file object: for line in f: (a for loop over the file, Topic 2.4, gives you one line per turn). It reads one line at a time from a buffer, so a 20 GB file uses almost no memory. Each line still ends with "\n". Use line.rstrip("\n") or line.strip() to remove it. This lazy reading is how big AI datasets in .jsonl files are processed.

  5. 5

    To write: f.write(text) writes a string (not a number: convert with str() or an f-string) and returns the number of characters written. It does not add a newline, so you write "\n" yourself. f.writelines(list_of_strings) writes many strings, also without newlines. print(text, file=f) writes with a newline added, which is often the neatest way.

  6. 6

    Newlines differ between systems: Windows ends lines with \r\n, Linux and macOS with \n. In text mode Python hides this. When reading, every kind of line ending becomes "\n". When writing, "\n" becomes the system's ending. Leave this default alone for ordinary files. When you must keep line endings exactly (a CSV file, Topic 7.7), pass newline="".

  7. 7

    And always use with (Topic 7.4) so the file is closed and the data is flushed (pushed out of the buffer onto the disk).

Explain it without notes

01

Explain the difference between the modes "r", "w", "a" and "x".

02

Why should you always pass encoding="utf-8" to open, even though Python 3.15 makes UTF-8 the default?

03

How do you process a file too big for memory? Why does iterating a file object work?

04

What happens to newline characters when you read and write text files, and what is newline="" for?

Practice

01

Write the numbers 1 to 5, one per line, to a file called numbers.txt. Then read the file back and print the sum of the numbers (it should print 15). Hint: each line is text, so convert it with int().

02

Write a function count_lines(path) that returns how many non-empty lines a text file has, reading it line by line (not with read()). Call it on numbers.txt. Hint: a line that is empty after strip() should not be counted.

03

Append the line done to numbers.txt (mode "a"), then print the file's last line. Hint: loop over the lines and remember the latest one.

Trade-offs

  • ↔

    **read() versus iterating.** read() is convenient for small files and when you need the whole text (a regular expression over everything). Iterating scales to any size but gives you only one line at a time.

  • ↔

    Strict versus forgiving decoding. The default errors="strict" stops on damaged text so you notice. errors="replace" keeps going and inserts \ufffd characters, which can hide data problems. Choose strict unless losing a few characters is acceptable.

Done when you can

  • Done when you can choose between modes r, w, a and x and say what each does to existing content.

  • Done when you can read a file by iterating over lines and strip the newline.

  • Done when you can explain what an encoding is and why encoding="utf-8" is always written.

  • Done when you can explain what happens to \n on reading and writing, and when to use newline="".

  • Done when you can handle FileNotFoundError and UnicodeDecodeError sensibly.