Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 8 – Files, paths and data formats

Reading and writing text files safely

Open text files in Python with the right mode and encoding, read them whole or line by line, close them with with, and save changes atomically.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Choose the open() mode that does what you need to a file that exists
  • Read and write text with an explicit encoding, and recognise mojibake
  • Read whole files, single lines and large files in little memory
  • Predict the line endings that text mode reads and writes
  • Use with so files close on every path, and save a file atomically

Before you start

What you’ll learn · Before you start

What you will learn

  • Choose the open() mode that does what you need to a file that exists
  • Read and write text with an explicit encoding, and recognise mojibake
  • Read whole files, single lines and large files in little memory
  • Predict the line endings that text mode reads and writes
  • Use with so files close on every path, and save a file atomically

Before you start

On this page

open("notes.txt", "w") empties a file the moment it opens it, so a program that fails before its first write() has already lost every line. Reading has traps of its own: the 12 bytes that hold Café ₹250 come back as Café ₹250 when a program reads them as Windows-1252, the default of many Windows computers, and read() needs about twice a file’s size in memory.

The safe way to read a text file is open(path, encoding="utf-8") in a with statement, then read() for a small file or a loop over the file object: What open() returns: an object with methods such as read(), write() and close() for one open file, which keeps its own position in that file. In text mode it reads and writes str; in binary mode, bytes. for one line at a time. To write one, open it with "w" to replace it or "a" to add to its end. Below you will step through each mode on a real file, watch bytes turn into the wrong text, measure what reading costs, and save a file so that a crash never leaves half of it behind.

The mode decides what open() does to an existing file

open() takes the file’s path, a mode and, for text, an encoding: A rule for writing text as bytes and reading it back, such as UTF-8, UTF-16 or Latin-1. str.encode() and bytes.decode() take its name, and the bytes themselves never say which encoding wrote them. More in “Text vs bytes: Unicode, encodings, bytearray”, and returns a file object: the thing you read from and write to. It opens whatever path it is given, so a name that a user typed, such as ../../secret.txt, can point outside your folder; the next lesson, on pathlib, shows how to refuse one. The mode is a short string, and each one treats an existing file differently:

Mode Opens the file for If the file exists If it does not exist
"r", the default reading reads it from the start raises FileNotFoundError
"w" writing empties it at once creates it
"a" appending writes after its last byte creates it
"x" writing a new file raises FileExistsError creates it

Two letters combine with these. "b" opens the file in binary mode ("rb", "wb"), where you read and write bytes instead of str; a later lesson of this module is about binary files. "+" opens it for reading and writing at once: "r+" keeps the contents, while "w+" empties the file like "w". Step through one diary file opened in all four modes, and watch what it holds after each call:

Interactive · message by message One diary file, opened in four modes Step through diary.py. Each step is an open() or a write(), and the line under diary.txt says what the file holds after it.
One diary file, opened in four modes, step 7 of 7. Messages so far: Your program to diary.txt: open("diary.txt", "w"); Your program to diary.txt: write() twice; Your program to diary.txt: open(…, "a"), write(); diary.txt to Your program: read(): 3 lines; Your program to diary.txt: open(…, "x"); Your program to diary.txt: open(…, "w"); Your program to diary.txt: write("Thu: …").Your programdiary.pydiary.txt1 line: Thuopen("diary.txt", "w")write() twiceopen(…, "a"), write()read(): 3 linesopen(…, "x")open(…, "w")write("Thu: …")

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. "w" creates the file. open("diary.txt", "w") finds no file, so it creates an empty one and returns a file object to write to.
  2. Two lines. Two write() calls add Monday and Tuesday. The with block ends and closes the file.
  3. "a" keeps the lines. Mode "a" opens the file without touching what is in it, and write() adds Wednesday after its last byte.
  4. "r" reads. With no mode, open() reads. read() returns the three lines as one string, and the program prints them.
  5. "x" refuses. Mode "x" only creates new files. diary.txt exists, so open() raises FileExistsError before anything is written, and the file keeps its three lines.
  6. "w" empties at once. Mode "w" again. The file is emptied the moment it is opened, before the first write(), with no error and no warning.
  7. Only Thursday. write() adds the one new line, and reading the file back shows that it is all the file holds.

Here is the same program, to run and to change:

One diary file, four modes Python · diary.py
# "w" creates the file, or empties it if it already exists.
with open("diary.txt", "w", encoding="utf-8") as f:
    f.write("Mon: installed Python\n")
    f.write("Tue: wrote a loop\n")

# "a" adds to the end and keeps what is there.
with open("diary.txt", "a", encoding="utf-8") as f:
    f.write("Wed: opened my first file\n")

# "r" (the default mode) reads.
with open("diary.txt", encoding="utf-8") as f:
    print(f.read(), end="")

# "x" creates a new file, and refuses to touch one that exists.
try:
    with open("diary.txt", "x", encoding="utf-8") as f:
        f.write("this never happens\n")
except FileExistsError as err:
    print("x refused:", type(err).__name__, err.filename)

# Opening with "w" again throws the old lines away at once.
with open("diary.txt", "w", encoding="utf-8") as f:
    f.write("Thu: lost the first three days\n")

with open("diary.txt", encoding="utf-8") as f:
    print(f.read(), end="")

Output

Mon: installed Python
Tue: wrote a loop
Wed: opened my first file
x refused: FileExistsError diary.txt
Thu: lost the first three days

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 diary.py

The second block kept Monday and Tuesday because "a" writes after what is there. The "x" block refused to start, so the diary was safe from it. The last "w" threw three days away before writing a single character: there was no error and no warning, because emptying the file is exactly what "w" promises. Press Edit, change that last "w" to "a" and run it again: the diary keeps all four days. Use "x" when you create files that must never replace an older one, such as a report named after today’s run, and keep "w" for files you mean to rewrite.

Note

Examples that you run in the browser write their files to an in-memory file system. The files exist only while the page is open and vanish when you reload it; nothing is written to your computer.

To change one line of a file, no mode does the job on its own:

Opening a file with "w" to change one line

open("config.txt", "w") empties the file before your first write(), so writing the changed line leaves that line alone in the file. Read the old text with "r", build the new text, then write all of it, through a temporary file if the old version matters (the last section shows how).

Checkpoint

