Errors, exceptions and files · about 45 min

stdin, stdout, stderr and exit codes

Why this matters

A program does not live alone. It is started by a shell, a script, a cron job, a CI pipeline, or another program, and those callers need three things from it: the data it produces, any diagnostics kept separate from that data, and a clear signal of success or failure. Unix provides exactly these channels: standard output, standard error, and the exit status, plus standard input and command-line arguments going in. Programs that use them properly can be combined in pipelines (cat log | python3 filter.py | sort | uniq -c) and automated reliably. This lesson connects what you learned about files to these process-level channels.

The process's view of the world

When a process starts, the kernel gives it:

            argv: ["main.py", "17", "5"]     (command-line arguments)
                         │
 stdin  (fd 0) ──>  ┌────────────┐ ──> stdout (fd 1)   data
                    │  process   │
                    └────────────┘ ──> stderr (fd 2)   diagnostics
                         │
                    exit status: 0..255  (returned to the parent when it ends)

The three standard streams are just file descriptors 0, 1 and 2, opened before your code runs. The shell decides where they point: the terminal by default, or files and pipes when you redirect:

$ python3 main.py < input.txt > output.txt 2> errors.txt
$ python3 main.py | sort        # stdout of python3 feeds stdin of sort
$ python3 main.py 2>/dev/null   # discard diagnostics

Python exposes them in the sys module as file objects: sys.stdin, sys.stdout and sys.stderr. They are text files exactly like the ones open returns, so everything from the files lesson applies.

Reading all of stdin

input() reads one line. To process an unknown number of lines, iterate sys.stdin; the loop ends at end of input (EOF: the end of a redirected file, the writer of a pipe closing it, or Ctrl-D at a terminal):

import sys

total = 0
for line in sys.stdin:
    total += int(line)
print(total)
$ printf '3\n4\n5\n' | python3 sum.py
12

Lines keep their newline, as with any file. int ignores surrounding whitespace, so it does not matter here, but when printing lines back out, strip the newline or use print(line, end=""). Other options: sys.stdin.read() returns everything as one string, and sys.stdin.read().split() gives all whitespace-separated tokens regardless of line structure.

A program that reads stdin, transforms it, and writes stdout is called a filter. Filters are the building blocks of shell pipelines.

stdout versus stderr

print writes to sys.stdout by default. Write diagnostics (errors, warnings, progress, summaries) to sys.stderr:

import sys

print("result data")                             # stdout
print("warning: skipped 3 rows", file=sys.stderr) # stderr
sys.stderr.write("also stderr\n")                # the file-object way

Why separate them? Because stdout is often redirected into a file or another program, and messages mixed into the data would corrupt it. python3 report.py > report.csv should produce a clean CSV while still showing warnings on the terminal. Tracebacks go to stderr for the same reason.

Buffering differences

When stdout is a terminal, Python flushes it at every newline (line buffering). When stdout is a pipe or file, it is block-buffered: output accumulates in memory and is written in chunks. stderr is always written promptly. So if you mix them, the order you see in a combined log may differ from the order of your print calls. Pass flush=True to print when the timing matters, or run Python with -u for unbuffered output.

Command-line arguments: sys.argv

sys.argv is a list of strings: the script name followed by the arguments the user typed.

import sys
print(sys.argv)
$ python3 args.py report.csv --verbose 42
['args.py', 'report.csv', '--verbose', '42']

Everything is a string; convert with int() as needed. The shell has already split the command line on spaces and handled quotes, so python3 args.py "two words" gives ['args.py', 'two words']. Check the length before indexing, or a missing argument becomes IndexError: list index out of range. For anything beyond a couple of positional arguments, the argparse module (module 15) builds proper option parsing and help text for you.

Exit status

When a process ends, it returns a small integer to its parent: the exit status (or exit code). 0 means success; any other value, 1 to 255, means failure. Shells and CI systems branch on it:

$ python3 check.py && echo "all good"   # runs echo only on status 0
$ python3 check.py; echo "status was $?"

How a Python program's status is determined:

How the program endsExit status
Reaches the end of the script0
sys.exit() or sys.exit(0)0
sys.exit(n) with an intn
sys.exit("message")1, and the message is printed to stderr
An uncaught exception1, with a traceback on stderr
Killed by Ctrl-C (KeyboardInterrupt uncaught)130 in a shell (128 + signal 2)

Conventions worth following: 1 for general errors, 2 for incorrect usage (wrong arguments), as grep, ls and Python's own argparse do. grep also uses 1 to mean "ran fine, found nothing", which shows that a non-zero status signals "not success" for the caller's purposes, not necessarily a crash.

How sys.exit works

sys.exit(n) does not kill the process on the spot. It raises the exception SystemExit(n). It propagates like any exception, so finally blocks and with statements still clean up on the way out. When it reaches the top level uncaught, the interpreter exits with the code. Because SystemExit inherits from BaseException, not Exception, an except Exception: handler does not catch it. (A bare except: would, which is one more reason not to write one.)

A common pattern for scripts:

import sys

def main():
    if len(sys.argv) != 2:
        print("usage: count.py FILE", file=sys.stderr)
        return 2
    try:
        with open(sys.argv[1], encoding="utf-8") as f:
            n = sum(1 for _ in f)
    except OSError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    print(n)
    return 0

