open atlas
↑ Back to track
Python for JS/TS developers PY · 02 · 01

Files and CLI arguments

A real Python script reads CLI arguments, opens files under a with-block that closes them even on exception, streams large files line by line, and exits with a status code the shell can branch on.

PY Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

A nightly job that “worked on my machine” mangles every accented name in production. The cause is one line: open("names.csv"). On the developer’s macOS, open defaulted to UTF-8; on the Linux box under a C locale it defaulted to ASCII-ish latin-1, so José round-tripped to garbage. Two days later a different script crashes mid-write and leaves a half-flushed, truncated output file because nothing ever closed the handle. Both bugs are the same lesson: file and CLI I/O in Python has defaults that quietly differ across machines, and a script that ignores them is a script that fails in CI, not on your laptop.

By the end of this lesson you will know why those two production bugs happen, what the one-line fix is for each, and how to wire them together into a script that behaves identically on every machine.

with: the close that survives an exception

open() returns a file object that holds an OS resource — a file descriptor and a write buffer. If you never close it, two things rot. The descriptor leaks (the OS caps how many you may hold; a long-running process eventually hits Too many open files), and on a writable file your buffered bytes may never reach disk, so the output is silently truncated. The naive fix is f.close() at the end — but if anything between open and close raises, the close line is skipped and you leak anyway. This is the same failure as JavaScript code that forgets a finally.

The with statement solves it structurally. open() is a context manager: it defines __enter__ (run on entry) and __exit__ (run on exit). Python guarantees __exit__ runs when the block ends for any reason — normal fall-through, return, or a raised exception that propagates out. For a file, __exit__ calls close(), which flushes the buffer and releases the descriptor. That guarantee is the whole point: you get correct cleanup without a try/finally and without trusting yourself to remember.

# Leaks the descriptor and may truncate output if transform() raises:
f = open("out.txt", "w", encoding="utf-8")
f.write(transform(data))   # if this raises, close() below never runs
f.close()

# Correct: __exit__ runs (and closes/flushes) even when transform() raises:
with open("out.txt", "w", encoding="utf-8") as f:
    f.write(transform(data))
# file is closed and flushed here, exception or not
Why this works

“Closed on exception” does not mean “swallows the exception.” A plain with runs __exit__ (closing the file) and then lets the exception keep propagating — your cleanup happens, but the error still crashes the script, which is what you want. Only a context manager that explicitly returns truthy from __exit__ suppresses the error, and open()’s never does. So with gives you guaranteed cleanup and honest failures.

Text vs binary, and the encoding you must name

Why does this matter more than it looks? Because your script will be deployed in containers, CI pipelines, and teammates’ machines — none of which you control. open() has two modes. Text mode (the default, "r" / "w") decodes bytes to str on read and encodes str to bytes on write, using an encoding. Binary mode ("rb" / "wb") gives you raw bytes with no decoding — what you want for images, archives, or any non-text payload.

The trap is the default encoding. When you omit encoding=, text mode uses locale.getpreferredencoding(), which depends on the machine’s locale environment — UTF-8 on a typical Mac, but possibly cp1252 on Windows or ascii/latin-1 under a bare C locale in a container. The same file then decodes differently on different hosts, which is exactly the production bug from the hook. The rule is unconditional: for text files, always pass encoding="utf-8" (or whatever the file actually is). Make the encoding explicit and the script behaves identically everywhere.

# Reads correctly on every host, regardless of locale:
with open("names.csv", "r", encoding="utf-8") as f:
    header = f.readline()

# Binary: no decoding, you get bytes:
with open("logo.png", "rb") as f:
    magic = f.read(8)        # b'\x89PNG\r\n\x1a\n'

Read patterns: don’t load a 4 GB file into RAM

How you read matters as much as that you read. f.read() returns the entire file as one string — fine for a small config, catastrophic for a multi-gigabyte log, because it pulls the whole thing into memory at once. f.readlines() is just as greedy: it builds a list of every line in memory. The memory-safe pattern is to iterate the file object directlyfor line in f: — which streams one line at a time and never holds more than a single line plus a buffer, so a 4 GB file processes in constant memory.

# DON'T on big files — both load everything into RAM:
data = f.read()              # whole file as one str
lines = f.readlines()        # list of every line

# DO — streams line by line, constant memory:
with open("app.log", "r", encoding="utf-8") as f:
    for line in f:           # one line at a time
        if "ERROR" in line:
            print(line, end="")

pathlib.Path: paths as objects, not strings

Manipulating paths with string concatenation is how you ship "logs" + "/" + name that breaks on Windows and double-slashes on edge cases. pathlib.Path (modern, since 3.4) models a path as an object with operators and methods, replacing most of the old os.path string functions. The / operator joins segments portably, and Path carries .exists(), .read_text(), .glob(), and friends.

from pathlib import Path

src = Path("data") / "input.csv"     # os.path.join(..) — but readable, cross-platform
if src.exists():
    text = src.read_text(encoding="utf-8")   # open+read+close in one call
