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