Skip to content
  • Home
  • How To
  • M0
  • Module 1 - Python
  • Agents
  • M2
  • M3
  • M4
  • M5
  • M6
  • Progress
  • Contact
  • Pricing
  • Sign in
  • Future
  1. Module 1M1
  2. ›
    S5Strings, Files, and Data Formats
  3. ›
    Lesson 2 of 3Reading and Writing Files Safely
  4. ›
    S5›Lesson 2 of 3
  1. Module 1
  2. ›
  3. Section 5
  4. ›
  5. Lesson 2

Reading and Writing Files Safely

FREE·~1.5 h·2 labs·4 questions·1 assignment


Context

Opening a file gives you a handle to read from or write to, and the mode chosen at that moment decides which. Wrapping the open in a managing block guarantees the file is closed afterwards, including when something fails partway through.

This is the boundary between a script and everything it has been given. Training data, saved results, configuration, and logs all cross it. Reading is usually done a line at a time so that a file larger than memory still works, and writing is how a cleaned dataset or a metrics summary outlives the run that produced it.

Two mistakes here cause real loss. The write mode, "w", truncates the file the instant it is opened, so pointing it at the raw dataset destroys the raw dataset before a single line is written, with no warning and nothing to recover. Leaving a file unclosed lets buffered output sit in memory instead of on disk, so anything that reads the results file while the script is still running can find it short or empty, and a run that is killed outright loses that output for good. Encoding is the third, a file read under the wrong assumption comes back with its unusual characters garbled, or stops partway with an error. The previous lesson handled text already in memory, this lesson gets it in and out.


Goal

  • Open a file with open() and obtain a file handle from a path, absolute or relative.
  • Choose the correct mode, r, w, a, for reading, overwriting, or appending, and predict what each does to existing data.
  • Wrap every file operation in a with block so the file is always closed, even on error.
  • Read a file one line at a time and strip each line for use.
  • Write results back to disk and set encoding="utf-8" explicitly so text reads and writes the same on every machine.

Prerequisites

Both optional. The lesson does not wait for them, they are here if you want to see the with open pattern before you build one.

  • YouTube - "Python Tutorial: File Objects - Reading and Writing to Files" by Corey Schafer. Watch it once for the with open pattern.
  • Official docs - The Python Tutorial, "Reading and Writing Files". Read it for open, modes, and the with statement.

Table of Contents

  • File Open (open)
  • File Modes
  • Context Manager (with)
  • Reading Lines
  • Writing to a File
  • File Encoding Parameter

Theory

From here on, your programs stop using hard-coded data and start reading the world's data off disk. The with open(...) block is the doorway. Module 0 already opened a file with with open to show that disk outlives a process, and introduced the working directory every relative path depends on. This lesson turns that first contact into deliberate practice, the modes and what each does to existing data, the close guarantee and line-by-line reading, then adds the rule that maps stored bytes to readable text.

File Open (open)

What. open() is a built-in function that takes a file path and returns a file object, a handle you use to read from or write to that file. The path can be relative to the working directory or absolute. Why. A file on disk is inert until your program connects to it. open is that connection: it locates the file, checks permissions, prepares it for the requested operation, and hands back an object whose methods do the actual reading and writing. Every read and every write starts here. When. Call open whenever you need to read existing data from disk or write results out to it. In practice you almost always call it as part of a with block (a later subtitle), but understanding what open itself does and returns comes first.

Two files are read below, so create them once before you start. Creating a file is one job of the write mode, which is the next subtitle, and the only thing to carry from it here is that "w" brings a file into existence while a plain open demands one that is already there.

# Create the file the rest of this lesson reads, in the working directory.
setup = open("data.txt", "w", encoding="utf-8")
setup.write("setosa\n")
setup.write("versicolor\n")
setup.write("virginica\n")
setup.close()

# And one at a fixed absolute location, for the absolute-path example below.
setup = open("/tmp/notes.txt", "w", encoding="utf-8")
setup.write("notes\n")
setup.close()

open(path) returns a file object. The path is resolved relative to the current working directory unless it is absolute, which is the same path-resolution behavior from Module 0, a relative name like "data.csv" is looked up from wherever the program is run, not from where the script file lives.

# A relative path is resolved from the current working directory.
handle = open("data.txt")  # opens 'data.txt' in the working directory

