learn
Weeks 4–6 · About 30 hours

Reusable steps, and Python's built-in containers

Packaging work into functions, and the four ways Python stores collections of things: lists, dictionaries, tuples and sets.

This is the direct on-ramp to everything that follows. A Python list is a . A Python dictionary is a . Those are two of the most important structures in the whole roadmap, and you meet them here as ordinary everyday tools before anyone asks you to build one yourself in week 18. Using something fluently first, then learning how it works underneath, is exactly the right order.

Functions

A function gives a name and a boundary to a repeatable piece of work.

Reach for it whenUse a function instead of copied code when one job repeats or deserves its own test; keep a tiny one-off expression inline when a name would obscure it.

Think of it likeA vending machine. You put something in, a fixed process happens, something comes out. You do not need to know what happens inside to use it.

You define one with def, a name, and brackets listing what it needs: def add(a, b): followed by an indented block. The names in brackets are called parameters — they refer to whatever gets passed in when the function runs.

The word return sends a value back to whoever called the function, and immediately stops the function. Anything written after a return never runs.

Syntax you can use
def greet(name):
Defines a function. The body is the indented block below.
return value
Hands a result back to the caller and stops the function.A function with no return gives back None.
def greet(name, greeting="Hi"):
A default, used when the caller omits that argument.
greet(name="Ada")
A keyword argument. Clearer than position once there are several.
def total(*numbers):
Collects any number of positional arguments into a tuple.
def config(**options):
Collects any number of keyword arguments into a dictionary.
def add(a: int, b: int) -> int:
Type hints. Python does not enforce them; they are for readers and tools.
"""One line saying what it does."""
A docstring, the first thing in the body. help(add) prints it.
maincallerareaw=3areaw=8
Notice that each call gets its own parameter and local values, even when both calls execute the same function body.
Several ways to see it
What the machine doesA call creates a fresh local workspace

Python binds argument values to parameter names, creates local variables as the body runs, and discards that frame when the call returns. Calling the same function twice therefore does not reuse the first call's locals.

Against the alternativeA function earns its name by hiding a decision

Copying three lines saves no thinking if callers still need to understand every step. A good function names a stable responsibility and lets its implementation change without changing its callers.

How it breaksA mutable default remembers earlier calls

Default arguments are evaluated once when `def` runs, not afresh for each call. Use `None` as the default and create the list inside when each call needs independent state.

In the wild
  • FastAPI — calls Python functions registered as handlers when matching HTTP requests arrive.
  • pytest — discovers named test functions and calls them with requested fixtures.
Commonly gets misread as
  • "A function must print its result." Correction: return hands a value to the caller; printing only displays it.
  • "Parameters are arguments." Correction: parameters are the function's names; arguments are the values supplied by a call.

Scope — where a name is visible

Scope is the part of a program where a name can be found.

Reach for it whenKeep values in local scope instead of globals when only one function needs them; share state explicitly when several parts truly own it.

This is a feature, not a limitation. It means two different functions can both use the name count without interfering with each other. Each function gets its own private space.

A function can read variables defined outside it, but if it assigns to a name, that name becomes local to the function. This asymmetry surprises people once and then makes sense.

Syntax you can use
count = 0
At module level, outside any function. Readable from inside one.
global count
Declares that assignments in this function change the module-level name.Needed only to ASSIGN. Reading an outer name works without it.
nonlocal total
The same idea, but for the enclosing function rather than the module.
def f(items=None): items = items or []
The safe way to default a mutable argument.def f(items=[]) reuses ONE list across every call — a bug that costs an hour to find.
Several ways to see it
Its shapeName lookup walks outward, never sideways

Python checks the current local scope, then enclosing functions, then the module, then built-ins. One sibling function cannot see another sibling's locals because neither encloses the other.

What the machine doesAssignment makes a name local before the line runs

If a function assigns to `count` anywhere, Python classifies `count` as local throughout that function. Reading it earlier can therefore raise `UnboundLocalError` even when a global name has the same spelling.

