CodingNic

File I/O

Reading and Writing Text Files

File I/O 25 min read

Reading and Writing Text Files

Objectives

By the end of this chapter, you should be able to:

  • Read and write to text files
  • Build and check file paths with pathlib
  • Explain the purpose of a with statement
  • Explain the different ways to open a file depending on whether you want to read, write, or append

💡 Why this matters: Nearly every real program needs to persist data somewhere: logs, config, saved output. Files are the simplest form of that, and the patterns here (modes, the cursor, with) carry over to every other kind of I/O you’ll do.

Key Terms

Three terms come up constantly when discussing file I/O (input/output):

  • Reading: getting data from a file so your program can use it.
  • Writing: saving new data to a file.
  • Cursor: a marker (like the cursor when you’re typing) that tracks where in the file you’re currently reading from or writing to. Once the cursor reaches the end of a file, there’s nothing left to read until you move it back.

Working with File Paths

Every example so far passes a bare filename like "first.txt" to open(). That only works if the file happens to be in your script’s current directory. Real programs usually need to be more deliberate about paths, and the standard library gives you two ways to do it.

pathlib.Path is the modern, object-oriented approach:

python
from pathlib import Path

data_dir = Path("data")
file_path = data_dir / "first.txt"   # joins paths with /, no manual string concatenation

print(file_path)          # data/first.txt
print(file_path.exists()) # False (until you actually create it)
print(file_path.name)     # 'first.txt'
print(file_path.suffix)   # '.txt'

The / operator joining data_dir and "first.txt" isn’t dividing anything: Path overrides it specifically to build paths correctly on whatever operating system the code actually runs on (backslashes on Windows, forward slashes elsewhere), so you never need to hardcode a separator yourself.

The older os.path module does the same jobs with plain functions instead of an object:

python
import os

file_path = os.path.join("data", "first.txt")
print(os.path.exists(file_path))   # False
print(os.path.basename(file_path)) # 'first.txt'

Both are still common in real code. pathlib is generally the more pleasant, more modern option, but you’ll see os.path in plenty of existing projects. The rest of this module uses pathlib.

Checking .exists() (or os.path.exists()) before opening a file you’re not sure is there is a good habit. It lets you handle a missing file gracefully instead of catching a FileNotFoundError after the fact:

python
from pathlib import Path

file_path = Path("data") / "first.txt"

if file_path.exists():
    with open(file_path) as f:
        print(f.read())
else:
    print("No file yet.")

Reading a File

Create a text file called first.txt containing:

text
This is a very simple text file!

Then, in a script or the REPL:

python
file = open("first.txt", "r")
print(file.read())

The second argument to open(), "r", means “open for reading” (it’s also the default if you leave the mode out). file.read() reads everything from the current cursor position to the end of the file, moving the cursor there in the process.

If you call file.read() a second time, you get back an empty string, because the cursor is already at the end and there’s nothing left to read. To read it again, move the cursor back to the beginning with seek():

python
file = open("first.txt", "r")
print(file.read())   # This is a very simple text file!
print(file.read())   # "" (empty string, nothing left)
file.seek(0)          # move the cursor back to the start
print(file.read())   # This is a very simple text file! (again)
file.close()

For a multi-line file, readline() reads one line at a time instead of the whole file at once. Say first.txt now contains:

text
line one
line two
line three
python
file = open("first.txt", "r")
print(file.readline())   # line one
print(file.readline())   # line two
file.close()

Always Close What You Open

file.closed tells you whether a file has been closed. If you never call file.close(), it stays open, which wastes resources and can even cause data loss if a program exits unexpectedly:

python
file = open("first.txt", "r")
file.read()
print(file.closed)   # False
file.close()
print(file.closed)   # True
file.read()           # ValueError: I/O operation on closed file

The with Statement

Remembering to call .close() every time is easy to forget, especially if an error happens partway through and your code never reaches the .close() line. A with block closes the file automatically once you leave it, even if an error happens inside:

python
with open("first.txt", "r") as file:
    data = file.read()
    print(data)

print(file.closed)  # True, closed automatically

Compare that to the manual version above: with with, there’s no .close() call to forget. This is the idiomatic way to work with files in Python. Prefer it over manual open()/close() pairs.

File Modes

The second argument to open() tells Python what you intend to do with the file:

Mode Meaning
r Read only (the default if you omit the mode)
r+ Read and write. Writing overwrites existing characters starting from the cursor’s position
a Append. Adds to the end; existing content is untouched
a+ Read and append
w Write. Empties the file first, so existing content is lost
w+ Write and read

See how each behaves differently, starting from a file containing "Lorem ipsum dolor sit amet.":

python
with open("first.txt", "w") as file:
    file.write("Lorem ipsum dolor sit amet.")

with open("first.txt", "r") as file:
    print(file.read())
# Lorem ipsum dolor sit amet.

with open("first.txt", "r+") as file:
    file.seek(6)          # move the cursor 6 characters in
    file.write("XXXXX")   # overwrite starting there, rest of the file is untouched
    file.seek(0)
    print(file.read())
# Lorem XXXXX dolor sit amet.

with open("first.txt", "a+") as file:
    file.write("\nAppended line.")   # 'a' mode always writes at the end, no matter where seek() puts the cursor
    file.seek(0)
    print(file.read())
# Lorem XXXXX dolor sit amet.
# Appended line.

with open("first.txt", "w+") as file:
    file.write("Now everything is overwritten.")
    file.seek(0)
    print(file.read())
# Now everything is overwritten.

That last block is worth pausing on: w and w+ truncate the file the moment you open it, before you’ve written a single character. Use them deliberately, and never on a file you meant to append to.

Text Encoding

Text in a file is stored as bytes, and encoding is the rulebook that says which bytes represent which characters. Python’s open() picks a default encoding based on your operating system, which is usually fine for plain English text but can cause a UnicodeDecodeError on files containing accented letters, emoji, or other non-ASCII characters, especially if the file was created on a different OS than the one reading it.

The fix is to always be explicit and pass encoding="utf-8" (UTF-8 is the standard encoding used across the web and by most modern tools):

python
with open("note.txt", "w", encoding="utf-8") as file:
    file.write("café")

with open("note.txt", "r", encoding="utf-8") as file:
    print(file.read())   # café

Making a habit of passing encoding="utf-8" to every open() call avoids an entire class of bugs that only show up on someone else’s machine.

Try It

  1. Create a text file, open it with "w", write a few lines, then reopen it with "r" and print the contents.
  2. Open that same file with "a" and add a new line without disturbing what’s already there.
  3. Predict what file.read() returns the second time you call it in a row, without calling seek(). Then verify.
  4. Build a path with pathlib.Path, joining at least two path segments with /, and check whether it exists before trying to open it.
  5. Open a file with encoding="utf-8" and write a string containing an accented character (like "café") to it, then read it back.

Recap

  • Reading retrieves data from a file; writing saves data to it; the cursor tracks your current position.
  • pathlib.Path (or the older os.path) builds and checks file paths correctly across operating systems. Prefer it over hardcoding path separators yourself.
  • with open(...) as file: closes the file automatically, even if an error occurs. Prefer it over manual open()/close().
  • File modes control behavior: r reads, w/w+ truncate before writing, a/a+ append without touching existing content, r+ overwrites from the cursor’s position.
  • Pass encoding="utf-8" to open() to avoid encoding bugs with non-ASCII characters.

Next lesson: reading and writing CSV files.