Unlock all weeks

Zero to FDE / Free Week 1 / Lesson 1

Free Week 1 · Lesson 1 of 5 · about 150 min

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
Why this matters

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.

The same code, two different jobs
Notebook habit
  • 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
Backend habit
  • 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.

Python
# 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)
Python · runs as is
# 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.0
What will this print? Guess first, then check.Show output
2356.46
105.0

Three 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.

Python · runs as is
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 fields
What will this print? Guess first, then check.Show output
urgent []
True

The 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.

Python
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 | None for "might be missing".
  • Literal["low", "normal", "urgent"] from typing when only certain values are allowed.
  • TypedDict or a dataclass for 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.

Where an error should be handled
  1. Deep insidea function raises a specific error, like ValueError
  2. Middle layerslet it pass through untouched
  3. The boundarythe API route or CLI catches it, once
  4. The usergets a clear message; the log gets the details
Python · runs as is
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:

Python
try:
    do_something()
except Exception:
    pass          # the error is gone forever, and so is your debugging afternoon
Python
try:
    result = call_api()
except:           # bare except also catches Ctrl+C and system exit
    result = None

If 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

Python · runs as is
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".

Terminal
# 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.txt

Add .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:

Text
notes-api/
  pyproject.toml
  README.md
  src/
    notes_api/
      __init__.py
      models.py
      service.py
      main.py
  tests/
    test_service.py

A minimal pyproject.toml declares the project and its dependencies:

Text
[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.

Pitfalls
  • Catching Exception (or using a bare except:) and doing nothing. The bug does not disappear; it moves somewhere harder to find.
  • Mutable default arguments (def f(x=[])) and dataclass fields without default_factory. State leaks between calls.
  • Functions that return a value on one path and implicitly None on 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.txt or pyproject.toml current.
  • Committing the .venv folder or an .env file with keys. Both belong in .gitignore.
  • Treating type hints as enforced at runtime. They are not; validate external input (API requests, model output) explicitly.
Practice
Check yourself

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.

Use AI for this

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.

Review my function like a senior engineer
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]
Explain this traceback without fixing it for me
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 until I get it
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.
Watch out

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.

Go deeper
Lesson 1 of 77 in the full course

Week 2 starts on how LLMs actually work.

The full course continues with 72 more lessons like this one, a real deliverable every week, and the AI Skills module.

See what unlocks