The Python you typeParameters make dependencies visible

Passing a value in and returning a result exposes the function's contract at the call site. Reaching into globals hides that contract and makes tests depend on ambient state.

In the wild
  • CPython — resolves a name through local, enclosing, global, and built-in scopes.
  • Django settings — loads project configuration into a settings object that modules access explicitly.
Commonly gets misread as
  • "A name created inside a function exists everywhere afterward." Correction: local names are only visible in that call's scope.
  • "Global is the easiest way to share data." Correction: parameters and return values make ownership and tests clearer.

Lists

A list is an ordered, changeable row of values that can contain repeats.

Reach for it whenUse a list instead of a set when order, duplicates, or position matters; use a tuple when the group should not change.

You write one as names = ["ana", "bo", "cy"]. You reach into it with square brackets and a position: names[0] is "ana". Counting starts at zero, so the last of three items sits at position 2. names[-1] is a shortcut for the last one.

Slicing takes a range: names[0:2] gives you the first two. The end number is not included, which matches how range() behaves — Python is consistent about this once you notice the pattern.

Syntax you can use
items = [1, 2, 3]
A list literal: ordered, changeable, duplicates allowed.
items[0]
The first element. items[-1] is the last.
items[1:3]
A slice — a new list holding elements 1 and 2. The end is excluded.
items.append(4)
Adds one element to the end.
items.extend([5, 6])
Adds every element of another sequence.
items.pop()
Removes and returns the last. items.pop(0) takes the first.pop(0) shifts every remaining element. Use collections.deque if you do it in a loop.
items.remove(2)
Removes the first element equal to 2. Raises ValueError if there is none.
items.sort()
Sorts in place and returns None. sorted(items) returns a new list instead.
items.sort(key=len, reverse=True)
Sorts by a computed value, longest first.
len(items)
How many elements. Works on strings, dictionaries and sets too.
copy = items[:]
A shallow copy.copy = items is the SAME list under a second name — changing one changes both.
0'Ada'1422True3
Notice the numbered slots are contiguous even though the objects they reference need not be.
Several ways to see it
Its shapeA Python list is a row of references

The contiguous slots point to objects that may live elsewhere and may have different types. Copying the outer row is therefore shallow: nested objects can still be shared.

What it costsThe front is the expensive end

Appending usually writes into spare capacity, but inserting at index zero shifts every existing reference one slot. A is the better fit when both ends receive frequent traffic.

How it breaksAn alias is not a snapshot

`backup = items` gives the same list a second name. Use `items.copy()` for an independent outer list, and use a deep copy only when nested mutable values must also be duplicated.

In the wild
  • CPython list — stores object references in a resizable for fast indexed access and append.
  • Ansible — represents an ordered playbook's tasks as YAML lists executed in sequence.
Commonly gets misread as
  • "A list copies every object put into it." Correction: it stores references to those objects.
  • "Removing the first item is as cheap as removing the last." Correction: later items must shift left.

What it costsReaching a position by number is O(1). Searching for a value whose position you do not know is O(n). Adding to the end is O(1) on average. Inserting at the front is O(n).

Dictionaries

A dictionary remembers values by meaningful keys instead of numbered positions.

Reach for it whenUse a dictionary instead of a list when you look things up by a unique name or identifier; use a list when order and repeats are the main concern.

Think of it likeA contacts app. You do not scroll to position 47, you look up the name.

You write one as ages = {"ana": 30, "bo": 25}. The labels are called keys and the things stored are called values. You fetch with ages["ana"].

The remarkable property is that looking something up takes the same time whether the dictionary holds ten items or ten million. It does not search. It calculates where the item must be from the key itself and goes straight there. That calculation is called hashing, and building it yourself is one of the main events of week 18.