# The returned object is a file handle, not the file's contents.
print(type(handle))        # <class '_io.TextIOWrapper'>

# Always close what you open (the 'with' block automates this later).
handle.close()             # releases the file so the OS can reclaim it

That distinction from Module 0 is the reason the same script can find a file when you run it from one folder and fail from another.

An absolute path removes the dependence on the working directory, which is sometimes what you want and sometimes too rigid. A missing file raises FileNotFoundError immediately.

# An absolute path is unambiguous regardless of working directory.
handle = open("/tmp/notes.txt")  # exact location on disk
handle.close()

# Opening a non-existent file for reading raises an error.
missing = open("nope.txt")       # raises FileNotFoundError if it does not exist

The error surfaces at the open call itself rather than at the first read, which is genuinely helpful, the traceback points at the line naming the path rather than somewhere downstream.

DON'T:

  • DON'T treat the return of open as the file's contents. It is a handle, you must call read methods on it to get the text.
  • DON'T forget that a relative path depends on the working directory. Running the same script from a different directory can make "data.txt" point somewhere else or fail.

File Modes

What. The mode is the second argument to open that declares your intent: "r" to read, "w" to write (overwriting), "a" to append. There are also "r+" and binary variants, but these three are the core. Why. The mode is a safety contract. It tells Python what you are allowed to do and what happens to existing data. Choosing "w" when you meant "a" silently erases a file, and choosing "r" when the file does not exist raises an error. Picking the mode deliberately is the difference between safe I/O and lost data. When. Use "r" to read an existing file, "w" to create or completely replace a file, and "a" to add to the end of a file without disturbing what is already there. Default mode is "r", so reading needs no explicit mode, but writing always does.

"r" is read-only and is the default. The file must already exist, or open raises FileNotFoundError. You cannot write through a handle opened in "r".

# "r" is read-only and the default, so these two are equivalent.
f = open("data.txt", "r")
f.close()             # close the first before opening a second handle
f = open("data.txt")  # same thing, "r" assumed

# Writing through a read handle is an error.
f.write("x")          # raises io.UnsupportedOperation: not writable
f.close()             # never reached, which is exactly the leak 'with' exists to close

The mode is enforced by the handle, not checked at write time by your own code, so a read handle simply cannot damage the file no matter what you call on it.

"w" creates the file if it is absent and truncates it to empty if it exists. This is the dangerous one: opening an existing file in "w" destroys its contents instantly, before you write anything.

# "w" CREATES the file, or TRUNCATES it to empty if it already exists.
f = open("out.txt", "w")  # if 'out.txt' existed, it is now empty
f.write("first line\n")   # write new content
f.close()

# Reopening in "w" again would WIPE what you just wrote.
f = open("out.txt", "w")  # 'out.txt' is empty again
f.close()

The truncation happens at the open call, before any write and whether or not you ever write at all. A with open(path, "w") block whose body raises on its first line has still emptied the file. This is the most common destructive file bug there is, so reach for "w" only when replacing the whole file is genuinely what you want.

"a" (append) creates the file if absent but preserves existing contents, adding new writes at the end. This is the safe choice for logs and incremental output.

# "a" preserves existing content and adds new writes at the END.
f = open("log.txt", "a")  # existing content is kept
f.write("new entry\n")    # appended after whatever was there
f.close()

Use "a" whenever output accumulates across runs, such as a log, where overwriting would quietly discard every previous run.

DON'T:

  • DON'T open an existing file in "w" unless you mean to erase it. Truncation happens at open, before any write, use "a" to add or "r" to read.
  • DON'T expect "r" to create a missing file. It raises FileNotFoundError, and of these three modes only "w" and "a" create files.

Context Manager (with)

What. A context manager, used via the with statement, is a block that automatically performs setup and cleanup. For files, with open(...) as f: opens the file and guarantees it is closed when the block ends, even if an error occurs inside. Why. A file left open leaks an operating-system resource, and what you write can sit in a buffer until the file is closed, so until then the file on disk may be missing it, and a process killed before the close never writes what is still buffered. Calling .close() manually is easy to forget, and a close placed after the risky line is skipped entirely when an exception jumps out of the function, unless it sits in a finally block, which Lesson 1.6.2 teaches. The with block makes correct cleanup automatic and unconditional. When. Use with for essentially every file operation. It is the standard, idiomatic way to work with files in Python, and there is rarely a reason to call open and .close() by hand.

