What’s one thing you learned? What’s still confusing?
Files, Paths, CSV, JSON Lines and Dates
Read and write text, CSV and JSON Lines files safely, build paths with pathlib, handle timestamps correctly and survive bad rows.
Mini-Project: Todo App
You'll build a CLI todo app, a classic first project, written in pure Python with real file persistence.
Modules, Packages & Environments with uv
Split your code into importable files, then create a project environment, install packages and share the setup with uv, while still reading the pip and venv commands every tutorial shows.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
with statement: it makes sure cleanup happens even when something goes wrong, and you can write your own with a generator and one decorator.yield, it pays to see the protocol generators implement. A for loop doesn't know or care whether you hand it a list, a generator, or any other object that follows the iterator protocol. They all answer the same two questions: "Give me the next value" and "Are you done yet?" Step through the five presets below — especially C. Generator — to see how yield pauses a function's frame in place, and how next() resumes it. The rest of this section is just sugar on top.yield instead of return. Each time you call next() on it, it runs until the next yield, pauses, and gives you the value. This is called lazy evaluation — values are produced on demand, not all at once.def count_up(n):
"""A generator that yields numbers from 1 to n."""
i = 1
while i <= n:
yield i
i += 1
# Using the generator
counter = count_up(5)
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
# Or use it in a for loop (most common)
for num in count_up(5):
print(num, end=" ")
# 1 2 3 4 5yield freeze the function mid-loop — i is preserved on the heap, the for loop in the caller pulls the next value, and execution resumes exactly where it paused.Edit the code, then click Trace it. Python actually runs in your browser — every line, every variable, every print.
Click Trace it to capture the execution trace. The scrubber below will let you step through every variable change line-by-line.
import sys
# List: stores ALL values in memory at once
numbers_list = [x ** 2 for x in range(1_000_000)]
print(f"List size: {sys.getsizeof(numbers_list):,} bytes")
# List size: 8,448,728 bytes (~8 MB; exact numbers vary by Python version)
# Generator: produces values one at a time
numbers_gen = (x ** 2 for x in range(1_000_000))
print(f"Generator size: {sys.getsizeof(numbers_gen):,} bytes")
# Generator size: 200 bytes (constant, regardless of how many items!)The generator uses virtually no memory because it only computes one value at a time. This is critical when working with datasets larger than your RAM.
def fibonacci_gen():
"""An infinite Fibonacci generator."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Get the first 10 Fibonacci numbers
fib = fibonacci_gen()
first_10 = [next(fib) for _ in range(10)]
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]yield from: Delegating to Another Generatoryield from is shorthand for iterating over a sub-generator and re-yielding each value. It is cleaner than a manual for loop and also handles .send() and .throw() correctly for advanced generator protocols:# Without yield from: manual delegation
def chain_manual(gen1, gen2):
for item in gen1:
yield item
for item in gen2:
yield item
# With yield from: delegate directly
def chain(gen1, gen2):
yield from gen1 # yield every value from gen1, then...
yield from gen2 # yield every value from gen2
gen = chain(range(3), range(10, 13))
print(list(gen)) # [0, 1, 2, 10, 11, 12]
# yield from works with any iterable, including lists and strings
def flatten(nested):
"""Recursively flatten arbitrarily nested lists using yield from."""
for item in nested:
if isinstance(item, list):
yield from flatten(item) # recurse and re-yield all values
else:
yield item
print(list(flatten([1, [2, [3, 4]], [5, 6]]))) # [1, 2, 3, 4, 5, 6]raw = """first line
second line
third line"""
def read_lines(text):
"""Yield lines one at a time, with the spaces trimmed."""
for line in text.splitlines():
yield line.strip()
def filter_nonempty(lines):
"""Yield only non-empty lines."""
for line in lines:
if line:
yield line
def to_uppercase(lines):
"""Yield each line in uppercase."""
for line in lines:
yield line.upper()
# Chain generators into a pipeline (nothing executes until you iterate!)
pipeline = to_uppercase(filter_nonempty(read_lines(raw)))
for line in pipeline:
print(line)
# FIRST LINE
# SECOND LINE
# THIRD LINEfor line in f hands you one line at a time in the same way, so the same pipeline can run over a file with billions of lines and never hold more than one line in memory.itertoolsgen[:5] does not work. The standard library module itertools has lazy tools for exactly this:from itertools import chain, islice
def naturals():
n = 1
while True:
yield n
n += 1
print(list(islice(naturals(), 5))) # [1, 2, 3, 4, 5]
print(list(islice(naturals(), 0, 10, 3))) # [1, 4, 7, 10] (start, stop, step)
print(list(chain([1, 2], (3, 4), range(5, 7)))) # [1, 2, 3, 4, 5, 6]islice takes the first few values of any iterator, even an infinite one. chain joins several iterables into one lazy stream without building a combined list. The built-in map() and filter() are lazy too, though a generator expression usually reads better. itertools.tee can split one iterator into several independent ones, but if one copy runs far ahead the values it passed are held in memory, so it is not the tool for very large data.A generator function's body does not run at all until you call `next()` on it. What does calling the function itself do?
Some things have to be given back when you are done with them: an open file, a database connection, a lock. If the code between "get it" and "give it back" fails halfway, the give-back line never runs and the resource leaks.
with statement fixes that. It sets something up, runs your block, and always cleans up afterward, even when the block raises an error.with Guarantees# open() returns a file object. Files get their own lesson next;
# for now all you need to know is that an open file must be closed.
with open("notes.txt", "w") as f:
f.write("hello")
# f is closed here, even if f.write had raised an errorwith statement does three things, in this order:as.return, or raised an exception.try/finally. This code does exactly what the with above does:f = open("notes.txt", "w")
try:
f.write("hello")
finally:
f.close()with is the short, hard-to-forget way to write it. Behind the scenes Python calls two methods named __enter__ and __exit__ on the object. You do not write them in this lesson, because contextlib lets you build the same thing from a generator.with blocks, and the generator-based shortcut you are about to write.@contextmanagercontextlib.contextmanager turns a generator with a single yield into a context manager. Everything before the yield is the enter step. The yield is where your with block runs. Everything after it is the exit step.from contextlib import contextmanager
import time
@contextmanager
def timer(label):
start = time.time()
print(f"{label}: started") # the enter step
try:
yield # the with block runs here
finally:
# try/finally is essential: without it, an error inside the
# with block would skip the exit step below.
print(f"{label}: finished in {time.time() - start:.4f}s")
with timer("sum"):
total = sum(range(1_000_000))
print(total)
# sum: started
# 499999500000
# sum: finished in 0.0257syield inside try/finally. Watch what happens when the block fails:try:
with timer("bad"):
1 / 0
except ZeroDivisionError:
print("the error still reached us")
# bad: started
# bad: finished in 0.0000s
# the error still reached usexcept. The context manager cleaned up without hiding the problem.asyield becomes the name after as. This is how a context manager gives you the resource it prepared:@contextmanager
def open_connection(db_name):
conn = {"db": db_name, "open": True} # a stand-in for a real connection
print(f"Connecting to {db_name}...")
try:
yield conn # 'as conn' receives this
finally:
conn["open"] = False
print(f"Closing connection to {db_name}")
with open_connection("ml_data.db") as conn:
print("open inside?", conn["open"])
print("open after?", conn["open"])
# Connecting to ml_data.db...
# open inside? True
# Closing connection to ml_data.db
# open after? FalseA with block raises an exception halfway through. What happens to the exit step of the context manager?
Tests · Build generators, test lazy evaluation, and write your own context manager!
yield for lazy evaluation. They produce values one at a time, so memory use stays constant even for infinite sequences. Use next() or a for loop to consume them.(x * x for x in items), is the one-line form. A generator is single-use: a second pass over an exhausted generator is silently empty.with statement runs an enter step, then your block, then an exit step that always runs, even when the block raises.@contextmanager, put the setup before the yield, and put the cleanup in a finally after it.What is the difference between return and yield?