Exit Codes, stderr, and Environment Variables
Every program that finishes tells the terminal whether it succeeded or failed using a number called an exit code. Zero means success. Anything else means something went wrong. This lesson teaches you how to send that signal and how to keep error messages separate from normal output.
sys.exit(0) for success, sys.exit(1) for failure. print(..., file=sys.stderr) for diagnostics that should not mix with stdout data. os.environ.get("KEY", "default") for configuration. Standard Unix patterns applied to SmartNotes.
In Lesson 3, James built subcommands for SmartNotes. He tests the search subcommand:
python cli.py search "nonexistent-tag"
Nothing happens. No output, no error, the terminal just returns to the prompt. He checks the exit code:
echo $?
0
"Exit code 0 means success," James says. "But the search found nothing. That is not success. If another program called my CLI and checked the exit code, it would think everything worked fine."
"Exactly," Emma says. "Your CLI is lying to the terminal. Success and failure should be distinguishable, even when no human is watching."
Exit Codes: 0 for Success, Nonzero for Failure
The convention is universal across Unix, macOS, and Windows terminals:
| Exit Code | Meaning |
|---|---|
0 | Success: the program did what was asked |
1 | General failure: something went wrong |
2 | Misuse: bad arguments or invalid input |
Python programs exit with code 0 by default. To exit with a different code, use sys.exit():
import sys
def handle_search(args: argparse.Namespace) -> None:
results = search_notes(load_notes(), args.query)
if not results:
print(f"No notes found for: {args.query}", file=sys.stderr)
sys.exit(1)
for note in results:
print(f"{note.title}: {note.body}")
Test it:
python cli.py search "python"
echo $?
Output (notes found):
Python Tips: Learn decorators
0
python cli.py search "nonexistent"
echo $?
Output (no notes found):
No notes found for: nonexistent
1
Now the exit code tells the truth. Other programs can rely on it:
python cli.py search "python" && echo "Found results" || echo "Nothing found"
The && runs the second command only if the first succeeds (exit 0). The || runs if it fails (nonzero). This is how shell scripts chain commands together, and it only works when your program reports exit codes correctly.
stderr: Errors Go Somewhere Else
Notice the file=sys.stderr in the error message above. Why not just print("No notes found...")?
Regular print() writes to stdout (standard output). Error messages should go to stderr (standard error). They are two separate streams, and keeping them apart is essential for piping.
Watch what happens when you redirect stdout to a file:
python cli.py search "python" > results.txt
If the search succeeds, results.txt contains the note titles. The terminal shows nothing because stdout went to the file.
Now if the search fails:
python cli.py search "nonexistent" > results.txt
The error message No notes found for: nonexistent still appears in the terminal because it was sent to stderr, not stdout. The file results.txt is empty. This is correct behavior: data goes to the file, errors stay visible.
If you had used plain print() for the error, it would have been silently swallowed into the file, and the user would see an empty terminal with no explanation.
# stdout: data that downstream programs consume
print(f"{note.title}: {note.body}")
# stderr: diagnostics, errors, progress messages
print(f"Error: {message}", file=sys.stderr)
print(f"Loaded {count} notes", file=sys.stderr)
The rule is: if another program might read your output through a pipe, keep errors out of stdout.
Environment Variables for Configuration
SmartNotes currently has a hardcoded data directory:
DATA_DIR = "~/.smartnotes"
What if a user wants notes stored in ~/Documents/notes/? Or what if tests need a temporary directory? Hardcoded paths cannot adapt.
Environment variables solve this. They are key-value pairs set in the terminal that any program can read:
# Set an environment variable
export SMARTNOTES_DATA_DIR="~/Documents/notes"
# Read it in Python
python -c "import os; print(os.environ.get('SMARTNOTES_DATA_DIR'))"
Output:
~/Documents/notes
In your CLI, use os.environ.get() with a default fallback:
import os
def get_data_dir() -> str:
"""Return the data directory, configurable via environment variable."""
return os.environ.get("SMARTNOTES_DATA_DIR", "~/.smartnotes")
If the user sets SMARTNOTES_DATA_DIR, the CLI uses that path. If they do not, it falls back to ~/.smartnotes. No code changes required, no config files to parse.
This pattern is how professional CLI tools handle configuration:
| Variable | Purpose | Default |
|---|---|---|
SMARTNOTES_DATA_DIR | Where notes are stored | ~/.smartnotes |
SMARTNOTES_FORMAT | Default export format | json |
EDITOR | Which editor to open | System default |
Add this to the CLI:
import os
import sys
import argparse
def get_config() -> dict[str, str]:
"""Read configuration from environment variables."""
return {
"data_dir": os.environ.get("SMARTNOTES_DATA_DIR", "~/.smartnotes"),
"default_format": os.environ.get("SMARTNOTES_FORMAT", "json"),
}
def handle_export(args: argparse.Namespace) -> None:
config = get_config()
fmt = args.format or config["default_format"]
data_dir = config["data_dir"]
print(f"Exporting notes from {data_dir} as {fmt}", file=sys.stderr)
# ... export logic here ...
print(f"Exported to {data_dir}/notes.{fmt}")
Output:
export SMARTNOTES_FORMAT=csv
python cli.py export
Exporting notes from ~/.smartnotes as csv
Exported to ~/.smartnotes/notes.csv
The progress message (Exporting notes from...) goes to stderr. The result (Exported to...) goes to stdout. A script piping the output gets clean data; a human at the terminal sees both.
Test environment variables in pytest by setting them before calling your function:
def test_custom_data_dir(tmp_path: Path) -> None:
import os
os.environ["SMARTNOTES_DATA_DIR"] = str(tmp_path)
config = get_config()
assert config["data_dir"] == str(tmp_path)
del os.environ["SMARTNOTES_DATA_DIR"] # clean up
This pattern verifies that your CLI reads configuration correctly without hardcoding paths.
PRIMM-AI+ Practice: Predict Exit Codes
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
Given this script:
import sys
def main() -> None:
items = ["apple", "banana", "cherry"]
query = sys.argv[1] if len(sys.argv) > 1 else ""
if not query:
print("Usage: finder.py <query>", file=sys.stderr)
sys.exit(2)
matches = [item for item in items if query in item]
if not matches:
print(f"No match for: {query}", file=sys.stderr)
sys.exit(1)
for match in matches:
print(match)
if __name__ == "__main__":
main()
Predict the exit code and which stream (stdout or stderr) receives output for each command:
python finder.pypython finder.py "grape"python finder.py "an"python finder.py "an" > out.txt
Check your predictions
python finder.py: Exit code 2. stderr:Usage: finder.py <query>. stdout: nothing.python finder.py "grape": Exit code 1. stderr:No match for: grape. stdout: nothing.python finder.py "an": Exit code 0. stdout:banana(the only item containing "an"). stderr: nothing.python finder.py "an" > out.txt: Exit code 0.out.txtcontainsbanana. Terminal shows nothing (no stderr output in this case).
Run
Press Shift+Tab to exit Plan Mode.
Create finder.py and test all four commands. After each one, run echo $? to check the exit code. For command 4, also check the contents of out.txt.
Investigate
Modify finder.py to accept a --verbose flag. When set, print Searching N items... to stderr before searching. Run python finder.py "an" --verbose > out.txt and verify that the verbose message appears in the terminal while the results go to the file.
If you want to go deeper, run /investigate @finder.py in Claude Code and ask: "What exit codes do standard Unix tools use? Is there a convention beyond 0 and 1?"
Modify
Add a SMARTNOTES_FORMAT environment variable to SmartNotes. The export subcommand should check this variable for the default format, but a --format flag on the command line should override it. Test that the flag takes priority over the environment variable.
Make [Mastery Gate]
Using /tdg, add error handling to all SmartNotes subcommands:
addwith a missing title prints an error to stderr and exits 1searchwith no results prints a message to stderr and exits 1exportwith an unsupported format prints an error to stderr and exits 2- All success paths exit 0
- Data directory is read from
SMARTNOTES_DATA_DIRwith a sensible default
Verify each case with echo $?.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Error Handling Patterns
My CLI uses sys.exit(1) for all errors. Should different
error types use different exit codes? Show me a pattern
for mapping error categories (user error, file not found,
permission denied) to specific exit codes like real Unix tools do.
What you're learning: Professional CLIs use a range of exit codes. The AI shows you conventions from tools like grep (0 = found, 1 = not found, 2 = error) and helps you design an exit code scheme for SmartNotes.
Prompt 2: Config Hierarchy
My CLI reads SMARTNOTES_DATA_DIR from the environment.
What if I also want a config file (~/.smartnotesrc)?
And command-line flags should override everything.
Show me the standard priority order for configuration
and how to implement it in Python.
What you're learning: Real tools use a configuration hierarchy: defaults, then config file, then environment variables, then command-line flags. The AI walks you through implementing each layer so the most specific setting always wins.
Prompt 3: Apply to Your Domain
I have a CLI for [your domain: file organizer, task tracker,
log analyzer, etc.]. Add proper error handling: stderr for
errors, exit codes for success/failure, and at least two
environment variables for configuration. Show the full code.
What you're learning: Transferring exit codes, stderr, and environment variables to a different domain. The AI generates a starting point; you evaluate whether the error messages are clear and the exit codes are correct.
James looks at the refactored CLI. The search subcommand now sends error messages to stderr and returns exit code 1 when nothing is found. The data directory comes from an environment variable.
"Errors go to a different inbox," James says. "In the warehouse, regular shipment confirmations go to the main log. Problems go to the exceptions queue. You do not mix them, because the people reading them are different."
Emma pauses. "That is exactly the stderr philosophy. Your analogy is better than my technical explanation. stdout is the main log for downstream automation. stderr is the exceptions queue for the human operator."
"So if I pipe smartnotes search python into another tool," James says, "only the note titles come through. The errors stay on screen where I can read them."
"Correct. And the exit code tells the pipe whether to keep going or stop. That is how you compose tools together, which is the next lesson: pipes and composability."