The with statement opens the file, binds the handle to a name with as, and runs the indented block. When the block ends, normally or by exception, the file is closed automatically.

# 'with' opens the file and guarantees it closes at the end of the block.
with open("data.txt", "r", encoding="utf-8") as f:
    contents = f.read()   # read the whole file as one string
    print(len(contents))  # 28, work with it inside the block
# Here, OUTSIDE the block, the file is already closed automatically.
print(f.closed)           # True, 'with' closed it for us

Note that f still exists after the block, it is an ordinary name, and only the file behind it is closed. That is why f.closed can be read on the last line, and why reading or writing through f there would raise.

The crucial guarantee is that cleanup happens even when the block raises. A manual open/close skips the close if an error fires between them, leaking the file, unless the close sits in a finally block (Lesson 1.6.2), while with always closes.

# Even if an error occurs inside, 'with' still closes the file.
with open("data.txt", "r", encoding="utf-8") as f:
    value = int(f.read())  # if this raises ValueError...
    # ...the file is STILL closed as the exception propagates out.

# The manual equivalent is verbose AND unsafe if it raises before close:
f = open("data.txt", "r", encoding="utf-8")
value = int(f.read())      # an error here skips the close below -> leak
f.close()

The guarantee is unconditional, which is the whole point. In the manual version an exception on the int(...) line jumps straight past f.close(), and for a file opened for writing that matters more than a leaked handle, because the written text can still be sitting in a buffer.

DON'T:

  • DON'T mix manual .close() with with. The block already closes the file, calling .close() again is redundant and closing then using f raises an error.
  • DON'T do heavy, slow, or unrelated work inside the with block, except the per-line work on a file too large to hold in memory. Read what you need, then exit the block so the file closes promptly, process the data afterward.

Reading Lines

What. Reading lines means consuming a file's text one line at a time. Iterating the file object directly (for line in f:) yields each line, including its trailing newline character when it has one. Why. Data files are line-oriented, one record per line, and they can be far too large to load entirely into memory. Iterating line by line processes a file of any size while holding only one line in memory at a time, and it pairs perfectly with the string cleaning you already know, since each line arrives as a string to strip and split. When. Iterate line by line whenever you process a file record by record, which is almost always for data files. Use .read() for the whole file only when it is small and you genuinely need it as one string, and .readlines() when you specifically want a list of all lines at once.

The idiomatic read is for line in f:, which yields each line as a string with its trailing \n attached when it has one. You strip that newline before using the line, applying the cleaning reflex from the previous lesson.

with open("data.txt", "r", encoding="utf-8") as f:
    # Iterating the file yields one line per loop, WITH the trailing newline.
    for line in f:
        clean = line.strip()  # remove the trailing '\n' and any padding
        if clean == "":       # skip blank lines
            continue
        print(clean)          # the usable, cleaned line

Iterating the handle reads one line at a time, so memory follows the longest line rather than the size of the file.

.read() loads the entire file as one string, and .readlines() loads it as a list of lines. Both hold the whole file in memory, so they suit small files only.

with open("data.txt", "r", encoding="utf-8") as f:
    whole = f.read()       # the entire file as a single string