Check: What each mode does to a file

  1. Question 1 of 2 notes.txt holds ten lines. A program runs with open("notes.txt", "w", encoding="utf-8") as f: and fails with an exception before its first write(). What does notes.txt hold now?

    Choose one answer.

    A hint

    Think about the moment "w" does its work, before or after the first write().

    Show the answer: Question 1 of 2

    Answer: Nothing, because "w" emptied the file when it opened it

    Mode "w" empties an existing file at the moment it opens it, before any write. Whatever happens afterwards, the old lines are gone. Read with "r" first, or write a temporary file and swap it in with os.replace().

  2. Question 2 of 2 A nightly job saves its report as report-<day>.txt. If a file of that name already exists, the job must stop rather than replace it. Which mode should it open the file with?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: "x"

    Exclusive creation, mode "x", is the only mode that refuses an existing file. The job can catch FileExistsError and report the clash instead of losing the earlier report.

The same bytes are different text under another encoding

A file holds bytes, not characters. Writing a str to a text file encodes it, turning each character into one or more bytes, and reading decodes the bytes back into characters. Both directions need the same encoding, and UTF-8: The Unicode encoding that writes each code point as 1 to 4 bytes: 1 for ASCII, 2 for é, 3 for Devanagari and 4 for most emoji. Any ASCII text is also valid UTF-8, byte for byte. More in “Text vs bytes: Unicode, encodings, bytearray”, which can store every character of every script, is the one to choose for new files. The lesson Text vs bytes shows how UTF-8 uses one to four bytes per character.

The same bytes under two encodings Python · encodings.py
text = "Café ₹250\nनमस्ते\n"

with open("menu.txt", "w", encoding="utf-8") as f:
    f.write(text)

with open("menu.txt", "rb") as f:  # "b": the bytes exactly as they are on disk
    raw = f.read()
print(len(text), "characters were saved as", len(raw), "bytes")

# The right encoding gives the text back.
with open("menu.txt", encoding="utf-8") as f:
    print(f.read() == text)

# Windows-1252, a common locale encoding on Windows, reads the same bytes as other characters.
with open("menu.txt", encoding="cp1252", errors="replace") as f:
    print(f.readline(), end="")

# Without errors="replace", a byte that means nothing in that encoding stops the read.
try:
    with open("menu.txt", encoding="cp1252") as f:
        f.read()
except UnicodeDecodeError as err:
    print(f"{type(err).__name__}: byte {err.object[err.start]:#04x} at position {err.start} has no meaning in cp1252")

# The bytes behind the garbled text: each character, its UTF-8 bytes, and those bytes read as Windows-1252.
for ch in "Cé₹न":
    raw = ch.encode("utf-8")
    print(ch, raw.hex(" "), raw.decode("cp1252"))

Output

17 characters were saved as 32 bytes
True
Café ₹250
UnicodeDecodeError: byte 0x8d at position 24 has no meaning in cp1252
C 43 C
é c3 a9 é
₹ e2 82 b9 ₹
न e0 a4 a8 न

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 encodings.py

Seventeen characters became 32 bytes, because é takes two bytes in UTF-8, ₹ takes three, and each of the six Devanagari code points of नमस्ते takes three. Read back as UTF-8, the text comes out unchanged. Read as Windows-1252, the bytes on disk are the same, yet é turns into é and ₹ into ₹: this garbled text is known as mojibake: Garbled text that comes from decoding bytes with the wrong encoding, such as é where é was meant: the two UTF-8 bytes of é read as Windows-1252. No error is raised. More in “Text vs bytes: Unicode, encodings, bytearray”. The last four lines of the output show why, byte by byte, and the table lines them up. Windows-1252 gives almost every byte a character of its own, so a character that UTF-8 stores in two or three bytes comes back as two or three characters:

Character Its bytes in UTF-8 The same bytes read as Windows-1252
C 43 C
é c3 a9 é
₹ e2 82 b9 ₹
न, the first code point of नमस्ते e0 a4 a8 न
्, its fourth code point e0 a5 8d an error: byte 8d means nothing

The Hindi line shows the second way a wrong encoding fails. The strict reader stopped at byte 0x8d, at position 24, the last byte of the fourth code point, because Windows-1252 gives that byte no meaning. The option errors="replace" shows such bytes as � instead of stopping, which helps when you only want to look at a damaged file.

Saving text that was read with errors="replace"

errors="replace" turns every byte it cannot decode into �, and errors="ignore" drops it; neither keeps the original bytes anywhere. Use them to look at a damaged file, never on text you will save again: the characters they replaced are lost for good.

That second encoding was not chosen at random. When you leave encoding out, Python 3.14 uses the computer’s locale encoding: The encoding a computer's settings name for text, which open() uses in Python 3.14 when no encoding is given: UTF-8 on most Linux and macOS systems, often a legacy code page such as Windows-1252 on Windows., which is UTF-8 on most Linux and macOS systems and often a legacy code page such as Windows-1252 on Windows. The same script can then read the same file correctly on one computer and garble it on another, which is why every open() in text mode: How open() opens a file unless its mode contains "b": it decodes the bytes it reads into str and encodes the str it writes, with the file's encoding, and translates line endings on the way. in this lesson names its encoding. To find the calls that do not, start Python with -X warn_default_encoding, and each one reports an EncodingWarning:

Finding open() calls without an encoding Python · encoding_warning.py
import subprocess
import sys

# A second Python, started with -X warn_default_encoding, reports every open() without an encoding.
program = 'open("notes.txt", "w").close()\nopen("notes.txt", "w", encoding="utf-8").close()'
result = subprocess.run(
    [sys.executable, "-X", "warn_default_encoding", "-c", program],
    capture_output=True,
    text=True,
)
print(result.stderr, end="")
print("warnings:", result.stderr.count("EncodingWarning"))

Output

<string>:1: EncodingWarning: 'encoding' argument not specified
warnings: 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 encoding_warning.py

Version note

Python 3.15 makes UTF-8 the default encoding of open() whatever the locale (PEP 686); Python 3.14 and older use the locale encoding. Code that passes encoding="utf-8" behaves the same on every version. When you really want the locale’s encoding, say so with encoding="locale" (Python 3.10 and later), and EncodingWarning stays quiet.

Unicode Character Inspector Paste a line from a garbled file to see its characters one by one, including invisible ones such as a byte order mark or a carriage return.

Checkpoint

Check: Encodings and the default one

  1. Question 1 of 2 How many bytes does UTF-8 use for the four characters ₹250?

    Type a number.

    A hint

    The table gives the bytes of ₹. Each digit is an ASCII character.

    Show the answer: Question 1 of 2

    Answer: 6 bytes

    ₹ takes three bytes in UTF-8 (e2 82 b9) and each of the digits 2, 5 and 0 takes one, so the four characters take 3 + 1 + 1 + 1 = 6 bytes. len() of the str counts 4 characters; len() of its UTF-8 bytes counts 6.

  2. Question 2 of 2 Your program saves Café in a file with encoding="utf-8". On a colleague's computer another program shows it as Café. What happened?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The other program decoded the UTF-8 bytes with a one-byte encoding such as Windows-1252

    The bytes on disk are right. Reading them with another encoding turns each of é's two bytes into its own character, which is mojibake. The cure is to name the encoding when reading too: encoding="utf-8".

