Python as backend code, not notebook code
Roadmap task: Functions, classes, type hints, exceptions, virtual environments, and basic packaging
In this lesson: 9 ideas
- The mental model: code is read far more than it is run
- Functions: one job, explicit inputs, explicit outputs
- Classes: when data and behaviour belong together
- Type hints: a promise the tools can check
- Exceptions: decide where failure is handled
- and 4 more
- 150 min
- to read and practise
- 15 steps
- one idea at a time
- 2 visuals
- diagrams and hands-on
- 11 code
- 4 you can run here
- 5 checks
- test yourself
Every agent you will build in this course is, underneath, an ordinary Python program: it reads input, calls functions, talks to a database and an API, and has to fail in a controlled way when something goes wrong. A notebook teaches you to run cells top to bottom and eyeball the output. A client's production system needs code that someone else can import, test, and trust at 3 a.m. This lesson moves you from the first habit to the second. Most "agent bugs" you will meet in real deployments are plain Python bugs: an exception swallowed silently, a function that returns None on one path, a dependency version that differs between your laptop and the server.
The mental model: code is read far more than it is run
In a notebook the reader is you, five minutes from now. In a backend the reader is a teammate, a reviewer, a future you who has forgotten everything, and increasingly an AI coding tool that will only be as good as the structure you give it. Each tool in this lesson exists to make code easier to read correctly: functions name a step, classes name a thing, type hints state a promise, exceptions state what can go wrong, and a virtual environment states exactly what the code depends on.
- Reads variables defined three cells up
- Prints results so you can eyeball them
- Crashes, and someone reruns the cell
- Works on one laptop, nobody knows why
- Takes everything it needs as parameters
- Returns values a test can check
- Raises a clear error the caller can handle
- Dependencies written down in a virtual environment
Functions: one job, explicit inputs, explicit outputs
A good function takes everything it needs as parameters and gives back a value. It does not quietly read global variables or print its result.
# Notebook habit: hidden inputs, printed output, nothing reusable
prices = [499, 1299, 199]
total = 0
for p in prices:
total += p
print("Total with GST:", total * 1.18)# Backend habit: inputs are parameters, the result is returned
GST_RATE = 0.18
def total_with_gst(prices: list[float], rate: float = GST_RATE) -> float:
"""Sum the prices and add GST. Rounded to 2 decimals for display."""
if rate < 0:
raise ValueError(f"GST rate cannot be negative, got {rate}")
return round(sum(prices) * (1 + rate), 2)
print(total_with_gst([499, 1299, 199])) # 2356.46
print(total_with_gst([100], rate=0.05)) # 105.0What will this print? Guess first, then check.Show output
2356.46
105.0Three things changed. The function can be called from anywhere, including a test. The constant has a name, so nobody wonders what 1.18 means. And bad input fails loudly with a message that says what was wrong, instead of producing a silently wrong number.
A useful rule: if you cannot describe what a function does in one sentence without the word "and", split it.
Classes: when data and behaviour belong together
Beginners either avoid classes entirely or wrap everything in one. Use a class when several values always travel together and some operations only make sense on them. For plain bundles of data, a dataclass gives you a clean constructor, a readable repr, and equality for free.
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class Ticket:
id: int
title: str
priority: str = "normal" # default value
tags: list[str] = field(default_factory=list) # never use a bare [] as a default
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def escalate(self) -> None:
if self.priority == "urgent":
raise ValueError(f"Ticket {self.id} is already urgent")
self.priority = "urgent"
t = Ticket(id=42, title="Invoice sync failing")
t.escalate()
print(t.priority, t.tags) # urgent []
ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
print(Ticket(1, "a", created_at=ts) == Ticket(1, "a", created_at=ts)) # True: equality compares fieldsWhat will this print? Guess first, then check.Show output
urgent []
TrueThe field(default_factory=list) line matters. A plain tags: list[str] = [] is rejected by dataclasses precisely because a mutable default would be shared across every instance, one of Python's classic traps. You will see the same trap in ordinary functions below.
Type hints: a promise the tools can check
Python does not enforce type hints at runtime. They are documentation that a checker such as mypy or pyright can verify, and that your editor and AI assistant use to understand your code. They also force you to decide what a function really returns.
from typing import Optional
def find_user_email(user_id: int) -> Optional[str]:
...Optional[str] (or str | None in Python 3.10+) says out loud that this can return nothing, so every caller must handle that case. Without the hint, the None path is a bug waiting for production traffic. A few hints you will use constantly:
list[str],dict[str, int],tuple[int, int]for built-in containers (Python 3.9+).str | Nonefor "might be missing".Literal["low", "normal", "urgent"]fromtypingwhen only certain values are allowed.TypedDictor adataclassfor structured records instead of loose dicts. Later in the course, Pydantic models play this role for API inputs and LLM outputs.
Run a checker to see the value: pip install mypy then mypy your_file.py. It will catch, for example, passing a str where an int was promised, before any user does.
Exceptions: decide where failure is handled
An exception is a message from deep inside the program saying "I cannot do what you asked". The skill is choosing where to catch it. Catch an exception only where you can do something useful: retry, return a clear error to the user, or add context and re-raise.
- Deep insidea function raises a specific error, like ValueError
- Middle layerslet it pass through untouched
- The boundarythe API route or CLI catches it, once
- The usergets a clear message; the log gets the details
import json
class ConfigError(Exception):
"""Raised when the app's configuration is missing or invalid."""
def load_config(text: str) -> dict:
try:
data = json.loads(text)
except json.JSONDecodeError as e:
# Add context, keep the original cause attached with "from e"
raise ConfigError(f"Config is not valid JSON (line {e.lineno})") from e
if "api_url" not in data:
raise ConfigError("Config is missing the required key 'api_url'")
return data
for raw in ['{"api_url": "https://example.com"}', '{"api_url": ', '{}']:
try:
print("loaded:", load_config(raw))
except ConfigError as err:
print("config problem:", err)What will this print? Guess first, then check.Show output
loaded: {'api_url': 'https://example.com'}
config problem: Config is not valid JSON (line 1)
config problem: Config is missing the required key 'api_url'Two anti-patterns to unlearn now:
try:
do_something()
except Exception:
pass # the error is gone forever, and so is your debugging afternoontry:
result = call_api()
except: # bare except also catches Ctrl+C and system exit
result = NoneIf you truly must catch broadly, at the very top of a request handler for instance, log the full error with logging.exception(...) so the traceback is kept.
The mutable default trap, once and for all
def add_tag(tag, tags=[]): # the [] is created ONCE, when the function is defined
tags.append(tag)
return tags
print(add_tag("a")) # ['a']
print(add_tag("b")) # ['a', 'b'] <- surprise: shared list
def add_tag_fixed(tag: str, tags: list[str] | None = None) -> list[str]:
tags = [] if tags is None else tags
tags.append(tag)
return tags
print(add_tag_fixed("a"), add_tag_fixed("b")) # ['a'] ['b']What will this print? Guess first, then check.Show output
['a']
['a', 'b']
['a'] ['b']Virtual environments: your dependencies, written down
Your laptop has one Python with whatever you have ever pip installed. The server has a different one. A virtual environment is a folder holding a private Python and its own packages for one project, so "works on my machine" becomes "works on any machine that follows the README".
# create and activate (macOS / Linux)
python3 -m venv .venv
source .venv/bin/activate
# Windows (PowerShell)
python -m venv .venv
.venv\Scripts\Activate.ps1
# install, then record exact versions
pip install fastapi uvicorn
pip freeze > requirements.txt
# anyone else, later:
pip install -r requirements.txtAdd .venv/ to .gitignore; you commit the list of dependencies, never the folder. Many teams now use uv, a much faster tool that does the same job (uv venv, uv pip install, uv add). The concept is identical, so learn the concept first.
Basic packaging: make your code importable
Once a project has more than one file, structure it so modules import each other cleanly and tests can import them too:
notes-api/
pyproject.toml
README.md
src/
notes_api/
__init__.py
models.py
service.py
main.py
tests/
test_service.pyA minimal pyproject.toml declares the project and its dependencies:
[project]
name = "notes-api"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["fastapi>=0.110", "uvicorn>=0.29"]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"Then pip install -e . installs your own package in "editable" mode, so from notes_api.service import create_note works from tests and scripts, and edits take effect without reinstalling.
How this shows up in FDE work
On a client engagement you will often inherit a script that "mostly works": one 600-line file, globals everywhere, except: pass around the API call, no requirements file. Your first useful hour is usually to pin the dependencies in a virtual environment, split the script into named functions, and replace silent failures with explicit exceptions and logs. That turns "the agent sometimes does nothing" into a traceback that points at the real cause, and it is exactly the work this lesson trains.
- Catching
Exception(or using a bareexcept:) and doing nothing. The bug does not disappear; it moves somewhere harder to find. - Mutable default arguments (
def f(x=[])) and dataclass fields withoutdefault_factory. State leaks between calls. - Functions that return a value on one path and implicitly
Noneon another. Add a return type hint and a checker will flag it. - Installing packages globally, then deploying to a server that has different versions. Always work inside a virtual environment and keep
requirements.txtorpyproject.tomlcurrent. - Committing the
.venvfolder or an.envfile with keys. Both belong in.gitignore. - Treating type hints as enforced at runtime. They are not; validate external input (API requests, model output) explicitly.
Answer in your head or on paper first, then open the answer and be honest with yourself.
Why is def f(items=[]) a bug, and what is the fix?
The default list is created once when the function is defined and shared by every call that does not pass items, so state leaks between calls. Default to None and create a new list inside the function.
Does Python stop you from passing a str to a parameter hinted as int?
No. Type hints are not enforced at runtime. A static checker such as mypy or pyright reports the mismatch before the code runs, and external input still needs explicit validation.
When should you catch an exception rather than let it propagate?
Only where you can do something useful with it: retry, translate it into a clear error for the caller, or add context and re-raise. Otherwise let it propagate to a place that can handle it.
What does raise ConfigError(...) from e add compared to a plain raise ConfigError(...)?
It chains the original exception as the cause, so the traceback shows both the high-level message and the low-level error that triggered it.
What goes into git: the .venv folder or requirements.txt?
Only requirements.txt (or pyproject.toml with a lock file). The environment folder is machine-specific and is rebuilt from that list.
An AI assistant is excellent at this topic as a reviewer and explainer, and risky as an author while you are still building instincts. Write the code yourself first, then use AI to critique it. The goal is that by the end of Week 1 you spot these issues before the assistant does.
You are a senior Python backend engineer reviewing a junior's code before it ships.
Review the code below for: hidden inputs or global state, missing or wrong type hints,
error handling (swallowed or overly broad exceptions), mutable default arguments,
and functions doing more than one job.
For each issue: quote the line, explain why it is a problem in production in one or two
sentences, and show the corrected snippet. Do not rewrite the whole file.
Finish with the single most important change.
Python version: [3.11]
Code:
[PASTE YOUR CODE]I am learning to read Python tracebacks. Here is one from my code.
Do NOT give me the fix yet. Instead:
1. Tell me which line of MY code (not library code) the error starts from, and how you found it.
2. Explain in plain English what the exception type means.
3. Ask me one question that would lead me to the cause myself.
Traceback:
[PASTE THE FULL TRACEBACK]
Relevant code:
[PASTE THE FUNCTION]Quiz me on Python fundamentals for backend work: functions, dataclasses, type hints,
exceptions, virtual environments and packaging. Ask one question at a time, mixing
"predict the output" snippets with "what is wrong with this code" snippets.
Wait for my answer, tell me if I am right, explain briefly, then ask the next question.
Make the questions harder when I get two in a row right.AI tools often "fix" an error by wrapping it in try/except Exception: pass or by adding a broad except that returns None. That makes the symptom vanish and the bug worse. They also mix packaging styles (setup.py, setup.cfg and pyproject.toml advice from different years) and may suggest commands for the wrong operating system. Check every suggested command against the official docs linked below before you run it, and never accept an exception-handling change you cannot explain.