Skip to main content

pathlib & file processing

Level 3: Level 3: Patternsmedium17 minpathlibfilestext-processingio

Read and transform real files in a project with pathlib.

Why file paths break in production

The bug that ruins a data pipeline at 2am is rarely the algorithm. It is a path. A script that reads data/scores.txt works on your laptop and fails on the server because the two machines were launched from different directories. Hardcoded string paths are also fragile across operating systems, where the path separator differs. pathlib.Path exists to make paths a real object with methods instead of fragile strings you glue together by hand. As a data engineer you touch files constantly (CSVs, logs, exports), so getting this layer right is table stakes.

The mental model

Check yourself
You run p = Path('data/scores.txt') on a machine where no such file exists. What happens on that line?

A Path is an object that represents a location, not the file's contents. Building one does no I/O and does not require the file to exist. You only touch disk when you call a method like read_text() or exists().

from pathlib import Path

p = Path("data") / "scores.txt"   # "/" joins path parts, OS-correct
p.exists()                        # True / False, cheap check
p.suffix                          # ".txt"
text = p.read_text(encoding="utf-8")   # whole file -> one str

The / operator is real: Path overloads it so Path("data") / "scores.txt" builds the joined path with the correct separator on any OS. Prefer it over "data/" + name.

Once you have a Path, its parts are attributes rather than string surgery:

Check yourself
A pipeline receives a file named archive.tar.gz. For Path('archive.tar.gz'), what do .suffix and .stem give you?
Table
Two of these bite. .suffix keeps the dot, so comparing it to 'csv' always fails and you want '.csv'. And on a double extension like archive.tar.gz, .suffix is only '.gz' while .stem is still 'archive.tar', because both look at the LAST dot only.
AttributeValue for Path('data/reports/scores.csv')What you reach for it for
.namescores.csvthe filename, extension included
.stemscoresthe filename without the extension
.suffix.csvthe extension, INCLUDING the leading dot
.parentdata/reportsthe containing directory, itself a Path
.parts('data', 'reports', 'scores.csv')every segment as a tuple
Two of these bite. .suffix keeps the dot, so comparing it to 'csv' always fails and you want '.csv'. And on a double extension like archive.tar.gz, .suffix is only '.gz' while .stem is still 'archive.tar', because both look at the LAST dot only.

Turning text into data

read_text() hands you the entire file as one string. Split it into lines, then convert:

text = "10\n20\n30"
numbers = [int(line) for line in text.splitlines() if line.strip()]
print(sum(numbers))   # 60

splitlines() breaks on line boundaries and drops the \n characters. The if line.strip() guard skips blank or whitespace-only lines so int() never receives an empty string. int() itself strips surrounding whitespace, so int(" 10 ") returns 10 without extra work. This is exactly the shape both exercises want: first sum the numbers in a string, then read a real file with Path(path).read_text() and run the same pipeline.

Pitfalls

Check yourself
A real scores file ends with a trailing newline, the way almost every file written by an editor does. Your code splits the text on the newline character with split(), then calls int() on each piece. What happens?

Real files almost always end with a trailing newline, and that is where interns get burned. Compare:

"10\n20\n30\n".split("\n")     # ['10', '20', '30', '']  <- trailing ''
"10\n20\n30\n".splitlines()    # ['10', '20', '30']       <- clean

If you use split("\n") you get a phantom empty string at the end, and int("") raises ValueError. Use splitlines(), or keep the if line.strip() guard, or both. That guard is your safety net for blank lines anywhere in the file, not just the last one.

Check yourself
A script at tools/report.py opens Path('data/scores.txt'). It works when you run 'python tools/report.py' from the project root. A teammate cds into tools/ and runs 'python report.py' instead. What do they get?

The second trap: a relative path like Path("data/scores.txt") resolves against the current working directory, which is wherever the process was launched, not where your script file lives. Run the same code from a different folder and it fails. When it matters, anchor to the file with Path(__file__).parent / "data" / "scores.txt".

Interview nuance: read_text() loads the whole file into memory at once, so its memory cost is O(n) in file size. That is fine for a scores file, but if an interviewer swaps in a multi-gigabyte log, stream it instead and keep memory O(1):

with Path(path).open(encoding="utf-8") as f:
    total = sum(int(line) for line in f if line.strip())

Iterating a file object yields one line at a time without holding the whole file in memory. Knowing when to read all versus stream is exactly the tradeoff data-engineering interviewers probe.

Worked example (Python)
text = "10\n20\n30"
numbers = [int(line) for line in text.splitlines() if line.strip()]
print(sum(numbers))   # 60

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement total_score(text). Sum the integer on each non-blank line of the string text.

For "10\n20\n30" return 60. Skip blank lines.

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement total_score(path) in reports/scores.py: read the file at path with pathlib and return the sum of the integer on each non-blank line. The score files live in data/. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.