Python Zero to Advanced #7: Error Handling, Modules, Packages, virtualenv & pip
Programs fail: files go missing, users type letters where you expect numbers, networks time out. Good Python code expects this and reacts sensibly instead of crashing with a scary traceback. In the second half of this part we grow beyond single files, organising code into modules and packages, and isolate each project's dependencies with a virtual environment.
Exceptions and tracebacks
When something goes wrong, Python raises an exception. If nothing handles it, the program stops and prints a traceback. Read tracebacks from the bottom up: the last line names the exception type and message.
print(int("abc"))
Output:
Traceback (most recent call last):
File "main.py", line 1, in <module>
print(int("abc"))
^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'abc'
Common built-in exceptions you will meet: ValueError, TypeError, KeyError, IndexError, ZeroDivisionError, FileNotFoundError, and AttributeError.
try, except, else, finally
Wrap risky code in try and handle specific exceptions in except. The optional else runs only if no exception occurred, and finally always runs, which makes it ideal for cleanup.
def safe_divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Cannot divide by zero")
return None
except TypeError as e:
print("Bad input:", e)
return None
else:
print("Division OK")
return result
finally:
print("-- done --")
print(safe_divide(10, 4))
print(safe_divide(10, 0))
print(safe_divide(10, "2"))
Output:
Division OK
-- done --
2.5
Cannot divide by zero
-- done --
None
Bad input: unsupported operand type(s) for /: 'int' and 'str'
-- done --
None
Catch the narrowest exception you can handle. A bare except: or except Exception: that silently passes hides real bugs. You can catch several types in one clause with a tuple:
import json
def load_config(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
print(f"{path} not found, using defaults")
return {"debug": False}
except (json.JSONDecodeError, UnicodeDecodeError) as e:
print(f"{path} is invalid: {type(e).__name__}")
return {"debug": False}
with open("broken.json", "w", encoding="utf-8") as f:
f.write("{not valid json")
print(load_config("missing.json"))
print(load_config("broken.json"))
Output:
missing.json not found, using defaults
{'debug': False}
broken.json is invalid: JSONDecodeError
{'debug': False}
Raising and custom exceptions
Use raise to signal that something is wrong in your own code. For domain-specific errors, define a custom exception class that inherits from Exception. Callers can then catch exactly your error.
class InsufficientBalanceError(Exception):
def __init__(self, balance, amount):
super().__init__(f"balance {balance} is less than {amount}")
self.balance = balance
self.amount = amount
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("amount must be positive")
if amount > balance:
raise InsufficientBalanceError(balance, amount)
return balance - amount
for amount in [30000, -5, 999999]:
try:
print("New balance:", withdraw(100000, amount))
except InsufficientBalanceError as e:
print("Rejected:", e, "| short by", e.amount - e.balance)
except ValueError as e:
print("Invalid:", e)
Output:
New balance: 70000
Invalid: amount must be positive
Rejected: balance 100000 is less than 999999 | short by 899999
Inside an except block, raise NewError(...) from e wraps the original error so the traceback shows both, which is very helpful when debugging.
Modules and packages
Any .py file is a module. You import it by its file name, without the extension. Create pricing.py:
# pricing.py
TAX_RATE = 0.11
def with_tax(amount):
return round(amount * (1 + TAX_RATE))
def format_rupiah(amount):
return f"Rp{amount:,}".replace(",", ".")
if __name__ == "__main__":
# runs only when executed directly: python3 pricing.py
print(format_rupiah(with_tax(100000)))
Then use it from another file in the same folder:
import pricing
from pricing import format_rupiah as rp
import math
from datetime import date
print(pricing.TAX_RATE)
print(rp(pricing.with_tax(250000)))
print(math.sqrt(144), math.pi)
print(date(2026, 10, 13).strftime("%d %B %Y"))
Output:
0.11
Rp277.500
12.0 3.141592653589793
13 October 2026
The if __name__ == "__main__": guard means the block runs only when the file is executed directly, not when it is imported. That is why importing pricing above printed nothing.
A package is a folder of modules, usually with an __init__.py file:
shop/
βββ main.py
βββ store/
βββ __init__.py
βββ pricing.py
βββ inventory.py
From main.py you would write from store.pricing import with_tax or from store import inventory.
Virtual environments and pip
Different projects need different library versions. A virtual environment (venv) is an isolated folder with its own Python and its own installed packages, so projects never conflict. pip installs packages from PyPI into the active environment.
# create a virtual environment in the .venv folder
python3 -m venv .venv
# activate it (macOS/Linux)
source .venv/bin/activate
# activate it (Windows PowerShell)
# .venv\Scripts\Activate.ps1
# install packages
pip install requests
pip install "requests==2.32.3"
# save and restore dependencies
pip freeze > requirements.txt
pip install -r requirements.txt
# leave the environment
deactivate
After activation your prompt usually shows (.venv). Add .venv/ to .gitignore and commit requirements.txt instead, so teammates can recreate the same environment. Tools such as uv and Poetry build on the same ideas with faster installs and lock files.
Common mistakes
- Swallowing errors with
except: pass. At least log the error. - Wrapping too much code in one
try, so you cannot tell which line failed. - Naming your file like a library, e.g.
json.pyorrandom.py. Your file shadows the real module and imports break mysteriously. - Installing packages globally with
sudo pip. Always use a venv. - Forgetting to activate the venv, then wondering why
ModuleNotFoundErrorappears.
Exercise
Write parse_age(text) that returns an int between 0 and 130, raising ValueError with a clear message otherwise. Then test it with several inputs.
def parse_age(text):
try:
age = int(text)
except ValueError:
raise ValueError(f"{text!r} is not a number") from None
if not 0 <= age <= 130:
raise ValueError(f"{age} is out of range")
return age
for value in ["25", "abc", "200", " 42 "]:
try:
print("OK:", parse_age(value))
except ValueError as e:
print("Error:", e)
Output:
OK: 25
Error: 'abc' is not a number
Error: 200 is out of range
OK: 42
Summary
- Handle specific exceptions with
try/except; useelsefor success andfinallyfor cleanup. - Raise meaningful errors and create custom exception classes for your domain.
- Modules are
.pyfiles, packages are folders; guard script code withif __name__ == "__main__". - One venv per project, dependencies tracked in
requirements.txt.
Next up is the advanced block. Part 8 covers object-oriented Python: classes, inheritance, dunder methods, and dataclasses.
Related articles
PostgreSQL Zero to Advanced #5: Aggregation, GROUP BY & HAVING
Turn raw orders into business numbers: total revenue, average order value, sales per month and top customers using COUNT, SUM, AVG, GROUP BY, HAVING and FILTER.
PHP Zero to Advanced #5: Arrays & Array Functions
Master PHP arrays: indexed and associative arrays, nested data, destructuring, and the essential functions array_map, array_filter, array_reduce, usort, and friends.
Ruby on Rails Zero to Advanced #5: Routing, Controllers & Views
Follow a request through Rails: define routes, use resources, read params in controllers, and render dynamic HTML with ERB views and partials.