read() takes the whole file, a loop takes one line at a time

A file object offers several ways to read, and each returns str in text mode:

read(), readline(), readlines() and a loop Python · read_styles.py
with open("todo.txt", "w", encoding="utf-8") as f:
    f.write("buy milk\ncall the bank\n\nwater the plants\n")

with open("todo.txt", encoding="utf-8") as f:
    whole = f.read()  # one str with every line
print(repr(whole))

with open("todo.txt", encoding="utf-8") as f:
    first = f.readline()  # one line, with its "\n"
    rest = f.readlines()  # a list of the lines that are left
print(repr(first))
print(rest)

# Looping over the file gives one line at a time: the usual way.
with open("todo.txt", encoding="utf-8") as f:
    for number, line in enumerate(f, start=1):
        task = line.rstrip("\n")
        if task:  # skip the empty line
            print(number, task)

# At the end of the file, read() and readline() return "" instead of raising an error.
with open("todo.txt", encoding="utf-8") as f:
    f.read()
    print(repr(f.read()), repr(f.readline()))

Output

'buy milk\ncall the bank\n\nwater the plants\n'
'buy milk\n'
['call the bank\n', '\n', 'water the plants\n']
1 buy milk
2 call the bank
4 water the plants
'' ''

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 read_styles.py

read() returns the whole remaining text as one string. readline() returns the next line, and readlines() returns a list of all the lines that are left; both keep the "\n" at the end of each line. Looping over the file object gives you one line per pass, which is the usual way to read a text file. Remove the line ending with line.rstrip("\n") rather than strip(): strip() also removes spaces and tabs at the start of the line, which may be data.

At the end of file (EOF): The point where a stream has nothing left to read. input() raises EOFError there, sys.stdin.readline() returns an empty string, and a for loop over sys.stdin simply stops. More in “Input and output: print(), input() and stdin”, read() and readline() return an empty string instead of raising an exception, so a blank line inside a file comes back as "\n" and the end as "". Step through a loop that relies on that difference, over the same four tasks:

Interactive · line by line Reading todo.txt one line at a time Step through the loop and watch line. Each readline() call hands over the next line with its "\n"; the blank line and the end of the file come back differently.

Program · readline_loop.py

with open("todo.txt", "w", encoding="utf-8") as f:
    f.write("buy milk\ncall the bank\n\nwater the plants\n")

with open("todo.txt", encoding="utf-8") as f:
    line = f.readline()
    while line:
        print(repr(line))
        line = f.readline()
print("after the loop:", repr(line))

Variables

After line 9: f is <TextIOWrapper object>; line is ''.f<TextIOWrapper object>line''
  • changed in this step

Printed so far

'buy milk\n'
'call the bank\n'
'\n'
'water the plants\n'
after the loop: ''

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 21 steps are listed below.

Line run 9
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. with open("todo.txt", "w", encoding="utf-8") as f: runs.
  3. Line 2. f.write("buy milk\ncall the bank\n\nwater the plants\n") leaves f as it was.
  4. Line 1. with open("todo.txt", "w", encoding="utf-8") as f: runs. The first block is over, so the with statement closes the file, and the text that write() left in the buffer reaches todo.txt.
  5. Line 4. with open("todo.txt", encoding="utf-8") as f: runs.
  6. Line 5. line = f.readline() sets line to 'buy milk\n'.
  7. Line 6. while line: tests its condition: it is true (line is 'buy milk\n'), so the body runs.
  8. Line 7. print(repr(line)) prints 'buy milk\n'.
  9. Line 8. line = f.readline() sets line to 'call the bank\n' (it was 'buy milk\n').
  10. Line 6. while line: tests its condition: it is true (line is 'call the bank\n'), so the body runs again (pass 2).
  11. Line 7. print(repr(line)) prints 'call the bank\n'.
  12. Line 8. line = f.readline() sets line to '\n' (it was 'call the bank\n').
  13. Line 6. while line: tests its condition: it is true (line is '\n'), so the body runs again (pass 3). Key point The blank line comes back as "\n", a string of one character. It is not empty, so the loop goes on.
  14. Line 7. print(repr(line)) prints '\n'.
  15. Line 8. line = f.readline() sets line to 'water the plants\n' (it was '\n').
  16. Line 6. while line: tests its condition: it is true (line is 'water the plants\n'), so the body runs again (pass 4).
  17. Line 7. print(repr(line)) prints 'water the plants\n'.
  18. Line 8. line = f.readline() sets line to '' (it was 'water the plants\n').
  19. Line 6. while line: tests its condition: it is false (line is ''), so the loop ends. Key point At the end of the file readline() returned "", an empty string. An empty string is false, so the loop stops here, and only here.
  20. Line 4. with open("todo.txt", encoding="utf-8") as f: runs.
  21. Line 9. print("after the loop:", repr(line)) prints after the loop: ''. That was the last line, so the program ends. Four lines were read, the blank one included. A loop written for line in f: reads the same lines with less code.

The blank line came back as "\n", which is not empty, so the loop went on; only the end of the file gave "", and while line: stopped there. That difference is what lets a loop such as while line := f.readline(): stop at the right moment. The file object also remembers how far it has read: the second read() in the last block of read_styles.py returned nothing because the first one had already moved to the end. To read again from the top, open the file again or call f.seek(0).

Large files: loop over them, do not load them

read() and readlines() hold the whole file in memory, which is fine for a settings file and a problem for a log of several gigabytes. A loop holds one line at a time. This example writes a log of 100,000 lines and counts them both ways, measuring the memory each way needs with the standard library’s tracemalloc:

Memory needed by read() and by a loop, measured Python · big_file.py
import os
import tracemalloc

# A log of 100,000 lines, about 3.6 MB.
with open("visits.log", "w", encoding="utf-8") as f:
    for i in range(100_000):
        f.write(f"{i:06d} GET /products/{i % 250} 200 user{i % 1000}\n")
size = os.path.getsize("visits.log")
print(f"file size: {size / 1e6:.1f} MB")

# 1. read() the whole file into one string.
tracemalloc.start()
with open("visits.log", encoding="utf-8") as f:
    text = f.read()
errors_whole = text.count(" 404 ")
lines_whole = text.count("\n")
_, peak_whole = tracemalloc.get_traced_memory()
tracemalloc.stop()
del text