with open("data.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()  # a LIST of lines, each with its '\n'

# A reading idiom: read all, split into stripped lines.
with open("data.txt", "r", encoding="utf-8") as f:
    # clean, non-empty
    rows = [line.strip() for line in f if line.strip()]

The final comprehension is the common idiom, cleaning and filtering in the same pass that reads.

DON'T:

  • DON'T forget to strip the newline. for line in f: keeps the \n, so "cat\n" will not equal "cat" and "42\n".isdigit() answers False on a line holding nothing but digits.
  • DON'T use .read() or .readlines() on a huge file. They load it all into memory, iterate with for line in f: instead for large files.

Writing to a File

What. Writing persists data to disk through a handle opened in "w" or "a". .write(text) writes a single string, and .writelines(list) writes a list of strings. Neither adds a newline for you. Why. A program that computes results in memory loses them the instant it ends, unless it writes them out. Writing is how results reach persistent storage, the disk Module 0 saved its first checkpoint to, so they survive the program, can be shared, or feed the next stage of a pipeline. When. Write whenever results must outlive the program: saving a cleaned dataset, logging metrics across runs, exporting a report. Choose "w" to produce a fresh file and "a" to add to an existing one, per the modes subtitle.

.write(text) writes exactly the string you give it, with no automatic newline. You must include "\n" yourself to separate lines, a frequent first surprise.

with open("out.txt", "w", encoding="utf-8") as f:
    # .write does NOT add a newline, you must include '\n' yourself.
    f.write("first line\n")
    f.write("second line\n")
    f.write("no newline here")  # the next write would be on the same line

This is the asymmetry that catches people, print adds a newline for you and .write never does, so two writes without an explicit "\n" produce one line rather than two.

.writelines(list) writes each string in a list, again with no newlines added, so the strings must already contain them. It does not insert separators between items.

rows = ["1,cat\n", "2,dog\n", "3,bird\n"]

with open("out.txt", "w", encoding="utf-8") as f:
    # writelines writes each string as-is, newlines must already be present.
    f.writelines(rows)

# Building lines to write, from data, with f-strings:
records = [(1, "cat"), (2, "dog")]
with open("out.txt", "w", encoding="utf-8") as f:
    for rec_id, label in records:
        f.write(f"{rec_id},{label}\n")  # format each record into a line

f.writelines(rows) writes the three strings back to back, relying on the "\n" already inside each, it adds nothing between them. The second block is the common pattern for writing computed data: loop over the records, format each into a line with an f-string (note the explicit "\n"), and write it. This mirrors the join-and-write side of the split/join pair from the previous lesson.

DON'T:

  • DON'T expect .write() or .writelines() to add newlines. You must include "\n" yourself, or every line runs together.
  • DON'T open the output in "w" inside a loop that runs per record. That truncates the file on every iteration, open once outside the loop, then write each record inside.

File Encoding Parameter

What. The encoding parameter of open specifies how bytes on disk are translated to and from text characters. encoding="utf-8" is the modern, near-universal choice for text files. Why. A file is bytes, and text is characters, and a rule called an encoding maps between them. If you read a file with the wrong encoding, characters like accented letters or symbols come out garbled or stop the read with a UnicodeDecodeError, the "strange symbols" problem. Worse, the default encoding can differ between machines, so code that works on one computer fails on another. Setting encoding="utf-8" explicitly makes the behavior identical everywhere, and correct for every file written in UTF-8. When. Set encoding="utf-8" on every text file you open, for both reading and writing. Make it a reflex, the cost is a few characters and the benefit is portable text handling, correct for every file written in UTF-8.

Pass encoding="utf-8" as a keyword argument to open. The same value should be used when writing a file and when reading it back, so the byte-to-character mapping is consistent.

# Always specify encoding, so a UTF-8 file reads and writes the same everywhere.
with open("data.txt", "r", encoding="utf-8") as f:
    text = f.read()

# Use the SAME encoding to write, so round-tripping is lossless.
with open("data.txt", "w", encoding="utf-8") as f:
    f.write("café, naïve, 数据\n")  # non-ASCII characters write correctly

Reading and writing must agree. Write UTF-8 and read it back as something else and the characters come back garbled, or the read stops with a UnicodeDecodeError, which is the mismatch this parameter exists to prevent.

Omitting encoding uses a platform-dependent default, which is the source of "works on my machine" text bugs.

# RISKY: no encoding -> platform default, may differ across machines.
with open("data.txt") as f:
    text = f.read()  # could garble or raise on a machine with a different default

# SAFE: explicit utf-8 behaves identically everywhere.
with open("data.txt", encoding="utf-8") as f:
    text = f.read()

The risk of the first form is not that it is wrong here, it is that it is wrong somewhere else. It reads correctly on the machine that wrote the file and garbles or fails on a colleague's, which is the definition of a bug you cannot reproduce.

DON'T:

  • DON'T rely on the default encoding. It is platform-dependent, so omitting it makes your code's text handling non-portable, always pass encoding="utf-8".
  • DON'T mismatch encodings between writing and reading. Write UTF-8 and read it as something else, and the characters come back garbled, or the read stops with a UnicodeDecodeError.

Lab Exercises

Lab Exercise 1: Write It, Append to It, Then Wipe It

Using a with block in "w" mode with explicit UTF-8 encoding, write the three lines "setosa", "versicolor" and "virginica" to labels.txt, each on its own line. Reopen the file for reading and print each line stripped of its newline.

Then open the same file in "a" mode and add one more label. Read it back and print how many lines it now holds, to show the original three survived.

Finally, open the file in "w" mode one more time and write nothing at all inside the block. Read it back and print the line count again. Add a comment stating what emptied the file, given that no .write call was made.

Lab Exercise 2: Read a File Line by Line, Then Write a Cleaned Copy

First create raw.txt with a with block, giving it several lines of which at least two are blank and at least one has surrounding whitespace.

Then open it for reading, iterate it one line at a time, strip each line, skip the empty ones, and count what remains. Print the count.

Then, in a second pass, write a cleaned copy to clean.txt in which every non-empty line is stripped and uppercased. Read clean.txt back and print its contents as a list so the newlines are visible.

Finally, write two values to joined.txt with two separate .write calls and no "\n" anywhere, read it back with .readlines(), and print the result. Add a comment saying how many lines you asked for and how many you got.


Solution 1: Write It, Append to It, Then Wipe It
# "w" creates the file, or empties it if it already exists.
with open("labels.txt", "w", encoding="utf-8") as f:
    for label in ["setosa", "versicolor", "virginica"]:
        f.write(label + "\n")  # '.write' never adds the newline for you

with open("labels.txt", "r", encoding="utf-8") as f:
    for line in f:             # iterating the handle yields one line at a time
        print(line.strip())    # the trailing '\n' comes with it
# "a" keeps what is there and positions writes at the end.
with open("labels.txt", "a", encoding="utf-8") as f:
    f.write("unknown\n")

with open("labels.txt", "r", encoding="utf-8") as f:
    print(len(f.readlines()))  # 4
# "w" again, with an empty body. No write call is made.
with open("labels.txt", "w", encoding="utf-8") as f:
    pass

with open("labels.txt", "r", encoding="utf-8") as f:
    print(len(f.readlines()))  # 0
# The 'open' call emptied it. Truncation is part of opening in "w", so it had
# already happened before the body ran, and an empty body changes nothing.

DON'T:

  • DON'T open a file you want to keep in "w" mode, the truncation is done by open itself and no amount of care inside the block undoes it.
  • DON'T rely on .write to separate your lines, it writes the argument and nothing more.
Solution 2: Read a File Line by Line, Then Write a Cleaned Copy
with open("raw.txt", "w", encoding="utf-8") as f:
    f.write("  setosa  \n")
    f.write("\n")
    f.write("versicolor\n")
    f.write("   \n")
    f.write("  virginica\n")

# Iterating the handle holds one line in memory at a time.
kept = 0
with open("raw.txt", "r", encoding="utf-8") as f:
    for line in f:
        clean = line.strip()  # drops the trailing '\n' and any padding
        if clean == "":       # a line of only spaces strips to empty too
            continue
        kept += 1
print(kept)                   # 3
with open("raw.txt", "r", encoding="utf-8") as src:
    cleaned = [line.strip().upper() for line in src if line.strip()]

with open("clean.txt", "w", encoding="utf-8") as out:
    for line in cleaned:
        out.write(line + "\n")

with open("clean.txt", "r", encoding="utf-8") as f:
    print(f.readlines())  # ['SETOSA\n', 'VERSICOLOR\n', 'VIRGINICA\n']
with open("joined.txt", "w", encoding="utf-8") as f:
    f.write("first")
    f.write("second")

with open("joined.txt", "r", encoding="utf-8") as f:
    print(f.readlines())  # ['firstsecond']
# Two writes were asked for and one line came back. Nothing separates two writes
# unless you write the separator yourself.

The blank-line guard is worth one note. The file contains both an empty line and a line of three spaces, and clean == "" catches both, because stripping a whitespace-only line leaves the empty string. Testing if line == "\n" instead would have caught only the first.

DON'T:

  • DON'T compare an unstripped line to a bare value, "setosa\n" is not "setosa", and printed, the difference is only an empty line you can miss.
  • DON'T reach for .read() or .readlines() on a file you did not create, they load the whole thing into memory and the size is not yours to assume.

Theory Assessment

Q1: What You Get Back, and What the Mode Promises

What does open("data.txt") return, and what does it not return? Say what happens to the relative path "data.txt" when the same script is run from a different directory. Then explain the difference between opening an existing file in "w" and in "a", and state precisely when the destructive part of "w" happens.

Hint: The return value is a connection rather than content. For the second half, ask what the file looks like immediately after the open call and before any method is called on the handle.

Q2: Why the Block, and Why One Line at a Time

Explain what with open(...) as f: guarantees that a manual open followed by .close() does not, and describe the specific situation where the manual version loses data rather than merely leaking a handle. Then explain why for line in f: is preferred over .read() for a large file.

Hint: For the first half, think about what happens when a line between the open and the close raises. For the second, ask how much of the file is in memory at any one moment under each approach.

Q3: The Newline You Get and the Newline You Do Not

Explain why for line in f: gives you "cat\n" rather than "cat", and what that breaks if you do not handle it. Then explain what .write("cat") puts in the file, and what you must add to get two writes onto two lines.

Hint: Reading and writing are asymmetric about the newline, and in opposite directions. One hands you a character you did not ask for and the other withholds one you assumed.

Q4: Two Writes, One Line, and a File That Emptied Itself

Read the following without running it, then state what the two print calls show.

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("alpha\n")
    f.write("beta\n")

with open("notes.txt", "w", encoding="utf-8") as f:
    pass  # nothing is written in this block

with open("notes.txt", "a", encoding="utf-8") as f:
    f.write("gamma")
    f.write("delta\n")

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

print(lines)
print(len(lines))

Four writes happened across the whole script and the file ends up holding one line. Explain where "alpha" and "beta" went, and why "gamma" and "delta" did not end up on separate lines.

Hint: One of the four blocks writes nothing and is still the most consequential. For the second half, count the newline characters actually written rather than the number of .write calls.


Answer 1:

open("data.txt") returns a file object, a handle connected to the file. It does not return the file's contents, so printing it shows something like <_io.TextIOWrapper ...> rather than any text, and you have to call a read method on it to get the characters.

A relative path is resolved from the current working directory, which is wherever the program was launched from, not where the .py file sits. So running the same script from a different directory makes "data.txt" refer to a different location, and it raises FileNotFoundError if nothing is there.

"w" truncates an existing file to empty, while "a" preserves the contents and positions new writes at the end. The destructive part of "w" happens at the open call itself, before any write and regardless of whether you ever write. A with open(path, "w") block whose body does nothing has still emptied the file.

DON'T treat the value open returns as the text, and don't assume a relative path means the same thing to every person who runs your script.

Answer 2:

with guarantees that the file is closed when the block ends for any reason, including an exception traveling out of it. The manual open/.close() pair only closes when control actually reaches the .close() line, so an error raised between the two skips it entirely.

The situation where that costs data rather than a handle is a file opened for writing. Output is buffered, and the buffer is written to disk when the file is closed, so if an exception skips the .close() whatever is still buffered stays in memory. If the program catches the exception and carries on, the file on disk can stay short or empty while the handle is still open, so the next step that reads it can get the wrong data, and if the process is killed before it exits, the buffered text is never written at all. Nothing in the traceback mentions it.

for line in f: is preferred for a large file because it holds one line in memory at a time, so the memory use is set by the longest line rather than by the file's size. .read() builds a single string containing the entire file, so a file larger than available memory cannot be read that way at all.

DON'T hand-roll open and close around code that can raise, the failure mode on a write handle is a file that is silently short of your own output.

Answer 3:

for line in f: yields "cat\n" because the newline is a real character stored in the file, and iterating hands you each line up to and including its terminator. Left in place it breaks comparisons, since "cat\n" == "cat" is False, and it breaks validation, since "42\n".isdigit() is False even though the line holds nothing but digits. Calling .strip() on each line is the standard fix.

.write("cat") puts exactly cat in the file and nothing else, with no newline. To get two writes onto two lines you must include the newline yourself, f.write("cat\n"), because .write never adds one.

The two behaviors are opposite and both surprise people: reading gives you a character you did not ask for, and writing withholds one you assumed. print adds a newline, which is where the assumption comes from.

DON'T carry an unstripped line into a comparison or a numeric check, and don't expect .write to behave like print.

Answer 4:

lines prints ['gammadelta\n'] and len(lines) prints 1.

"alpha" and "beta" were written and then destroyed. The second block opens the same path in "w" mode, and that truncates the file to empty as part of opening it. The block's body is pass, so it writes nothing, but the damage was done by the open call before the body ever ran. An empty "w" block is not a no-op, it empties the file.

"gamma" and "delta" are on one line because only one newline was written. The third block appends "gamma" with no terminator, so the next write continues from exactly that point, and "delta\n" lands directly after it, producing gammadelta\n. Four .write calls ran in total across the script and the file holds one line, because lines are made by newline characters rather than by write calls.

The two halves are the same lesson from opposite sides. "w" does something you did not ask it to do at a moment you were not thinking about, and .write declines to do something you assumed it would. Neither raises.

DON'T count .write calls to predict a file's line count, count the "\n" characters you actually wrote.


Assignment

Assignment: A Safe Overwrite, and the Two Ways an Unsafe One Loses Data

Build the following in one file, in this order.

Write safe_write(path, lines) that, before replacing path with the new lines, copies the current contents of path into path + ".bak" when path already exists, and skips the backup when it does not. Use with and explicit UTF-8 throughout, and give every written line its own newline.

Begin by deleting any files this script wrote on a previous run, with os.remove(path), which deletes the file at path, so that running it twice gives the same output both times. Then demonstrate it: call it once on a path that does not exist and show no backup was created. Call it again with different content, then print the current file and the backup side by side to show the old content survived.

Then demonstrate the two failures rather than describing them.

First, write unsafe_write(path, lines) that opens path in "w" mode and only afterwards tries to read the old contents for its backup. Run it on an existing file, print whatever it managed to back up, and add a comment stating what the backup contains and why. Note before you run it that nothing here raises.

Second, write no_newline_write(path, lines), a plain writer that opens path in "w" and writes each line without a trailing newline. Run it with three lines, read the file back with .readlines(), print the result and its length, and add a comment stating how many lines you supplied and how many came back.

Hints:

  • Guard the backup with if os.path.exists(path): so the first ever write does not fail on a file that is not there yet.
  • For the first failure, remember that truncation is part of opening in "w", so ask what is left to read once that open has run.

Solution:
# 'os.path.exists' answers whether the path is already there.
import os

# Start from a clean slate so this script gives the same answer every run.
for leftover in ("cfg.txt", "cfg.txt.bak", "cfg2.txt", "cfg2.txt.bak", "flat.txt"):
    if os.path.exists(leftover):
        os.remove(leftover)           # deletes the file, so a re-run is not affected by the last one

def safe_write(path, lines):
    if os.path.exists(path):          # nothing to back up on the very first write
        with open(path, "r", encoding="utf-8") as f:
            old = f.read()
        with open(path + ".bak", "w", encoding="utf-8") as f:
            f.write(old)
    # truncates, deliberately
    with open(path, "w", encoding="utf-8") as f:
        for line in lines:
            f.write(line + "\n")      # the newline is ours to add

safe_write("cfg.txt", ["lr=0.01", "epochs=10"])
print(os.path.exists("cfg.txt.bak"))  # False

safe_write("cfg.txt", ["lr=0.05"])
with open("cfg.txt", "r", encoding="utf-8") as f:
    current = f.read().splitlines()   # one string per line, newlines dropped
with open("cfg.txt.bak", "r", encoding="utf-8") as f:
    backup = f.read().splitlines()
print(current, backup)                # ['lr=0.05'] ['lr=0.01', 'epochs=10']
# FAILURE 1: the backup is read AFTER the destination has been opened for writing.
def unsafe_write(path, lines):
    # truncates HERE
    with open(path, "w", encoding="utf-8") as out:
        with open(path, "r", encoding="utf-8") as f:
            old = f.read()  # reads the file that was just emptied
        with open(path + ".bak", "w", encoding="utf-8") as b:
            b.write(old)
        for line in lines:
            out.write(line + "\n")

safe_write("cfg2.txt", ["keep=this", "and=this"])
unsafe_write("cfg2.txt", ["new=value"])
with open("cfg2.txt.bak", "r", encoding="utf-8") as f:
    print(repr(f.read()))   # ''
# The backup is empty. Opening 'path' in "w" truncated it, so by the time the
# read ran there was nothing left to copy. No error was raised at any point.
# FAILURE 2: every line written without its terminator.
def no_newline_write(path, lines):
    with open(path, "w", encoding="utf-8") as f:
        for line in lines:
            f.write(line)  # the '+ "\n"' is missing

no_newline_write("flat.txt", ["one", "two", "three"])
with open("flat.txt", "r", encoding="utf-8") as f:
    got = f.readlines()
print(got, len(got))       # ['onetwothree'] 1
# Three lines were supplied and one came back. A line is made by a newline
# character, not by a separate call to '.write'.

The two failures are both about when and what, rather than about anything being written incorrectly.

unsafe_write performs every step of a correct backup, in the wrong order. It reads the file, writes what it read to .bak, and writes the new content, and every one of those operations succeeds. It backs up an empty file because the destructive part of "w" had already run at the open that started the block. Reading the file before it is opened in "w" is the entire fix, which is what safe_write does.

no_newline_write writes exactly the three strings it was given. Nothing is lost and nothing is corrupted, the file holds all eleven characters. It is only when the file is read back as lines that the omission shows, because the file has no line structure to recover.

DON'T open the destination before you have finished reading everything you need from it, and don't judge a written file by whether the write succeeded, read it back the way it will actually be consumed.


Additional Resources

Optional, and nothing later depends on either. Worth a look if you want paths as objects, or the full reference behind mode and encoding.

  • Official docs - pathlib, "Basic use". Paths as objects rather than strings, which is how most new code builds, joins and tests them.
  • Official docs - Built-in Functions, the open entry, focusing on mode and encoding.

Summary

This lesson is the threshold your programs cross from toy to tool. Until now almost every value you worked with was typed into the source code, so a program could only ever know what you already knew when you wrote it. Files change that. With open and a with block, your code reaches off the disk and pulls in data it has never seen, and writes results that outlive the run. That is the whole point of persistent storage, the Module 0 idea now put to work on data you did not write: the program ends, but the file remains.

The discipline here is a small, precise protocol, and the payoff for following it is that whole categories of bug simply never occur. Choose the mode deliberately, because "w" erases on open and there is no undo. Wrap the work in with, because a file must be closed even when something goes wrong, and with is the only way to guarantee that without thinking about it. Strip every line you read, because the newline that rides along will quietly break the next comparison or validation check. Write your own newlines, because the file methods add none. And set encoding="utf-8" every single time, because the alternative is text that can work on your machine and garble or fail on someone else's. None of these is hard, but each is a habit, and the habits are the lesson.

What you cannot quite do yet is understand the data you are reading. A line like 1,setosa,5.1 comes in as one undifferentiated string, and turning it into a record, an id, a label, a measurement, each as its proper type, takes the string skills from the last lesson and the file skills from this one and combines them into something larger. That combination is the next lesson: parsing structured text, where the comma-separated line becomes a dictionary of named fields, the header row names the columns, and a file of such lines becomes a list-of-dictionaries table, one record dictionary per data line.


Cheat Sheet

Part A - Reference Table

Operation Code Notes
Open to read open(path, "r", encoding="utf-8") file must exist
Open to overwrite open(path, "w", encoding="utf-8") TRUNCATES on open
Open to append open(path, "a", encoding="utf-8") keeps existing content
Safe open with open(...) as f: auto-closes, even on error
Read all f.read() whole file as one string
Read lines (list) f.readlines() list, newlines kept
Iterate lines for line in f: one line at a time, low memory
Strip newline line.strip() required on each read line
Write string f.write(text) no newline added
Write list f.writelines(lines) no newlines added
Encoding encoding="utf-8" always set it

Part B - Compact Code Block

# Write, then read back, always utf-8, always 'with'.
with open("out.txt", "w", encoding="utf-8") as f:
    for row in ["a", "b", "c"]:
        f.write(row + "\n")  # explicit newline

with open("out.txt", "r", encoding="utf-8") as f:
    # clean, non-empty
    lines = [line.strip() for line in f if line.strip()]

with open("out.txt", "a", encoding="utf-8") as f:
    f.write("d\n")           # append, keeps a/b/c

# Modes: r=read(exists), w=overwrite(truncates), a=append(keeps)
# 'with' closes, strip read lines, add '\n' yourself
←Previous: Lesson 1
Next: Lesson 3→

Questions about this lesson: #module-1 on Discord

Found an error in this lesson? Report it

Your progress
Current streak
🔥 0
Lessons marked today
0 / 3
Longest streak
0

Sign in

The bootcamp

  • Curriculum
  • How To
  • Pricing
  • Changelog
  • Future

The community

  • Discord
  • Reviews
  • Contact

Follow

  • Instagram
  • X
  • TikTok
  • Reddit

Legal

  • Terms
  • Privacy
  • Refunds

Coming soon on November 16, 2026

Theme