PythonMastery
beginner 14 min read · lesson 18 of 19 in Python Fundamentals

Files: Reading and Writing to Disk

1 · The lesson

read

So far, every variable your program created was lost as soon as the program ended. That is fine for a calculator or a quiz. But a TODO app, a journal or a log analyser needs its data to persist: to still be there the next time the program runs.

This lesson covers the basics of file I/O: opening files, reading, writing, the with statement (the right way to open files), and the modern pathlib module for paths.

The advanced topics (CSV, JSON, binary files, reading large files in pieces) are in the Intermediate File I/O lesson.

Note: Pyodide in the browser can't see your local filesystem. The Run button on this lesson's examples will work for in-memory operations (io.StringIO) but file paths like "notes.txt" won't persist between runs. The code shown here is exactly what you would write in a Python script on your own computer.


1. The Three Steps of File I/O

python
1. OPEN the file (and tell Python what you want to do)
2. READ or WRITE
3. CLOSE the file

The simple version looks like this:

python
# Don't do it this way. Read on for the better version
f = open("notes.txt", "w", encoding="utf-8")
f.write("Hello, file!")
f.close()                           # ← you MUST remember this line

If you forget to close the file, your data may not be written to disk (Python keeps it in a buffer first), or the file may stay locked. There is a much better way.


2. The with Statement: Always Use This

with closes the file automatically when the block ends, even if an exception is raised.

python
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Hello, file!")
# file is automatically closed here

# Read it back
with open("notes.txt", "r", encoding="utf-8") as f:
    content = f.read()
print(content)                      # Hello, file!

This is the standard pattern. From now on, every file example uses with. The encoding="utf-8" argument tells Python how to turn the text into bytes. Section 8 below explains why you should always include it. If you open files without with, a code reviewer will usually ask you to change it.


3. File Modes: What "w", "r", "a" Mean

The second argument to open() is the mode. The common ones:

ModeMeansBehaviour
"r"readDefault. File must exist.
"w"writeCreates new file, overwrites if exists
"a"appendCreates if missing, adds to end if exists
"x"exclusive createCreates new file, fails if exists
"r+"read + writeFile must exist
"rb", "wb"binaryFor non-text files (images, etc.)
python
# Overwrites every time you run
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("first line\n")
    f.write("second line\n")

# Append mode keeps the existing content
with open("notes.txt", "a", encoding="utf-8") as f:
    f.write("third line\n")

# Read everything
with open("notes.txt", "r", encoding="utf-8") as f:
    print(f.read())

Notice the \n: write() doesn't add newlines automatically. If you want separate lines, you must write the \n yourself.


4. Reading: Three Common Patterns

python
# Pretend this file exists (in the browser, io.StringIO stands in for it)
import io
fake_file = io.StringIO("apple\nbanana\ncherry\n")

# Pattern 1: Read the whole file as one string
content = fake_file.read()
print(repr(content))                # 'apple\nbanana\ncherry\n'

# Pattern 2: Read all lines into a list
fake_file = io.StringIO("apple\nbanana\ncherry\n")
lines = fake_file.readlines()
print(lines)                        # ['apple\n', 'banana\n', 'cherry\n']

# Pattern 3: Iterate line by line (best for large files)
fake_file = io.StringIO("apple\nbanana\ncherry\n")
for line in fake_file:
    print(line.strip())             # .strip() removes the trailing newline

Pattern 3 is the right default, because it doesn't load the whole file into memory at once. It works for a 10-line config file and for a 10 GB log file.

The equivalent for a real local file:

python
# with open("fruits.txt", "r", encoding="utf-8") as f:
#     for line in f:
#         print(line.strip())


5. Writing: Two Common Patterns

python
import io
out = io.StringIO()                 # in-memory stand-in for a real file

# Pattern 1: Multiple write() calls
out.write("Line 1\n")
out.write("Line 2\n")
out.write("Line 3\n")
print(out.getvalue())

# Pattern 2: writelines() takes a list of strings (or any iterable)
out2 = io.StringIO()
out2.writelines(["A\n", "B\n", "C\n"])
print(out2.getvalue())

writelines() doesn't add newlines, so you include them in the strings. The name suggests that it adds them, but it doesn't.


6. Always Handle the Errors

File operations often fail: the file is missing, permission is denied, the disk is full, or the encoding is wrong. Put them in a try block:

python
try:
    with open("missing-file.txt", "r", encoding="utf-8") as f:
        content = f.read()
except FileNotFoundError:
    print("File doesn't exist. Using empty content")
    content = ""
except PermissionError:
    print("Don't have permission to read this")
    content = ""

The two most common errors when reading:


  • FileNotFoundError: wrong path, or the file was deleted

  • PermissionError: the operating system denied access

When writing:


  • PermissionError: you can't write to that directory

  • IsADirectoryError: you passed a directory path by mistake


7. pathlib: The Modern Way to Handle Paths

The old way used the os.path module and lots of string concatenation. The modern way is pathlib. It has been available since Python 3.4, and it is now the standard choice.

python
from pathlib import Path

# A path object handles / and \ for every operating system
notes = Path("notes.txt")

print(notes.name)                   # notes.txt
print(notes.stem)                   # notes
print(notes.suffix)                 # .txt
print(notes.exists())               # True/False depending on whether the file exists