# 2. Loop over the lines: only one line is in memory at a time.
tracemalloc.start()
lines = 0
with open("visits.log", encoding="utf-8") as f:
    for line in f:
        lines += 1
_, peak_loop = tracemalloc.get_traced_memory()
tracemalloc.stop()

print("lines counted:", lines_whole, lines)
print(f"peak memory with read(): {peak_whole / 1e6:.1f} MB")
print(f"peak memory line by line: {peak_loop / 1e3:.0f} KB")

Output

file size: 3.6 MB
lines counted: 100000 100000
peak memory with read(): 7.4 MB
peak memory line by line: 149 KB

This output changes from run to run: the measured memory differs a little between Python builds and computers

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 big_file.py

Both ways counted the same 100,000 lines. read() needed about twice the size of the file at its peak, because the bytes it read and the string it decoded were in memory at the same moment; a file of 2 GB would need about 4 GB. The loop stayed at about 150 KB, and that figure does not grow with the file: a ten times larger log needs the same memory, only more time. Reach for read() when the file is small and you know it, and for the loop in every other case.

Calling read() on a file of unknown size

f.read() or f.readlines() on a log that may grow to gigabytes can use up the computer’s memory. Loop over the file object instead: for line in f: keeps one line at a time.

Checkpoint

Check: Reading whole files and lines

  1. One question A loop calls line = f.readline() again and again. Which value tells it that the end of the file has been reached?

    Choose one answer.

    Show the answer: One question

    Answer: ''

    readline() keeps the "\n" of every line, so a blank line comes back as '\n' and only the end of the file as ''. That difference is what lets while line := f.readline(): stop at the right moment.

Text mode translates line endings, and write() adds none

Lines end with "\n" on Linux and macOS and traditionally with "\r\n" on Windows. Text mode hides the difference:

A Windows file read three ways Python · newlines.py
# A file saved on Windows usually ends each line with "\r\n".
with open("windows.txt", "wb") as f:
    f.write(b"first\r\nsecond\r\n")

with open("windows.txt", encoding="utf-8") as f:
    print(repr(f.read()))  # text mode turns every line ending into "\n"
    print("line endings seen:", repr(f.newlines))

with open("windows.txt", encoding="utf-8", newline="") as f:
    print(repr(f.read()))  # newline="" leaves them as they are

with open("windows.txt", "rb") as f:
    print(f.read())  # binary mode never changes a byte

Output

'first\nsecond\n'
line endings seen: '\r\n'
'first\r\nsecond\r\n'
b'first\r\nsecond\r\n'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 newlines.py

By default, reading turns "\r\n" and a lone "\r" into "\n", so your code only ever sees "\n"; the file object’s newlines attribute records which endings it met. This is called universal newlines: Reading text so that every kind of line ending counts: in text mode "\n", "\r\n" and a lone "\r" all come back as "\n". The file object's newlines attribute records which endings it met.. When writing, text mode does the opposite and turns each "\n" into the platform’s line ending, which is "\r\n" on Windows. Passing newline="" switches the translation off in both directions, and the csv module needs exactly that, because it handles line endings itself (see CSV files with the csv module). Binary mode never changes a byte.

The line endings you write are yours to add. write() writes exactly the string you give it and returns the number of characters it wrote. It adds no line ending, and neither does writelines(), whose name suggests otherwise; print() with file= adds one, as on the screen:

write(), print(file=) and writelines() Python · write_methods.py
with open("shopping.txt", "w", encoding="utf-8") as f:
    count = f.write("milk")  # no line ending is added
    f.write(", bread\n")
    print("eggs", "rice", sep=", ", file=f)  # print() adds "\n" for you
    f.writelines(["tea\n", "sugar\n"])  # a list of strings, written as they are

print("write() returned", count)
with open("shopping.txt", encoding="utf-8") as f:
    print(f.read(), end="")

Output

write() returned 4
milk, bread
eggs, rice
tea
sugar

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 write_methods.py

"milk" and ", bread\n" ended up on one line because nothing separated them. When you write lines in a loop, put the "\n" in the string yourself, as in f.write(f"{name},{score}\n"), or use print(..., file=f). Between your str values and the disk, a text file object has two layers: the text layer, which encodes and translates line endings, and a output buffer: Memory where written text waits before it is sent on: a line at a time when standard output is a terminal, in larger blocks for a file or a pipe. print(..., flush=True) sends it at once. More in “Input and output: print(), input() and stdin”, which the next section is about.

A text file object between your program and the disk: a text layer that encodes and translates line endings, then a buffer, then bytes.Your programworks with strText layerencodes str to bytes (encoding=)and translates line endings (newline=)Bufferkeeps bytes in memory until it is full,or until flush() or close()File on diskholds only byteswrite()bytesflush() or close()read in chunksbytesread(), readline(),for line in f

What a file opened in text mode does when you write and when you read

Text description of the diagram

The diagram is a column of four boxes, read from top to bottom for writing and from bottom to top for reading.

  • Your program works with str values.
  • The text layer turns each str into bytes with the encoding you chose, and translates "\n" into the platform's line ending. When reading, it decodes bytes back into str and turns every kind of line ending into "\n".
  • The buffer keeps bytes in memory and hands them to the operating system when it is full, when you call flush(), or when the file is closed. When reading, it fetches the file in chunks.
  • The file on disk holds only bytes. A file opened in binary mode ("rb", "wb") skips the text layer, so you read and write those bytes unchanged.

Expecting write() to end the line

f.write(name) in a loop puts every name on one line. Write f.write(name + "\n"), or print(name, file=f), which ends the line for you.

Checkpoint

Check: Line endings in and out

  1. One question What does this print?

    Read the code, then choose one answer.

    with open("t.txt", "w", encoding="utf-8") as f:
        f.write("one")
        f.write("two")
        print("three", file=f)
    
    with open("t.txt", encoding="utf-8") as f:
        print(f.read().splitlines())
    Show the answer: One question

    Answer: ['onetwothree']

    The file holds "onetwothree\n": the two write() calls added no line ending, and print() added one "\n" at the end. splitlines() then finds one line.

Written text waits in a buffer until the file is closed

Writing does not put the bytes on the disk at once. The file object collects them in a buffer in memory, the lower layer of the diagram above, and passes them to the operating system when the buffer is full, when you call flush() and when the file is closed. Until then, other programs, and even another file object in the same program, see an older version of the file. Step through a write that waits, a close that delivers it, and a with block that fails:

Interactive · message by message Where written text waits until the file is closed Step through closing.py. Watch the middle column, the file object's buffer in memory, and what the file on disk holds at each step.
Where written text waits until the file is closed, step 8 of 8. Messages so far: Your program to The file on disk: open("log.txt", "w"); Your program to The buffer: write("started\n"); The file on disk to Your program: read() returns ''; The buffer to The file on disk: close() flushes; The file on disk to Your program: read(): "started\n"; Your program to The buffer: write("total: 42\n"); The buffer to The file on disk: with closes: flush.Your programclosing.pyThe bufferemptyThe file on diskreport.txt: "total: 42\n"open("log.txt", "w")write("started\n")read() returns ''close() flushesread(): "started\n"write("total: 42\n")with closes: flushclosed: True

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Open log.txt. open("log.txt", "w") creates log.txt on the disk, empty, and the file object gets a buffer in memory.
  2. write() fills the buffer. log.write("started\n") returns at once, but the text only reaches the buffer. The operating system has not been given a single byte yet.
  3. A second reader. A second file object opens log.txt and reads it. It gets an empty string, because the line is still in the first object's buffer.
  4. close() flushes. log.close() hands the buffer to the operating system, which puts the bytes in the file, and then closes the file.
  5. Now it is there. Read again, log.txt holds the line. Closing, or flush(), is what makes written text visible to other readers.
  6. A with block. The with block opens report.txt and writes "total: 42\n", which waits in the buffer like the first line did.
  7. An exception. The next line raises ValueError. The exception leaves the block, and on its way out the with statement closes the file, which flushes the buffer first.
  8. Saved and closed. The except clause prints the message, report.closed is True, and the file holds the line written before the error.

Here is the same run as a program:

A buffer, a close and a with block that fails Python · closing.py
# Without with: what you write waits in a buffer until the file is closed.
log = open("log.txt", "w", encoding="utf-8")
log.write("started\n")
with open("log.txt", encoding="utf-8") as reader:
    print("before close:", repr(reader.read()))
log.close()
with open("log.txt", encoding="utf-8") as reader:
    print("after close: ", repr(reader.read()))

# With with: the file is closed when the block ends, even when a line in it fails.
try:
    with open("report.txt", "w", encoding="utf-8") as report:
        report.write("total: 42\n")
        raise ValueError("the next figure was missing")
except ValueError as err:
    print("caught:", err)
print("closed:", report.closed)
with open("report.txt", encoding="utf-8") as f:
    print("saved:", repr(f.read()))

Output

before close: ''
after close:  'started\n'
caught: the next figure was missing
closed: True
saved: 'total: 42\n'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 closing.py

The first reader found the file empty although write() had returned: the text was still in the buffer. Closing the file flushed it. A file you never close may lose what is still in its buffer when the program is killed, and every open file takes a handle from the operating system, which allows each program only a limited number. The with statement makes the close automatic: the file is closed when the block ends, by its last line, a return or an exception, as the second half of the example shows. After the block the object still exists, but reading from it raises ValueError, because the file is closed.

Key idea

Open files in a with statement: however the block ends, the file is closed, and the text still in its buffer is written first.

Checkpoint

Check: Buffers, close() and with

  1. Question 1 of 2 A program runs log = open("log.txt", "w", encoding="utf-8") and log.write("started\n"). Before log.close(), a second file object reads log.txt. What does it get?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty string

    A file object collects written text in a buffer and hands it to the operating system when the buffer is full, on flush() and on close(). Until then other readers see the file as it was.

  2. Question 2 of 2 A with block writes "total: 42\n" to report.txt, then raises ValueError. After the exception is caught, what is true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The file is closed and holds the line

    However a with block ends, by its last line, a return or an exception, the file is closed, and the text still in its buffer is written first. Reading it after the block raises ValueError, because it is closed.

An atomic save never leaves half a file

Rewriting a file in place has a weak moment. "w" empties the file first and fills it afterwards, so a crash, a power cut, a full disk or an exception in the middle leaves only part of the new data, and none of the old. Settings files, saved games and downloaded data deserve better. The safe pattern writes the new version to a temporary file in the same folder and then swaps it in with os.replace(). Step through both ways of saving, cut off at the same moment, and then a save that completes:

Interactive · message by message Two saves cut off at the same moment, then one that completes Step through atomic_save.py. Watch what settings.txt holds when each save stops, and where the new lines go first.
Two saves cut off at the same moment, then one that completes, step 9 of 9. Messages so far: Your program to settings.txt: open(path, "w"); Your program to settings.txt: write() twice; Your program to Temporary file: NamedTemporaryFile(dir=…); Your program to Temporary file: write() twice; Your program to Temporary file: os.remove(tmp.name); Your program to Temporary file: write(), flush(), fsync(); Temporary file to settings.txt: os.replace(tmp, path).Your programatomic_save.pysettings.txt3 new settingsTemporary filerenamed: goneopen(path, "w")write() twiceNamedTemporaryFile(dir=…)write() twiceos.remove(tmp.name)write(), flush(), fsync()os.replace(tmp, path)

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. In place: emptied. save_in_place() opens settings.txt with "w", which empties it at once. The three old settings are gone before the first new one is written.
  2. Two new lines. The settings arrive one at a time. "theme = dark" and "language = hi" are written to the file.
  3. Cut off. The third setting never arrives, because ConnectionError is raised. The file keeps half of the new settings and none of the old ones, and the program will read it next time.
  4. Atomic: same start. The example writes the three old settings back and calls save_atomic() with the same settings, which fail at the same moment.
  5. A file beside it. NamedTemporaryFile(dir=folder) creates an empty temporary file in the same folder as settings.txt.
  6. Write the copy. The two settings that arrive go into the temporary file. settings.txt is not touched.
  7. Cut off again. ConnectionError at the same moment. The except clause deletes the temporary file and raises the error again, so the caller still learns that the save failed.
  8. A complete save. The last call gets all three new settings. They go into a new temporary file, and flush() and os.fsync() push them out to the disk.
  9. One step. os.replace() puts the temporary file in place of settings.txt in one step. A program that opens settings.txt sees the old version or the new one, never a mixture.

A save that fails halfway, two ways, and one that completes Python · atomic_save.py
import os
import tempfile


def save_in_place(path, lines):
    """Rewrite the file directly: it is emptied first, then filled line by line."""
    with open(path, "w", encoding="utf-8") as f:
        for line in lines:
            f.write(line + "\n")


def save_atomic(path, lines):
    """Write a temporary file next to the target, then swap it in with one rename."""
    folder = os.path.dirname(os.path.abspath(path))
    tmp = tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=folder, suffix=".tmp", delete=False)
    try:
        with tmp:
            for line in lines:
                tmp.write(line + "\n")
            tmp.flush()
            os.fsync(tmp.fileno())  # ask the operating system to put the bytes on the disk now
        os.replace(tmp.name, path)  # the old file is replaced in one step
    except BaseException:
        os.remove(tmp.name)  # never leave the half-written copy behind
        raise


