Write Clear CLI Output and Exit Codes
Separate successful CLI output from error diagnostics and return meaningful exit codes.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Write Clear CLI Output and Exit Codes
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
A command-line tool is often called by another program, not only by a human watching a terminal. That caller sees standard output, standard error, and an exit status. Our tiny greet command has a bug that is easy to miss: with no arguments, it prints a usage message but returns zero. Predict how a shell script that checks only the status interprets that result. It will think the command succeeded. Before fixing syntax, define the behavior: a valid --name NAME prints a greeting on stdout and returns zero; invalid usage prints a diagnostic on stderr and returns a nonzero code. A command-line tool communicates through more than its printed words.
- 00:43
Standard output carries successful results that another program may capture. Standard error carries diagnostics, warnings, and usage mistakes. The process exit status says whether the command succeeded: zero means success and a nonzero value means failure. A script that prints an error but returns zero misleads shell scripts and continuous integration, even if a person can read the message. Keeping output streams and status aligned turns a small script into a reliable building block for automation. The broken run displays usage followed by exit zero. A person can infer failure from the words, but a build step or pipe might consume the
- 01:25
usage line as if it were a greeting. This mismatch can propagate bad data into another command. We deliberately keep the argument grammar simple here because a related argparse lesson covers richer parsing. Our focus is what the command promises through its channels. The first test captures stdout and demonstrates that the diagnostic leaked into the success stream. Next we will make those channels observable independently. Consider a greet command that expects --name NAME. A broken implementation prints a usage message to standard output and returns zero when the argument is missing.
- 02:05
A caller that checks only the exit status assumes the operation worked; a caller that captures standard output may mistake the diagnostic for a valid greeting. The corrected main(argv) checks the small input contract, writes usage to sys.stderr with print(..., file=sys.stderr), and returns 2 for invalid usage. On success it writes only the greeting to stdout and returns 0. The number 2 is a conventional usage-error status for this exercise, not a universal Python rule. Python's print function writes to stdout unless told otherwise. Passing file=sys.stderr sends a diagnostic to the error stream.
- 02:50
A test can use contextlib.redirect_stdout and redirect_stderr with two StringIO buffers to inspect both. The status is a separate integer return value, so correct text on the wrong stream is still a failed contract. Likewise, a nonzero status with a valid greeting would be misleading. This is why the checked task asserts stream contents and return codes together. There is no special magic in the number two; this exercise chooses it as the usage-error code. The main function returns a code rather than calling sys.exit internally, which makes it easy to test in the browser console.
- 03:30
A local script can use raise SystemExit(main(sys.argv[1:])) at its entry point so that the operating system receives the returned code. The browser task stays inside the function and tests both streams using redirected output. Do not catch every exception and convert it to success; unexpected defects need to remain visible or be translated into a documented failure. Larger argument grammars belong in argparse, covered by the related lesson, while this page focuses on observable CLI behavior. The corrected main checks that there are exactly two tokens, the first is --name, and the second contains a nonblank name.
- 04:13
Invalid input writes usage to stderr and returns two without greeting. Valid input writes one greeting to stdout and returns zero. We keep sys.exit out of main so browser practice and unit tests can call it repeatedly without terminating the process. In a local script, an entry-point wrapper can raise SystemExit(main(sys.argv[1:])), giving the operating system the code. That wrapper belongs at the boundary, after the testable logic has returned. The contract is intentionally narrow: this page is about output and status, not sophisticated argument parsing. A real command might use argparse to produce usage and exit codes for many
- 04:54
options. Even then, successful data should not be mixed with diagnostics. Returning a code from main improves testability and allows a launcher to decide how to terminate the process. Some commands use different nonzero codes for distinct errors; document them if callers need to branch on them. Avoid catching all exceptions and reporting success, because that hides defects. On Windows and Unix, shells may expose process status differently, but zero as success is the portable expectation. The browser exercise tests the core function rather than spawning another
- 05:34
process, while the video explains how SystemExit connects the function's integer to the actual CLI process. The final run checks an empty argument list, a valid Ada name, and a blank name. We capture both streams for each call, print their representations, and compare them with the returned status. Try an extra token too. A well-behaved CLI gives other tools a dependable signal about success while keeping machine-readable output free of diagnostics. Complete the browser task and quiz, then imagine piping greet into a text file: which branch should put text in that file, and which should only report an error?
- 06:15
That thought experiment connects the status and stream rules to everyday automation. Implement main(argv) for the explicit two- token form. Require a nonblank name after --name. A valid call prints Hello, Ada! to stdout and returns 0. Missing, extra, or blank arguments print usage: greet --name NAME to stderr and return 2 without printing a greeting. Test both branches by capturing stdout and stderr separately. This contract lets another program safely pipe the success output and react to the return code. The final example shows how to wrap main for a real local command, while keeping
- 06:55
the core logic independent of the operating system process during the interactive exercise.
Understand the concept
A command-line tool communicates through more than its printed words. Standard output carries successful results that another program may capture. Standard error carries diagnostics, warnings, and usage mistakes. The process exit status says whether the command succeeded: zero means success and a nonzero value means failure. A script that prints an error but returns zero misleads shell scripts and continuous integration, even if a person can read the message. Keeping output streams and status aligned turns a small script into a reliable building block for automation.
Consider a greet command that expects --name NAME. A broken implementation prints a usage message to standard output and returns zero when the argument is missing. A caller that checks only the exit status assumes the operation worked; a caller that captures standard output may mistake the diagnostic for a valid greeting. The corrected main(argv) checks the small input contract, writes usage to sys.stderr with print(..., file=sys.stderr), and returns 2 for invalid usage. On success it writes only the greeting to stdout and returns 0. The number 2 is a conventional usage-error status for this exercise, not a universal Python rule.
The main function returns a code rather than calling sys.exit internally, which makes it easy to test in the browser console. A local script can use raise SystemExit(main(sys.argv[1:])) at its entry point so that the operating system receives the returned code. The browser task stays inside the function and tests both streams using redirected output. Do not catch every exception and convert it to success; unexpected defects need to remain visible or be translated into a documented failure. Larger argument grammars belong in argparse, covered by the related lesson, while this page focuses on observable CLI behavior.
Implement main(argv) for the explicit two-token form. Require a nonblank name after --name. A valid call prints Hello, Ada! to stdout and returns 0. Missing, extra, or blank arguments print usage: greet --name NAME to stderr and return 2 without printing a greeting. Test both branches by capturing stdout and stderr separately. This contract lets another program safely pipe the success output and react to the return code. The final example shows how to wrap main for a real local command, while keeping the core logic independent of the operating system process during the interactive exercise.
- stdout is successful program output
- stderr is diagnostic output
- Zero exit status means success
- Return codes from main are easy to test before passing them to SystemExit
See it step by step
Read the code, predict the output, then compare it with the result.
01. Write an error to stderr
import sys
print('usage: greet --name NAME', file=sys.stderr)
print('diagnostic sent to stderr')
diagnostic sent to stderrThe file argument directs the usage diagnostic to the error stream.
02. Test both streams independently
import contextlib, io, sys
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
print('result')
print('problem', file=sys.stderr)
print(out.getvalue().strip(), err.getvalue().strip())
result problemRedirecting each stream separately shows whether a command follows its output contract.
03. Keep a return code separate from output
def status(ok):
return 0 if ok else 2
print(status(True), status(False))
0 2The return value can later become the process status through SystemExit.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Identify the misleading success
A command-line tool communicates through more than its printed words. Standard output carries successful results that another program may capture. Standard error carries diagnostics, warnings, and usage mistakes. The process exit status says whether the command succeeded: zero means success and a nonzero value means failure. A script that prints an error but returns zero misleads shell scripts and continuous integration, even if a person can read the message. Keeping output streams and status aligned turns a small script into a reliable building block for automation.
Run the broken command with no arguments and observe that it returns zero after printing usage. A human notices the error text, but a shell pipeline may treat it as ordinary successful output.
import sys
print('usage: greet --name NAME', file=sys.stderr)
print('diagnostic sent to stderr')
diagnostic sent to stderr- Call the broken main without arguments.
- Compare its text with its return code.
Separate result and diagnostic streams
Consider a greet command that expects --name NAME. A broken implementation prints a usage message to standard output and returns zero when the argument is missing. A caller that checks only the exit status assumes the operation worked; a caller that captures standard output may mistake the diagnostic for a valid greeting. The corrected main(argv) checks the small input contract, writes usage to sys.stderr with print(..., file=sys.stderr), and returns 2 for invalid usage. On success it writes only the greeting to stdout and returns 0. The number 2 is a conventional usage-error status for this exercise, not a universal Python rule.
Use print(..., file=sys.stderr) for the usage message. In a test, redirect stdout and stderr independently so an invalid call cannot pass merely because the right words appeared somewhere.
import contextlib, io, sys
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
print('result')
print('problem', file=sys.stderr)
print(out.getvalue().strip(), err.getvalue().strip())
result problem- Capture stdout and stderr separately.
- Move usage to stderr.
Return a testable status
The main function returns a code rather than calling sys.exit internally, which makes it easy to test in the browser console. A local script can use raise SystemExit(main(sys.argv[1:])) at its entry point so that the operating system receives the returned code. The browser task stays inside the function and tests both streams using redirected output. Do not catch every exception and convert it to success; unexpected defects need to remain visible or be translated into a documented failure. Larger argument grammars belong in argparse, covered by the related lesson, while this page focuses on observable CLI behavior.
Keep main(argv) free of sys.exit. The entry-point wrapper can convert its return code to a process status later. This lets the browser task exercise both branches without terminating the Python session.
def status(ok):
return 0 if ok else 2
print(status(True), status(False))
0 2- Return 0 on success and 2 on usage error.
- Use SystemExit only at a real CLI entry point.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Check len(argv), argv[0], and argv[1].strip(). Use print('usage: greet --name NAME', file=sys.stderr) in the invalid branch, then return 2.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Printing usage to stdout and returning success
print('usage: greet --name NAME')
return 0print('usage: greet --name NAME', file=sys.stderr)
return 2What happens: A shell script treats invalid input as successful output.
Keep diagnostics on stderr and return a failure status.
Calling sys.exit inside reusable logic
def main(argv):
sys.exit(2)def main(argv):
return 2
# at the local entry point: raise SystemExit(main(sys.argv[1:]))What happens: A unit test or browser exercise exits instead of inspecting a return code.
Separate testable command logic from process termination.