sys.exit(main())

main() returns a status code, and one sys.exit at the bottom turns it into the exit status. Errors become one clear line on stderr instead of a traceback, which is the right experience for a user (tracebacks are for developers). The OSError handler covers missing files, permission problems and directories in one place; str(e) already reads well, for example [Errno 2] No such file or directory: 'nope.txt'.

Common mistakes

Diagnostics on stdout. print("Error: bad file") goes into the data stream, breaking whatever consumes your output. Use file=sys.stderr.

Exiting 0 on failure. A script that prints an error and then ends normally reports success; a CI pipeline will happily continue. Exit non-zero.

Indexing argv blindly. sys.argv[1] with no arguments gives IndexError: list index out of range. Check len(sys.argv) and print a usage line.

Double newlines. for line in sys.stdin: print(line) prints blank lines between lines, since each line already has \n. Use end="" or strip.

Waiting forever. A program that reads stdin, run in a terminal with no redirection, waits for you to type. Press Ctrl-D to send EOF.

Catching SystemExit by accident. A bare except: around code that calls sys.exit swallows the exit.

Recap

Going further: run python3 -c 'import sys; sys.exit(3)'; echo $? in a terminal, then try python3 main.py 2>&1 | less to see both streams in one pager. Read the sys module documentation for sys.stdin, sys.exit and sys.argv.

Exercises

Exercise 1 · quiz

Exit statuses

Which statements are true? Select all that apply.

Answer
  • Exit status 0 conventionally means success

  • An uncaught exception makes Python exit with status 1

  • In a shell, $? holds the exit status of the last command

0 is success, anything else failure; an uncaught exception exits with 1; $? shows the last status. sys.exit("bad input") prints the message to stderr and exits with status 1. And sys.exit works by raising SystemExit, which finally blocks see and which except BaseException (or a bare except:) could catch.

Exercise 2 · write code

A Unix filter

Write a filter that reads all lines from standard input by iterating over sys.stdin, and writes each line to stdout converted to uppercase, with a line number and a tab in front, numbering from 1. Blank lines are numbered too. It must work for any number of lines, including none. For input:

hello
world

the output is (<TAB> stands for one tab character):

1<TAB>HELLO
2<TAB>WORLD

File: main.py

Starting point

import sys

line = input()
print(line.upper())

Example

input

hello
world

expected output

1	HELLO
2	WORLD

Example

input

a

b

expected output

1	A
2	
3	B
Hint 1

for line in sys.stdin: reads until end of input; each line ends with a newline.

Hint 2

Strip the newline with line.rstrip("\n") before printing, or print with end="".

Hint 3

enumerate(sys.stdin, start=1) numbers the lines.

Reference solution
import sys

for number, line in enumerate(sys.stdin, start=1):
    print(f"{number}\t{line.rstrip('\n').upper()}")

Exercise 3 · write code

Arguments, errors and exit codes

Write a program main.py that takes exactly two command-line arguments and prints the first divided by the second, using integer division (//).

Nothing at all may be printed to stdout in the error cases. Use sys.argv, sys.stderr and sys.exit.

File: main.py

Starting point

import sys

a = int(sys.argv[1])
b = int(sys.argv[2])
print(a // b)

Example with arguments 17 5

expected output

3

Example with arguments -7 2

expected output

-4
Hint 1

sys.argv[0] is the script name, so two arguments means len(sys.argv) == 3.

Hint 2

print(..., file=sys.stderr) writes to stderr; sys.exit(2) ends with status 2.

Hint 3

Convert each argument in a try / except ValueError and exit on the first failure.

Reference solution
import sys

if len(sys.argv) != 3:
    print("usage: main.py A B", file=sys.stderr)
    sys.exit(2)

numbers = []
for arg in sys.argv[1:]:
    try:
        numbers.append(int(arg))
    except ValueError:
        print(f"error: not an integer: {arg}", file=sys.stderr)
        sys.exit(1)

a, b = numbers
if b == 0:
    print("error: division by zero", file=sys.stderr)
    sys.exit(1)
print(a // b)

Exercise 4 · write code

grep, in Python

Write a tiny grep: python3 main.py WORD reads lines from stdin and prints every line that contains WORD (case-sensitive), unchanged. Follow grep's exit status convention:

Also, at the end, if there were matches, print <N> matching lines to stderr (not stdout), so that the count does not pollute the output when it is piped into another program.

File: main.py

Starting point

import sys

word = sys.argv[1]
for line in sys.stdin:
    if word in line:
        print(line, end="")

Example with arguments cat

input

a cat
the dog
catalog
CAT

expected output

a cat
catalog

Example with arguments zebra

input

a cat
the dog

expected output

(nothing)
Hint 1

Count matches in a variable while looping over sys.stdin.

Hint 2

Lines from stdin already end in a newline: print(line, end="") or sys.stdout.write(line).

Hint 3

sys.exit(1) when the count is zero; otherwise just let the program end (status 0).

Reference solution
import sys

if len(sys.argv) != 2:
    print("usage: main.py WORD", file=sys.stderr)
    sys.exit(2)

word = sys.argv[1]
matches = 0
for line in sys.stdin:
    if word in line:
        sys.stdout.write(line)
        matches += 1

if matches == 0:
    sys.exit(1)
print(f"{matches} matching lines", file=sys.stderr)