def settings():
    """New settings, but the third value fails to arrive, like a crash halfway through a save."""
    yield "theme = dark"
    yield "language = hi"
    raise ConnectionError("the server stopped answering")


for save in (save_in_place, save_atomic):
    with open("settings.txt", "w", encoding="utf-8") as f:
        f.write("theme = light\nlanguage = en\nfont_size = 14\n")
    try:
        save("settings.txt", settings())
    except ConnectionError as err:
        print(f"{save.__name__} failed: {err}")
    with open("settings.txt", encoding="utf-8") as f:
        print("  the file now holds:", f.read().splitlines())
    print("  files in the folder:", sorted(name for name in os.listdir() if not name.endswith(".py")))

# A save that gets every setting: the new file replaces the old one in one step.
save_atomic("settings.txt", ["theme = dark", "language = hi", "font_size = 16"])
with open("settings.txt", encoding="utf-8") as f:
    print("save_atomic succeeded:", f.read().splitlines())
print("  files in the folder:", sorted(name for name in os.listdir() if not name.endswith(".py")))

Output

save_in_place failed: the server stopped answering
  the file now holds: ['theme = dark', 'language = hi']
  files in the folder: ['settings.txt']
save_atomic failed: the server stopped answering
  the file now holds: ['theme = light', 'language = en', 'font_size = 14']
  files in the folder: ['settings.txt']
save_atomic succeeded: ['theme = dark', 'language = hi', 'font_size = 16']
  files in the folder: ['settings.txt']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 atomic_save.py

The in-place save lost the old font size and kept a half-updated file that the program would happily read next time. The atomic save failed at the same moment, yet the file still held the complete old settings, and the temporary file was deleted; the last save then replaced all three settings at once. Four details make it work:

  • The temporary file is created in the same folder as the target, so it is on the same file system and the final step is a rename, not a copy.
  • flush() and os.fsync() push the new bytes out of Python’s buffer and ask the operating system to write them to the disk before the swap.
  • os.replace() puts the new file in place of the old one. The documentation of os.replace() states that a successful rename is atomic operation: An operation that other programs see either not at all or complete, never half done. On POSIX systems a successful os.replace() is atomic, so a program that opens the file sees the old version or the new one. on POSIX systems such as Linux and macOS: a program that opens the file sees the old version or the new one, never a mixture. It also overwrites an existing file on Windows, where os.rename() raises FileExistsError instead.
  • If anything fails, the except clause removes the temporary file and raises the error again, so the caller still learns that the save failed.

Key idea

To replace a file that matters, write the new version beside it and swap it in with os.replace(): a reader then finds the old file or the new one, never half of either.

Checkpoint

Check: Saving a file atomically

  1. One question An atomic save has written two of three new settings to its temporary file when the third one fails with ConnectionError. What does settings.txt hold afterwards?

    Choose one answer.

    Show the answer: One question

    Answer: The three old settings, and no temporary file is left

    The new text goes to a temporary file first, and the old file is replaced only after everything is written. A failure before that step leaves the old file whole; the except clause cleans up and re-raises.

Practise: tail a log, save atomically

The exercise asks for two functions that use this lesson’s ideas. tail() returns the last lines of a text file while keeping only those lines in memory, so it works on a log of any size, and atomic_write() replaces a file so that it never holds part of the old or the new text. Its sample tests run in your browser, one of them on a file of about 3 MB:

Exercise · Medium · Python

Read the last lines and save a file atomically

Write two functions in textfiles.py. Both read and write text as UTF-8.

  • tail(path, n) returns a list of the last n lines of the text file at path, oldest first, without their line endings. A file with fewer than n lines gives all of them, and n of 0 gives []. Empty lines count as lines, and a last line without "\n" is still a line. The function must work on a file far larger than the memory you want to spend: keep at most n lines at a time, never the whole file. One sample test measures this on a file of about 3 MB.
  • atomic_write(path, text) replaces the file at path (or creates it) with text, so that the file holds either all of the old text or all of the new text, never part of either. Write a temporary file in the same folder, then move it over path in one step. If anything fails, path keeps its old contents and no temporary file is left in the folder. text must be a str: anything else, such as 123, raises TypeError, and the tests use that to make a write fail halfway.

Starter code · textfiles.py

def tail(path, n):
    """Return the last n lines of the text file at path, without their line endings."""
    # Replace these lines with your code.
    with open(path, encoding="utf-8") as f:
        return f.read().splitlines()[:n]


def atomic_write(path, text):
    """Replace the file at path with text, so that it never holds part of the old or the new text."""
    # Replace these lines with your code.
    with open(path, "w", encoding="utf-8") as f:
        f.write(text)
The sample tests · test_textfiles.py
import os
import tempfile
import tracemalloc

from textfiles import atomic_write, tail


def write_bytes(folder, name, data):
    path = os.path.join(folder, name)
    with open(path, "wb") as f:
        f.write(data)
    return path


def test_tail_last_lines():
    """returns the last n lines, oldest first, without line endings"""
    with tempfile.TemporaryDirectory() as folder:
        path = write_bytes(folder, "log.txt", b"one\ntwo\nthree\nfour\nfive\n")
        assert tail(path, 2) == ["four", "five"]
        assert tail(path, 1) == ["five"]


def test_tail_short_and_zero():
    """gives every line when the file is shorter than n, and [] for n == 0"""
    with tempfile.TemporaryDirectory() as folder:
        path = write_bytes(folder, "log.txt", b"one\ntwo\n")
        assert tail(path, 5) == ["one", "two"]
        assert tail(path, 0) == []


def test_tail_edges():
    """keeps empty lines, a last line without a newline, Windows line endings and UTF-8 text"""
    with tempfile.TemporaryDirectory() as folder:
        path = write_bytes(folder, "notes.txt", "a\r\n\r\nनमस्ते\r\nlast".encode("utf-8"))
        assert tail(path, 3) == ["", "नमस्ते", "last"]


def test_tail_large_file_small_memory():
    """reads a file of about 3 MB while keeping well under 1 MB in memory"""
    with tempfile.TemporaryDirectory() as folder:
        path = os.path.join(folder, "big.log")
        with open(path, "w", encoding="utf-8") as f:
            for i in range(100_000):
                f.write(f"{i:06d} GET /products/{i % 250} 200\n")
        tracemalloc.start()
        try:
            last = tail(path, 3)
            peak = tracemalloc.get_traced_memory()[1]
        finally:
            tracemalloc.stop()
        assert last == ["099997 GET /products/247 200", "099998 GET /products/248 200", "099999 GET /products/249 200"]
        assert peak < 1_000_000, f"tail() used {peak:,} bytes at its peak for a {os.path.getsize(path):,}-byte file"