out = src.with_suffix(".out.csv")            # data/input.out.csv

If you come from Node, the mental map is direct: path.join becomes the / operator, fs.existsSync becomes .exists(), and fs.readFileSync(p, "utf8") becomes Path(p).read_text(encoding="utf-8").

CLI input: sys.argv, then graduate to argparse

A script gets its arguments from sys.argv, a list where sys.argv[0] is the script name and the rest are the user’s words. Reading it by hand is fine for a one-off, but it scales badly: no --flags, no type conversion, no usage message, and an out-of-range index is an unhandled IndexError.

import sys
# Fragile: positional only, crashes on missing arg, everything is a str
infile = sys.argv[1]          # IndexError if the user forgot it
limit = int(sys.argv[2])      # ValueError on bad input, no help text

The right tool the moment a script has real options is argparse (standard library). You declare positionals and --flags, give them type= for conversion, and argparse parses sys.argv, validates, converts types, generates -h/--help, and on a bad invocation prints usage to stderr and exits with code 2 — all for free.

import argparse

parser = argparse.ArgumentParser(description="Filter and transform a CSV.")
parser.add_argument("infile", help="path to the input CSV")          # positional
parser.add_argument("-o", "--out", help="output path (default: stdout)")
parser.add_argument("-n", "--limit", type=int, default=0,            # auto-converts to int
                    help="max rows to emit (0 = all)")
args = parser.parse_args()    # validates, fills defaults, exits 2 on misuse
# args.infile, args.out, args.limit are ready to use

Roughly, process.argv plus a flags library in Node collapses into argparse here — argument parsing, type coercion, and the --help screen in one declaration.

stdin/stdout/stderr and the exit code that CI reads

When you write a script, the person running it is often not a person — it’s cron, a Makefile, a GitHub Actions step. A well-behaved CLI tool participates in the shell. Normal output goes to stdout (print() writes there by default) so it can be piped. Diagnostics — errors, progress, warnings — go to stderr (print(..., file=sys.stderr)) so they don’t corrupt piped data. And the script ends with an exit code: 0 means success, any non-zero means failure. The shell exposes it as $?, && chains on it, and CI marks the step failed on non-zero — so the exit code is how every automated caller learns whether your script worked.

sys.exit(code) sets it. Pass an int for the status; pass a string and Python prints it to stderr and exits with code 1. Returning from main() without calling sys.exit exits 0. An uncaught exception exits with code 1 and a traceback on stderr.

import sys

if not args.infile:
    print("error: no input file", file=sys.stderr)   # diagnostics → stderr
    sys.exit(2)                                       # non-zero → caller sees failure
print(result)                                         # data → stdout
sys.exit(0)                                           # explicit success
Node / JSPythonNote
fs.readFileSync(p, “utf8”)open(p, encoding=“utf-8”).read()Wrap in with; always name the encoding
createReadStream + line splitfor line in f:Streams big files in constant memory
process.argvsys.argv / argparseargv[0] is the script; argparse for real flags
path.join, fs.existsSyncPath / ”..”, .exists()pathlib models paths as objects
console.errorprint(…, file=sys.stderr)Keep diagnostics off stdout
process.exit(code)sys.exit(code)0 = ok; non-zero = failure for shell/CI

Put the pieces together and a real script is a pipeline: parse arguments, read input, transform, write output (file or stdout), and exit with a status the caller can branch on.

Quiz

Why prefer `with open(...)` over `f = open(...)` then `f.close()`?

Quiz

A script calls sys.exit(0) even when a file was missing and it printed an error. What breaks?

Pick the best fit

You must process a 6 GB application log, keeping only ERROR lines. How do you read it?

Recall before you leave
  1. 01
    Explain exactly why `with open(...)` closes the file when an exception is raised, and what would go wrong with a plain open()/close() pair instead.
  2. 02
    What does the exit code of a script communicate, how do you set it in Python, and why does it matter more than a printed message?
Recap

A production-grade Python script treats file and CLI I/O as something that must behave identically on every host, not just your laptop. Open files under a with-block: open() is a context manager whose __exit__ runs — closing and flushing the file — when the block ends for any reason, including a propagating exception, so you never leak a descriptor or ship a truncated output. Always pass encoding="utf-8" for text, because the default encoding depends on the machine’s locale and is the source of the “works on my Mac, breaks in the container” class of bug; reach for binary mode only for non-text payloads. Read big files by iterating the file object line by line rather than read()/readlines(), which load everything into memory. Use pathlib.Path to build paths as objects with the / operator instead of brittle string concatenation. Take arguments with argparse once you have real flags — it parses, type-converts, validates, and generates --help for free — falling back to raw sys.argv only for throwaways. Finally, route data to stdout and diagnostics to stderr, and call sys.exit with a non-zero code on failure so the shell, && chains, and CI can branch on whether your script actually worked. Now when you see a script that silently mangles names in production or leaves a half-written output file, you’ll know exactly which two lines to reach for first.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.