Chapter 1

Opening and Closing Files

Working with a file in Python always has the same three beats. You open it, you do your thing, then you close it. This chapter is the open and the close: opening a file so it does what you meant, and closing it so your data actually sticks.

We won't pull the reading log apart into rows yet. For now the file is just a lump of text, and the job is getting that text into your program and back out again.

Opening a file

Here's the smallest useful thing you can do with the log. Open it, read it, and print the start of what you got.

f = open("reading-log.csv")
contents = f.read()
f.close()

print(contents[:28])
Output
title,author,rating,finished

Three lines, three steps.

open("reading-log.csv") opens the file and hands back a file object. The file object isn't the contents of the file. It's a handle: a small object that knows which file you mean and where you're up to in it. You do your reading and writing through the handle.

f.read() asks the handle for the whole file as one string. The reading log is small, so that's fine. For a file that might be large, reading it all at once is a mistake, and the next chapter covers what to do instead.

f.close() tells the operating system you're done. We'll come back to why that line isn't optional.

The "reading-log.csv" here is a bare filename with no folder in front of it. Python looks for it in whatever directory your script is running from. That's enough to get started; Chapter 5 deals with paths properly.

Reading part of a file

f.read() with no argument reads everything from the current position to the end. Give it a number and it reads that many characters and stops:

with open("reading-log.csv") as f:
    start = f.read(12)
    rest = f.read(16)

print(repr(start))
print(repr(rest))
Output
'title,author'
',rating,finished'

The handle remembers where it left off. The first read returns the first twelve characters, and the second read picks up from character thirteen. That's the position the file object tracks for you.

What the handle knows

A file object can tell you a few things about itself:

with open("reading-log.csv") as f:
    print(f.name)
    print(f.mode)
Output
reading-log.csv
r

f.mode is r because we didn't ask for anything else. That's the next topic.

Modes: what you plan to do with the file

open takes an optional second argument, the mode, which says what you intend to do. Leave it out, as above, and you get "r", for reading.

Mode Opens the file for If it already exists If it does not exist
"r" reading (the default) reads from the start raises FileNotFoundError
"w" writing empties it first creates it
"a" writing keeps it, adds to the end creates it
"x" writing raises FileExistsError creates it

The three writing modes differ only in how they treat a file that's already there. "w" throws the old contents away. "a" leaves them and appends. "x" refuses, which is handy when overwriting something by accident would be bad.

Write a line to a new file:

with open("today.txt", "w") as f:
    f.write("Started The Three-Body Problem\n")

with open("today.txt") as f:
    print(f.read(), end="")
Output
Started The Three-Body Problem

f.write doesn't add a newline for you, so the \n at the end of that string is doing real work. Without it, the next thing you write would run straight on from Problem.

That example used with, which we haven't explained yet. Ignore it for one more section; the point here is the mode.

Open the same file in "a" mode and the new line lands after the old one:

with open("today.txt", "a") as f:
    f.write("Finished Piranesi\n")

with open("today.txt") as f:
    print(f.read(), end="")
Output
Started The Three-Body Problem
Finished Piranesi

"x" mode is the careful one. today.txt exists now, so opening it with "x" stops you:

with open("today.txt", "x") as f:
    f.write("this line is never written")

That raises FileExistsError. Nothing is written, and the existing file is left alone.

When the file isn't there

Open a file that doesn't exist in a reading mode and Python won't invent one for you:

open("wishlist.csv")

That's a FileNotFoundError. It catches people who expect open to create the file. It doesn't. Only the writing modes ("w", "a", "x") create a file that's not there.

Often a missing file isn't something you want to crash on. Maybe the wishlist just hasn't been started yet. Catch the exception and carry on with a sensible default:

try:
    with open("wishlist.csv") as f:
        wishlist = f.read()
except FileNotFoundError:
    wishlist = ""

print(f"wishlist is {len(wishlist)} characters")
Output
wishlist is 0 characters

Catch FileNotFoundError specifically, not every exception. If the file is there but unreadable for some other reason, you want to hear about that.

Closing a file, and why it matters

Back to f.close(). It's easy to leave out, and on a small script you often get away with it. Here's what you're risking when you do.

When you write to a file, the data doesn't always go to disk straight away. Python and the operating system hold some of it in memory and flush it out in batches, because lots of tiny writes are slow. Closing the file flushes whatever is left. Skip the close and your last few writes can simply not be there.