def test_atomic_write_creates_and_replaces():
    """creates the file, replaces it, writes UTF-8 and leaves no other file in the folder"""
    with tempfile.TemporaryDirectory() as folder:
        path = os.path.join(folder, "settings.txt")
        atomic_write(path, "theme = light\n")
        atomic_write(path, "theme = dark\nभाषा = hi\n")
        with open(path, "rb") as f:
            assert f.read() == "theme = dark\nभाषा = hi\n".encode("utf-8")
        assert os.listdir(folder) == ["settings.txt"]


def test_failed_write_keeps_the_old_file():
    """a write that fails halfway leaves the old contents and no temporary file"""
    with tempfile.TemporaryDirectory() as folder:
        path = os.path.join(folder, "settings.txt")
        atomic_write(path, "theme = light\n")
        try:
            atomic_write(path, 123)
        except TypeError:
            pass
        else:
            assert False, "atomic_write(path, 123) should raise TypeError"
        with open(path, encoding="utf-8") as f:
            assert f.read() == "theme = light\n"
        assert os.listdir(folder) == ["settings.txt"]
A hint

For tail, loop over the open file and append each line to collections.deque(maxlen=n): a full deque drops its oldest item when you append a new one, so it never holds more than n lines. For atomic_write, create the temporary file with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=folder, delete=False), where folder is the folder of path, write and close it, and only then call os.replace(temporary_name, path). Put the writing in try and delete the temporary file in except before you raise the error again.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then test yourself on modes, encodings, reading, line endings, buffers and atomic saves. The practice begins with the idea you know least, asks one question at a time, and explains every choice you make, right or wrong:

Practice that adapts to you

Practise this lesson

18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 18 Which mode creates a new file for writing but raises FileExistsError when the file is already there?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: "x"

    "x" means exclusive creation: it refuses to open a file that exists. "a" appends to an existing file, "w+" empties it, and "r+" opens it for reading and writing without emptying it, but fails when it does not exist.

  2. Question 2 of 18 What does this print?

    Read the code, then choose one answer.

    for mode, text in [("w", "a\n"), ("a", "b\n"), ("w", "c\n")]:
        with open("m.txt", mode, encoding="utf-8") as f:
            f.write(text)
    
    with open("m.txt", encoding="utf-8") as f:
        print(repr(f.read()))
    Show the answer: Question 2 of 18

    Answer: 'c\n'

    "w" creates m.txt with "a\n", "a" adds "b\n", and the second "w" empties the file before writing "c\n". The file ends with one line.

  3. Question 3 of 18 A program must change the third line of config.txt and keep every other line. Which plan does that?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: Read the file with "r", change the line in the list of lines, then write the whole new text, ideally to a temporary file swapped in with os.replace()

    No mode edits a line in place. Read the old text first, build the new text in memory, then write it. Writing a temporary file and swapping it in keeps the old file whole if the program fails halfway.

  4. Question 4 of 18 Which encoding does open() use in Python 3.14 when you leave out encoding=?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: The computer's locale encoding, which differs between computers

    Without encoding=, Python 3.14 uses the locale encoding, so the same script can read a file correctly on one computer and garble it on another. Passing encoding="utf-8" behaves the same everywhere.

  5. Question 5 of 18 A large program has hundreds of open() calls. How do you find the ones that rely on the default encoding?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: Run it with python -X warn_default_encoding, which reports each such call with an EncodingWarning

    EncodingWarning is opt-in (PEP 597): -X warn_default_encoding makes every text-mode open() without an encoding report itself. Fix each one with encoding="utf-8", or with encoding="locale" when the locale is really meant.

  6. Question 6 of 18 A file has a few damaged bytes. A script reads it with errors="replace", fixes a typo in the text and saves it as UTF-8. What has it done to the damaged places?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: Replaced each with U+FFFD, the replacement character, which is now saved in the file for good

    errors="replace" and errors="ignore" help when you only want to look at a damaged file. Saving text read that way makes the loss permanent, so never write it back over the original.

  7. Question 7 of 18 f.read() returns the whole file, and a second f.read() on the same file object returns "". Why?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: The file object remembers how far it has read, and it is at the end

    A file object keeps a position. read() takes everything from it to the end and leaves the position there, so the next read() finds nothing left and returns "". f.seek(0) moves back to the start.

  8. Question 8 of 18 What does read_styles.py print?

    What does this program print? Choose one answer.

    with open("todo.txt", "w", encoding="utf-8") as f:
        f.write("buy milk\ncall the bank\n\nwater the plants\n")
    
    with open("todo.txt", encoding="utf-8") as f:
        whole = f.read()  # one str with every line
    print(repr(whole))
    
    with open("todo.txt", encoding="utf-8") as f:
        first = f.readline()  # one line, with its "\n"
        rest = f.readlines()  # a list of the lines that are left
    print(repr(first))
    print(rest)
    
    # Looping over the file gives one line at a time: the usual way.
    with open("todo.txt", encoding="utf-8") as f:
        for number, line in enumerate(f, start=1):
            task = line.rstrip("\n")
            if task:  # skip the empty line
                print(number, task)
    
    # At the end of the file, read() and readline() return "" instead of raising an error.
    with open("todo.txt", encoding="utf-8") as f:
        f.read()
        print(repr(f.read()), repr(f.readline()))
    Show the answer: Question 8 of 18

    Answer: it prints

    'buy milk\ncall the bank\n\nwater the plants\n'
    'buy milk\n'
    ['call the bank\n', '\n', 'water the plants\n']
    1 buy milk
    2 call the bank
    4 water the plants
    '' ''

    read() gives one string with every line ending. readline() gives the next line with its "\n", and readlines() the rest as a list. The loop numbers lines from 1, so the blank line takes number 3 and is skipped. At the end of the file read() and readline() return "".

  9. Question 9 of 18 You need to count the error lines of a 6 GB log file. Which of these read it without holding the whole file in memory?

    Choose every answer that is right.

    Show the answer: Question 9 of 18

    Answer:

    • while line := f.readline():
    • for line in f:

    Looping over the file object, or calling readline() until it returns an empty string, keeps one line in memory at a time. read() returns the whole file as one string and readlines() a list of every line, so both need at least the size of the file in memory.

  10. Question 10 of 18 A file saved on Windows holds the bytes b'first\r\nsecond\r\n'. What does f.read() return when the file is opened in text mode with the defaults?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: 'first\nsecond\n'

    By default, reading in text mode turns "\r\n" and a lone "\r" into "\n", and the file object's newlines attribute records which endings it met. newline="" keeps the endings as they are; binary mode never changes a byte.

  11. Question 11 of 18 How many lines does this program count?

    Read the code, then choose one answer.

    with open("a.txt", "w", encoding="utf-8") as f:
        f.write("one")
        f.write("two\n")
        f.writelines(["three", "four\n"])
        print("five", file=f)
    
    with open("a.txt", encoding="utf-8") as f:
        print(len(f.readlines()))
    Show the answer: Question 11 of 18

    Answer: 3

    write() and writelines() add no line endings, so the file holds "onetwo\n", "threefour\n" and the "five\n" that print() wrote with its own newline: three lines.

  12. Question 12 of 18 A program must read a file as str but see every "\r\n" exactly as the file has it, as the csv module needs. Which call opens it that way?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: open(path, encoding="utf-8", newline="")

    The newline argument controls the translation, the encoding controls the decoding. newline="" leaves endings as they are while still giving str, which is why the csv lesson opens every file with it.

  13. Question 13 of 18 What does closing.py print?

    What does this program print? Choose one answer.

    # Without with: what you write waits in a buffer until the file is closed.
    log = open("log.txt", "w", encoding="utf-8")
    log.write("started\n")
    with open("log.txt", encoding="utf-8") as reader:
        print("before close:", repr(reader.read()))
    log.close()
    with open("log.txt", encoding="utf-8") as reader:
        print("after close: ", repr(reader.read()))
    
    # With with: the file is closed when the block ends, even when a line in it fails.
    try:
        with open("report.txt", "w", encoding="utf-8") as report:
            report.write("total: 42\n")
            raise ValueError("the next figure was missing")
    except ValueError as err:
        print("caught:", err)
    print("closed:", report.closed)
    with open("report.txt", encoding="utf-8") as f:
        print("saved:", repr(f.read()))
    Show the answer: Question 13 of 18

    Answer: it prints

    before close: ''
    after close:  'started\n'
    caught: the next figure was missing
    closed: True
    saved: 'total: 42\n'

    Text written to a file waits in a buffer until the file is flushed or closed, so the first reader sees an empty file. The with block closes the report file when the ValueError leaves it, and closing flushes the buffer, so "total: 42" is saved and report.closed is True.

  14. Question 14 of 18 What does a with statement guarantee for the file it opens?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: The file is closed when the block ends, whether by its last line, a return or an exception

    The with statement calls the file's close on every way out of its block, which flushes the buffer and gives the operating system's handle back. It neither hides exceptions nor protects the file's contents from a half-done write.

  15. Question 15 of 18 A long-running script writes progress lines to log.txt with write() and never closes the file. It is killed during the night. What can the log be missing, and what fixes it?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: The last lines, which were still in the buffer; flush() after each important line, or open the log in a with block around each burst of writing

    Buffering trades safety for speed: the file object writes in large pieces. When every line matters, call flush() (and os.fsync() if a power cut must not lose it either), or close the file promptly.

  16. Question 16 of 18 Why does an atomic save create its temporary file in the same folder as the file it replaces?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: So that os.replace() is a rename on one file system, which happens in one step

    A rename within one file system swaps the new file in at once; across file systems, os.replace() may fail instead. Writing the temporary file next to the target keeps both on the same file system.

  17. Question 17 of 18 Put the steps of an atomic save in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 17 of 18

    Answer:

    1. Create a temporary file in the same folder as the target
    2. Write the whole new text to the temporary file
    3. Call flush() and os.fsync() on it
    4. Swap it in with os.replace(temporary, target)

    The new text must be complete, and on the disk, before the swap: only then does os.replace() put a whole new file in place of the whole old one. If any step before the swap fails, delete the temporary file and raise the error again.

  18. Question 18 of 18 The atomic save must also work on Windows. Why does it swap the files with os.replace() rather than os.rename()?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: os.rename() raises FileExistsError on Windows when the target exists, while os.replace() overwrites it

    On POSIX systems the two behave alike, but on Windows os.rename() fails when the target exists. os.replace() replaces it everywhere, and on POSIX systems a successful replace is atomic.

