Skip to main content

Exceptions and Exit Codes

What You'll Learn

How Python's exception system works, how to catch and raise errors correctly, and how to communicate failure to the outside world via exit codes.

How Exceptions Work

When something goes wrong, Python raises an exception — an object that describes the error. If nothing catches it, the program crashes with a traceback:

Traceback (most recent call last):
File "script.py", line 5, in <module>
result = 10 / 0
ZeroDivisionError: division by zero

You can catch exceptions with try/except to handle them gracefully:

try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
result = 0

print(f"Result: {result}")

try / except / else / finally

import json

def load_config(path: str) -> dict:
try:
with open(path, encoding="utf-8") as f:
return json.load(f)

except FileNotFoundError:
# File doesn't exist
print(f"Config file not found: {path}")
return {}

except json.JSONDecodeError as e:
# File exists but has bad JSON
print(f"Invalid JSON in {path}: {e}")
return {}

except OSError as e:
# Permissions error, disk error, etc.
print(f"Could not read {path}: {e}")
return {}

else:
# Only runs if NO exception occurred
print("Config loaded successfully")

finally:
# ALWAYS runs — use for cleanup
print("Done loading config")

Catching Multiple Exceptions

# Catch multiple types in one except block
try:
value = int(user_input)
except (ValueError, TypeError) as e:
print(f"Invalid input: {e}")

# Catch a broad base class (use carefully)
try:
risky_operation()
except Exception as e:
print(f"Unexpected error: {type(e).__name__}: {e}")
raise # re-raise after logging — don't swallow unknown errors

Exception Hierarchy

Python's built-in exceptions form a hierarchy. Catching a parent catches all children:

BaseException
└── Exception
├── ValueError ← wrong value (int("abc"))
├── TypeError ← wrong type ("a" + 1)
├── KeyError ← missing dict key
├── IndexError ← list index out of range
├── AttributeError ← object has no attribute
├── NameError ← variable not defined
├── OSError ← file/network errors
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── IsADirectoryError
├── RuntimeError ← general runtime failure
├── StopIteration ← iterator exhausted
└── ArithmeticError
└── ZeroDivisionError

Catch the most specific exception type you can:

# ❌ Too broad — hides bugs
except Exception:
pass

# ✅ Specific — handles exactly what you expect
except FileNotFoundError:
...
except json.JSONDecodeError:
...

Raising Exceptions

Use raise to signal that something is wrong:

def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError(f"Divisor cannot be zero (got a={a}, b={b})")
return a / b

def process_age(age: int) -> str:
if not isinstance(age, int):
raise TypeError(f"age must be int, got {type(age).__name__}")
if age < 0 or age > 150:
raise ValueError(f"age must be 0–150, got {age}")
return "adult" if age >= 18 else "minor"

Re-raising after logging:

try:
result = risky_operation()
except Exception as e:
log.error("Operation failed: %s", e)
raise # propagate the original exception

Context Managers for Safe Cleanup

Use with statements to guarantee cleanup even when exceptions occur:

# ✅ File automatically closed even if an exception occurs
with open("data.txt", encoding="utf-8") as f:
data = f.read()

# ✅ Multiple context managers
with open("input.txt") as fin, open("output.txt", "w") as fout:
fout.write(fin.read().upper())

Exit Codes

Exit codes communicate success or failure to the shell, CI/CD, and orchestration systems:

  • 0 = success
  • 1 = generic failure
  • 2 = usage error (wrong arguments)
import sys

def main() -> int:
try:
result = do_work()
print(f"Done: {result}")
return 0 # success

except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
return 1 # failure

except KeyboardInterrupt:
print("\nInterrupted", file=sys.stderr)
return 130 # conventional for Ctrl+C

if __name__ == "__main__":
sys.exit(main())

Check in the shell:

python3 script.py
echo "Exit code: $?" # 0 for success, 1 for failure

In shell scripts:

python3 script.py || echo "Script failed!"

Writing Errors to stderr

Error messages belong on stderr, not stdout:

import sys

# ✅ Errors → stderr
print("Error: file not found", file=sys.stderr)

# Output → stdout
print("Result: 42")

This lets users pipe stdout while still seeing errors:

python3 script.py | grep "processed" # errors still appear on screen

Common Mistakes

MistakeConsequenceFix
except Exception: passSilently hides all errorsAt minimum, log the error
Catching BaseExceptionCatches KeyboardInterrupt tooUse Exception instead
Not re-raising unknown errorsHides bugsAdd raise after logging
Printing to stdout for errorsMixes output and errorsUse file=sys.stderr
No exit codeCI/CD can't detect failureReturn 0/1 from main()

Quick Reference

# try/except
try:
...
except SpecificError as e:
handle(e)
except (TypeA, TypeB) as e:
handle(e)
else:
# no exception occurred
...
finally:
# always runs
cleanup()

# Raise
raise ValueError("message")
raise TypeError(f"expected int, got {type(x).__name__}")

# Re-raise
except Exception as e:
log.error("%s", e)
raise

# Exit codes
import sys
sys.exit(0) # success
sys.exit(1) # failure
sys.exit(main())

# Errors to stderr
print("Error: ...", file=sys.stderr)

What's Next

Lesson 2: Logging for Humans and Machines