Syntax you can use
ages = {"ada": 36}
A dictionary literal: keys mapped to values.
ages["ada"]
Looks up a key. Raises KeyError if it is missing.
ages.get("bob", 0)
Looks up a key, returning a fallback instead of raising.
ages["bob"] = 41
Adds or overwrites. There is no separate insert.
"ada" in ages
Tests for a KEY, not a value. Average O(1).
for key, value in ages.items():
Walks pairs. .keys() and .values() give one side each.
ages.setdefault("cy", []).append(1)
Fetches the key, inserting a default first if it is absent. The one-line “group into buckets”.
del ages["bob"]
Removes a key. ages.pop("bob", None) removes it without raising.
ages.update(other)
Merges another dictionary in, overwriting on collision.
0Ada12LinSam3Kai
Notice that two different keys can land together; equality checks distinguish the collision after hashing.
Several ways to see it
What the machine doesThe key is turned into a candidate slot

Python hashes the key, uses part of that number to probe the table, and confirms equality when it finds a candidate. Lookup does not scan keys from the beginning.

What it costsSpare slots buy fast lookup

A dictionary deliberately keeps unused capacity so probes remain short. It spends more memory than a list of pairs in exchange for average constant-time access by key.

How it breaksA changing key would become lost

Keys must be hashable because their hash and equality must remain stable while stored. Lists are rejected as keys; an tuple works only when every value inside it is hashable too.

In the wild
  • FastAPI — serializes returned Python dictionaries into JSON response objects.
  • PyTorch state_dict — maps parameter and buffer names to tensors for saving and loading models.
Commonly gets misread as
  • "Dictionary keys are positions." Correction: keys are labels and need not be consecutive numbers.
  • "Looking up a missing key returns None." Correction: bracket lookup raises KeyError unless you handle the absence.

What it costsLooking up, adding and removing are all O(1) on average — the size of the dictionary does not matter.

Tuples and sets

A tuple is a fixed ordered group; a set is an unordered collection that keeps only one of each value.

Reach for it whenUse a tuple instead of a list for a fixed record, and a set instead of a list for membership or deduplication; avoid a set when order or repeats matter.

Tuples are written with round brackets: point = (3, 4). Because they cannot change, they can be used as dictionary keys where lists cannot. They are also a clear signal to whoever reads your code that this group of values belongs together and is not meant to be edited.

Sets are written with curly brackets: seen = {1, 2, 3}. Adding something already there does nothing. Checking whether something is in a set is instant, in the same way as a dictionary lookup — a set is essentially a dictionary with keys and no values.

Syntax you can use
point = (3, 4)
A tuple: ordered like a list, but immutable.
x, y = point
Unpacking. Works for any sequence of the right length.
first, *rest = items
Takes one element and collects the remainder into a list.
seen = set()
An empty set.{} is an empty DICTIONARY, not a set. This trips up everyone once.
seen = {1, 2, 3}
A set literal: unordered, no duplicates, average O(1) membership.
seen.add(4)
Adds one element. Adding a duplicate does nothing at all.
a & b
Intersection. Also | union, - difference, ^ symmetric difference.
set(items)
Deduplicates any sequence.Order is not preserved. list(dict.fromkeys(items)) dedupes and keeps it.
Several ways to see it
Against the alternativeA tuple records position; a set records membership

A tuple preserves order and duplicates because each position has meaning. A set discards duplicates and makes no indexing promise because its question is only whether a value is present.

What it costsSet algebra replaces repeated scans

Intersection and difference express bulk membership work directly and use hashing underneath. Repeating `x in some_list` inside a loop can turn linear work into quadratic work.

How it breaksTuple immutability stops at its boundary

A tuple cannot replace one of its slots, but a slot may point to a list that can still change. Immutability describes the tuple's references, not a magical freeze of every nested object.

In the wild
  • Django QuerySet values_list — returns selected database columns as tuples, with one fixed position per column.
  • pytest parametrize — accepts tuples of argument values as individual test cases.