An open file also ties up resources. The operating system limits how many files one program can have open at once. On Windows, an open file can stop another program, or your own code, from touching it. Open files in a loop without closing them and a long-running program will eventually fail.

You can ask a file object whether it's closed:

f = open("reading-log.csv")
print("closed?", f.closed)
f.close()
print("closed?", f.closed)
Output
closed? False
closed? True

The real problem with a manual close is that it doesn't run if something goes wrong first:

f = open("reading-log.csv")
rows = parse(f.read())   # if this line raises an error
f.close()                # this line is skipped

If parse fails, the exception jumps past f.close() and the file stays open. You could wrap it in try and finally, but there's a cleaner way built for exactly this.

with: the way you actually do it

A with block opens the file, gives it to you as f for the length of the block, and closes it on the way out. It closes it whether the block finishes normally or raises an error.

with open("reading-log.csv") as f:
    contents = f.read()

print("closed?", f.closed)
print(contents[:28])
Output
closed? True
title,author,rating,finished

Notice that f still exists after the block. It hasn't disappeared; it's just closed, so you can't read from it any more. What you keep is contents, the string you pulled out while the file was open.

You can open more than one file in a single with by separating them with a comma. That's how you copy one file's contents into another:

with open("reading-log.csv") as source, open("backup.csv", "w") as target:
    target.write(source.read())

with open("backup.csv") as f:
    print(f.readline(), end="")
Output
title,author,rating,finished

Both files close when the block ends, in reverse order.

This is the form to use every time. From here on, every example in the book opens files with with, and so should you. The only reason to call open and close by hand is to understand what with is doing for you, which you now do.

Common mistakes

Opening in "w" when you meant "a". "w" empties the file the moment you open it, before f.write runs at all:

with open("today.txt", "w") as f:
    pass

with open("today.txt") as f:
    print(repr(f.read()))
Output
''

today.txt had two lines a moment ago. Opening it in "w" threw them away. If you meant to add to a file, the mode is "a".

Reading from a file after the with block. Once the block ends the file is closed, and a closed file object won't read:

with open("reading-log.csv") as f:
    pass

f.read()

That raises ValueError with the message I/O operation on closed file. Do your reading inside the block, and keep the result in a variable, the way contents kept the text in the earlier examples.

Forgetting the mode when you meant to write. open("notes.txt") with no mode is read-only. Call .write on it and you get io.UnsupportedOperation: not writable. The fix is to pass "w" or "a".

Practice

Try each of these before you read the solution under it.

  1. Copy reading-log.csv to log-copy.csv using the two-file with form, then open the copy in "a" mode and add a row for a book you finished recently. Read the copy back and print how many lines it has.
  2. Write line_count(path) that returns the number of lines in a file, and returns 0 rather than raising if the file does not exist.
  3. Write create_if_new(path) that creates the file empty if it is not there, and prints a message instead of crashing if it already exists.

Solutions

1.

with open("reading-log.csv") as src, open("log-copy.csv", "w") as dst:
    dst.write(src.read())

with open("log-copy.csv", "a") as f:
    f.write("Dune,Frank Herbert,5,2026-09-05\n")

with open("log-copy.csv") as f:
    print(len(f.readlines()), "lines")
Output
12 lines

2. Catch FileNotFoundError and return the default. The _ is a throwaway name for the loop variable, since you only want the count.

def line_count(path):
    try:
        with open(path) as f:
            n = 0
            for _ in f:
                n += 1
            return n
    except FileNotFoundError:
        return 0

print(line_count("reading-log.csv"))
print(line_count("nope.csv"))
Output
11
0

3. "x" mode does the "only if new" check for you. Catch FileExistsError for the other case.

def create_if_new(path):
    try:
        with open(path, "x"):
            pass
        print(f"created {path}")
    except FileExistsError:
        print(f"{path} already exists")

create_if_new("fresh.txt")
create_if_new("fresh.txt")
Output
created fresh.txt
fresh.txt already exists

Where this leaves you

You can open a file for reading, writing, or appending; you know which modes create a file and which expect it to exist already; you can read a whole file or part of one; and you know why the file has to be closed and how with closes it for you. Chapter 2 is next, about pulling text out of a file in more useful shapes than one big string.