# Combine paths with the `/` operator (yes, really)
data_dir = Path("data")
config_file = data_dir / "settings" / "config.json"
print(config_file)                  # data/settings/config.json (or data\settings\config.json on Windows)

# Read or write a small file in one step, without open() and with
# config_file.write_text("hello", encoding="utf-8")
# content = config_file.read_text(encoding="utf-8")

For paths with more than two parts, or code that must run on several operating systems, pathlib is much cleaner than the old os.path.join() style.


8. Encoding: Characters and Bytes

Text files have an encoding: a rule for turning characters into bytes. Most systems today use UTF-8, but Windows computers sometimes use a different default encoding (such as cp1252).

Always specify encoding when reading or writing text:

python
# GOOD: explicit, and works on every system
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Hello — with em-dash and 🐍")

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

Without encoding=, your code may work on your computer but fail on someone else's. It is one short argument, so always add it.


9. A Mini-Project: A Simple Note Logger

python
import io
from datetime import datetime

# In real code you'd use:
#   with open("journal.txt", "a", encoding="utf-8") as f:
# Here we use io.StringIO to simulate a persistent file
journal = io.StringIO()

def log_entry(text, file_handle):
    """Append a timestamped entry to the journal."""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
    file_handle.write(f"[{timestamp}] {text}\n")

log_entry("Started the new project today", journal)
log_entry("Finished the file I/O lesson", journal)
log_entry("Going for a walk", journal)

# Read it all back
print(journal.getvalue())

That is a working journal in a few lines. Only the real file is missing. Replace io.StringIO with open("journal.txt", "a", encoding="utf-8"), and the notes are saved to disk between runs.


10. Mistakes You'll Hit

1. Forgetting with and leaving files open
Especially on Windows, this can lock the file and stop other programs from reading it.

2. Forgetting newlines in writes
write() doesn't add them. print() does. The two look similar, but they behave differently.

3. Using "w" when you meant "a"
"w" overwrites the file. Most people, at least once, delete the contents of a file they wanted to add to.

4. Skipping encoding=
The code works on your computer and fails on someone else's. Always type encoding="utf-8".

5. Hardcoded paths with \
On Windows, \ is the path separator BUT also the escape character. "C:\notes\new.txt" contains \n, which Python reads as a newline character. Use forward slashes (Python accepts them on Windows) or use pathlib.


🎯 Your Turn: Count the Words in a File

Write word_counts(path) that reads a text file and returns a dictionary
mapping each lowercase word to how many times it appears. Ignore case, and strip
the punctuation that would otherwise make "tea." and "tea" different words.

The skeleton writes the file first, so the whole thing runs start to finish.

python
"Tea is good. Tea is cheap. Coffee is not."

→ {'tea': 2, 'is': 3, 'good': 1, 'cheap': 1, 'coffee': 1, 'not': 1}

Skeleton:

python
# Create the file we are about to read
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Tea is good. Tea is cheap. Coffee is not.")


def word_counts(path):
    counts = {}
    with open(path, encoding="utf-8") as f:
        text = f.read()
    # TODO 1: lowercase the text
    # TODO 2: split it into words
    # TODO 3: strip .,!? from each word and skip anything left empty
    # TODO 4: count each word into the dict
    return counts


print(word_counts("notes.txt"))
Hint 1: with closes the file for you with open(path, encoding="utf-8") as f: guarantees the file is closed even if an error happens inside the block. Reading the whole file with f.read() is fine here. For a file too large to fit in memory you would loop over the lines instead.
Hint 2: Strip punctuation off the ends "good.".strip(".,!?") gives "good". Every character you pass to strip is removed from both ends until it hits something else. For counting, counts.get(word, 0) + 1 avoids a KeyError the first time you see a word.
Show full solution
python
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Tea is good. Tea is cheap. Coffee is not.")


def word_counts(path):
    counts = {}
    with open(path, encoding="utf-8") as f:
        text = f.read()

    for raw in text.lower().split():
        word = raw.strip(".,!?;:\"'")
        if not word:
            continue
        counts[word] = counts.get(word, 0) + 1

    return counts


print(word_counts("notes.txt"))
# {'tea': 2, 'is': 3, 'good': 1, 'cheap': 1, 'coffee': 1, 'not': 1}

Four steps: read, lowercase, clean, count. strip only removes characters from
the ends of a word, which is what you want: it leaves don't unchanged and doesn't
remove the apostrophe in the middle. Once you have written this by hand, look up
collections.Counter, which does the counting part in a single call. Now that
you know what it replaces, you will understand what it does for you.


What You Learned

  • Three steps: open, read/write, close. Use with so close is automatic.
  • Modes: "r" read, "w" overwrite, "a" append, "x" create-or-fail.
  • Read with f.read(), f.readlines(), or iterate for line in f: (the right default).
  • Write with f.write(str), and include \n yourself.
  • Wrap in try/except, and catch FileNotFoundError and PermissionError.
  • Use pathlib for path manipulation, encoding="utf-8" always for text.

You can now write programs that save data between runs. Next: Modules & Imports, how to split bigger programs across several files.


Source: adapted from Python official documentation Section 7.2 (Reading and Writing Files) and the pathlib module reference. PSF License.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.