Commonly gets misread as
  • "A tuple makes the objects inside it immutable." Correction: a contained mutable object can still change.
  • "A set is a faster list." Correction: it deliberately discards duplicates and positional order.

What it costsChecking whether something is in a set is O(1). Checking whether it is in a list is O(n). For large collections that difference is enormous.

List comprehensions

A comprehension builds a new collection by saying what to take and which items qualify.

Reach for it whenUse a comprehension instead of a short append loop when the transform and filter fit one readable line; use a normal loop when steps or error handling matter.

Instead of creating an empty list and appending in a loop, you write doubled = [x * 2 for x in numbers]. Read it left to right as: make a list, of x times two, for each x in numbers.

You can filter at the same time: evens = [x for x in numbers if x % 2 == 0].

Syntax you can use
[x * 2 for x in items]
Builds a new list by transforming each element.
[x for x in items if x > 0]
Filters. The condition goes at the end.
[f(x) if x else 0 for x in items]
Chooses per element. This if/else goes BEFORE the for; a filtering if goes after.
{k: v for k, v in pairs}
A dictionary comprehension.
{word.lower() for word in words}
A set comprehension, which deduplicates as it builds.
sum(x * x for x in items)
A generator expression. It computes lazily, so nothing large is built in memory.
[c for word in words for c in word]
Flattens. The for clauses read left to right, exactly like nested loops.Past two clauses, write the loop out. Clever is not the goal.
Several ways to see it
Its shapeRead it as output, source, then gate

In `[clean(x) for x in rows if valid(x)]`, the left expression says what is produced, the `for` says where values come from, and the final `if` says which survive. That fixed grammar makes a short data pipeline visible on one line.

Against the alternativeA comprehension is for transforming, not performing

Choose it when each input may yield one output value. Use a normal loop for logging, multiple state changes, `break`, or nested decisions; compressing those hides the control flow.

What it costsThe brackets still build the whole result

A list comprehension allocates every output item before the next statement runs. Replace brackets with parentheses when a consumer can process values lazily and the full result need not live in memory.

In the wild
  • Jupyter Notebook — runs ordinary Python comprehensions to build filtered collections during data exploration.
  • Django shell — runs comprehensions over model query results during inspection and maintenance work.
Commonly gets misread as
  • "A comprehension changes the original collection." Correction: it creates a new one.
  • "Shorter is always clearer." Correction: nested or multi-condition comprehensions are often better as named loops.

Reading about this stage is not the same as finishing it. Type every one of these from a blank file rather than copying — the writing is the part that teaches.

Word-frequency counter

Take a paragraph of text and print how many times each word appears. Use a dictionary with the word as the key and the count as the value. This is the single most useful small program for understanding what dictionaries are for.

Contact book

Add a contact, search for one, delete one, list them all. Store them in a dictionary and give each operation its own function. Your first program with a real structure rather than one long script.

Grade tracker

Store several scores per student and compute each student's average. A dictionary where each value is a list — your first nested structure.

Write find_max yourself

Write a function that takes a list of numbers and returns the largest, without using Python's built-in max(). Keep a running best-so-far and compare each item against it. It looks trivial, and it is the pattern underneath a surprising number of later algorithms.

Remove duplicates, twice

Remove duplicates from a list two ways: once with a loop that checks whether each item is already in a new list, and once by converting to a set. Then run both on a list of 100,000 items and time them. The difference you feel here is exactly what Big-O notation describes.

Exit checkYou can write a function that takes a list and returns a dictionary, and explain out loud what every line of it does.

If you cannot do this, repeat the stage rather than moving on. Nothing later gets easier by skipping it.

Worth knowing before you start

  • This is one of the two places beginners most often quit. The return-versus-print confusion makes people feel stupid, and it is not a sign of anything — everyone hits it. If a function seems to work but the value vanishes, check whether you printed when you meant to return.

This stage corresponds to Phase 0 — Functions & Built-in Data Structures in the source roadmap.