Interview questions

What is the difference between opening a file with “w”, “a” and “x”?

All three open a file for writing and create it when it does not exist; they differ in what they do to a file that is already there. "w" empties it at once, before anything is written, so the old contents are gone even if you never call write(). "a" keeps the contents and adds everything you write after the last byte. "x" refuses to open an existing file and raises FileExistsError, which makes it the safe choice for files that must never replace an older one. Add "b" to any of them for bytes instead of text, and "+" to read as well; "w+" still empties the file. To change a file without risking its old contents, write a temporary file and swap it in with os.replace().

Key takeaways

  • Open text files with open(path, mode, encoding="utf-8") in a with statement: "r" reads, "w" empties and writes, "a" appends, and "x" creates a new file but refuses an existing one.
  • The same bytes give different text under another encoding; without encoding=, Python 3.14 uses the locale encoding, which differs between computers, and -X warn_default_encoding finds the calls that leave it out.
  • read() and readlines() load the whole file, while a loop over the file object reads one line at a time in a small, fixed amount of memory; a blank line comes back as "\n" and the end of the file as "".
  • Text mode turns every line ending into "\n" when reading and back into the platform’s ending when writing, newline="" turns that off, and write() adds no line ending of its own.
  • Written text waits in a buffer until a flush or the close, and a with block closes the file however the block ends.
  • To replace a file safely, write a temporary file in the same folder, call flush() and os.fsync() on it, and move it into place with os.replace().

Terms in this lesson

atomic operation
An operation that other programs see either not at all or complete, never half done. On POSIX systems a successful os.replace() is atomic, so a program that opens the file sees the old version or the new one.
file object
What open() returns: an object with methods such as read(), write() and close() for one open file, which keeps its own position in that file. In text mode it reads and writes str; in binary mode, bytes.
locale encoding
The encoding a computer's settings name for text, which open() uses in Python 3.14 when no encoding is given: UTF-8 on most Linux and macOS systems, often a legacy code page such as Windows-1252 on Windows.
text mode
How open() opens a file unless its mode contains "b": it decodes the bytes it reads into str and encodes the str it writes, with the file's encoding, and translates line endings on the way.
universal newlines
Reading text so that every kind of line ending counts: in text mode "\n", "\r\n" and a lone "\r" all come back as "\n". The file object's newlines attribute records which endings it met.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.