skip to navigation
skip to content

Planet Python

Last update: May 29, 2026 04:44 PM UTC

May 29, 2026


Real Python

The Real Python Podcast – Episode #297: Improving Python Through PEPs and Protocols

Have you ever been confused by the naming of modules you're importing from a package? Is there a standard way to organize and name your Python virtual environments? This week on the show, Brett Cannon returns to discuss the Python Enhancement Proposals (PEPs) he's been working on recently.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 29, 2026 12:00 PM UTC

Quiz: Python's assert: Debug and Test Your Code Like a Pro

In this quiz, you’ll test your understanding of Python’s assert: Debug and Test Your Code Like a Pro.

By working through this quiz, you’ll revisit how assertions help you debug, test, and document your code, when to disable them in production, and which common pitfalls to avoid.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 29, 2026 12:00 PM UTC


Ned Batchelder

Snake way for ducklings

This is the mascot for Boston Python. It’s called Snake Way for Ducklings:

A cute snake with a napkin around its neck, with eight duckling-shaped bumps along its body

My son Ben drew it, which makes me very happy. He also drew Sleepy Snake. Wearing this image on a shirt around PyCon, I had to explain it a number of times. People in Boston understand it almost immediately, but others need more background.

In 1941, Robert McCloskey wrote a children’s book called Make Way for Ducklings. It’s a classic, selling millions of copies and never going out of print. We read it to our own children growing up many times.

The book is the story of Mrs. Mallard making her way through Boston guiding her eight ducklings (Jack, Kack, Lack, Mack, Nack, Oack, Pack, and Quack) to a pond in Boston’s Public Garden. It has charming pencil illustrations:

A page spread from Make Way for Ducklings showing a policeman stopping traffic to let the ducks cross the street

The book led to a sculpture in the Public Garden near the actual pond:

The bronze sculpture of the ducks in the Public Garden

The sculpture is sized and placed for kids to play on, and is widely known and beloved in Boston. The ducks are dressed in costumes for all kinds of occasions: holidays, sports events, even Star Wars day. On Mother’s Day, there’s a duckling parade: families bring their children dressed as ducklings. In Boston, the ducklings are a big deal.

And it’s not just fiction.

So it seemed natural to Ben to riff on the ducklings for Boston Python. One observer thought a snake eating the ducklings seemed kind of dark, but you can see the ducklings are still quacking, so they are fine!

A cute snake with a napkin around its neck, with eight duckling-shaped bumps along its body

BTW, Boston also has Duck Boat tours, but that’s completely different.

May 29, 2026 11:29 AM UTC


Seth Michael Larson

How much “Super Mario” per year?

It's impossible to objectively quantify art, but we try anyway. For example: Is “Super Mario” a good video-game franchise?

Looking at review scores, Super Mario includes some of the most universally-acclaimed games ever published: Galaxy, Galaxy 2, and Odyssey are respectively the #4, #5, and #13 highest ranking video-games of all time on Metacritic, all with 97 overall. Chances seem good?

What if we tried quantifying art in a different and slightly more reductive way? This blog post introduces and calculates a new unit: “Super Mario per year”. If you enjoy this franchise like I do then this unit is of particular importance to you.

Calculating “Super Mario per year”

There have been ~19 titles (and two add-ons) published to what I consider the "main-line" Super Mario games, both 2D and 3D. Below is a table with every title, the year it was published, and the approximate duration to play. This last column is the most subjective, because there’s speed-runners, casual players, completionists. If you think any value is way off, send me an email.

Game 2D/3D Platform Year Time to Beat
Super Mario Bros. 2D NES 1985 5 hours
Super Mario Bros. Lost Levels 2D NES 1986 10 hours
Super Mario Bros. 2 2D NES 1988 5 hours
Super Mario Bros. 3 2D NES 1988 5 hours
Super Mario Land 2D GB 1989 5 hours
Super Mario World 2D SNES 1990 10 hours
Super Mario Land 2 2D GB 1992 10 hours
Super Mario 64 3D N64 1996 15 hours
Super Mario Sunshine 3D GC 2002 20 hours
New Super Mario Bros. 2D DS 2006 10 hours
Super Mario Galaxy 3D Wii 2007 15 hours
New Super Mario Bros. Wii 2D Wii 2009 5 hours
Super Mario Galaxy 2 3D Wii 2010 15 hours
Super Mario 3D Land 3D 3DS 2011 15 hours
New Super Mario Bros. 2 2D 3DS 2012 10 hours
New Super Mario Bros. U 2D Wii U 2012 15 hours
Super Mario 3D World 3D Wii U 2013 20 hours
Super Mario Odyssey 3D Switch 2017 25 hours
Bowser's Fury (Super Mario 3D World) 3D Switch 2021 5 hours
Super Mario Bros. Wonder 2D Switch 2023 15 hours
Meetup at Bellabel Park (Super Mario Bros. Wonder) 2D Switch 2026 5 hours

Using the table above we can calculate approximately how much new Super Mario gameplay is published on average per year.

Year All-Time Avg 10-Year Avg (10YA) 2D (10YA) 3D (10YA)
1985 5.0 5.0 5.0 0.0
1986 7.5 7.5 7.5 0.0
1987 5.0 5.0 5.0 0.0
1988 6.2 6.2 6.2 0.0
1989 6.0 6.0 6.0 0.0
1990 6.7 6.7 6.7 0.0
1991 5.7 5.7 5.7 0.0
1992 6.2 6.2 6.2 0.0
1993 5.6 5.6 5.6 0.0
1994 5.0 5.0 5.0 0.0
1995 4.5 5.0 5.0 0.0
1996 5.4 6.0 4.5 1.5
1997 5.0 5.0 3.5 1.5
1998 4.6 5.0 3.5 1.5
1999 4.3 4.0 2.5 1.5
2000 4.1 3.5 2.0 1.5
2001 3.8 2.5 1.0 1.5
2002 4.7 4.5 1.0 3.5
2003 4.5 3.5 0.0 3.5
2004 4.2 3.5 0.0 3.5
2005 4.0 3.5 0.0 3.5
2006 4.3 4.5 1.0 3.5
2007 4.8 4.5 1.0 3.5
2008 4.6 4.5 1.0 3.5
2009 4.6 5.0 1.5 3.5
2010 5.0 6.5 1.5 5.0
2011 5.4 8.0 1.5 6.5
2012 6.1 10.5 4.0 6.5
2013 6.6 10.5 4.0 6.5
2014 6.3 10.5 4.0 6.5
2015 6.1 10.5 4.0 6.5
2016 5.9 10.5 4.0 6.5
2017 6.5 12.0 3.0 9.0
2018 6.3 10.5 3.0 7.5
2019 6.1 10.5 3.0 7.5
2020 6.0 10.0 2.5 7.5
2021 5.9 9.0 2.5 6.5
2022 5.8 7.5 2.5 5.0
2023 6.0 6.5 1.5 5.0
2024 5.9 4.5 1.5 3.0
2025 5.7 4.5 1.5 3.0
2026 5.7 5.0 2.0 3.0

This table will help you calculate approximately how much Super Mario is coming in the next decade. The current 10-year window pace shows 5 hours of Super Mario per year.

Looking at the trends, it looks like we may have already passed peak 2D and 3D Mario individually. This table also shows how overdue we are for a new big 3D Super Mario title, the last entry being Super Mario Odyssey almost a decade ago in 2017.

If I were to somewhat morbidly apply these numbers I can estimate how much more new “Super Mario” gameplay I’m likely to experience. Let’s be optimistic and apply the “All-Time Average” instead of the “10-Year Average”: the resulting number is 256 hours. Around 10 games of similar size to “Super Mario Odyssey”... seems good to me!

Super Mario Blogroll

If you want to read more Super Mario writing here are a few personal selections from my blogroll:

Happy gaming!



Thanks for keeping RSS alive! ♥

May 29, 2026 12:00 AM UTC

May 28, 2026


Real Python

Quiz: BNF Notation: Dive Deeper Into Python's Grammar

In this quiz, you’ll test your understanding of BNF Notation: Dive Deeper Into Python’s Grammar.

By working through this quiz, you’ll revisit how to read Python’s grammar rules, recognize terminals and nonterminals, and interpret the BNF fragments that appear throughout the official documentation.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 28, 2026 12:00 PM UTC


Bob Belderbos

Two Python Scoping Bugs: A Lesson in Object Lifetimes

Your app works fine with one user. You open a second browser tab and the data is wrong. Your tests pass individually but fail when run together. The culprit: a global object created at module scope.

How it starts

I see this a lot in Python web projects:

# database.py
from sqlmodel import create_engine, Session

engine = create_engine("sqlite:///database.db")

def get_session():
    with Session(engine) as session:
        yield session

This 'innocent' engine is created the moment database.py is first imported. Every module that imports from database shares the same engine, the same connection pool, the same database file. For a simple script, this is fine. For a multi-module app, it creates hidden coupling and shared state.

The test isolation problem

I hit this recently in a FastAPI app:

# test_app.py
from myapp.database import engine, create_db_and_tables, clear_db_and_tables

@pytest.fixture(autouse=True)
def setup_database():
    clear_db_and_tables()
    create_db_and_tables()

def test_create_race(race_events):
    championship = create_races(2026, race_events)
    assert championship.id == 1  # Passes alone, fails in suite

That assert championship.id == 1 works when the test runs first. Run it after another test that inserts data, and the auto-increment ID comes back as 2. The fixture does its job, but state still leaks between tests in subtle ways: connection pool state, cached metadata, and SQLite's own bookkeeping on the shared file can carry over even with drop/recreate cycles.

The root cause is upstream: every test reaches for the same module-level engine pointed at the same on-disk database. If you want true isolation, the engine itself has to be per-test, not the cleanup ritual around it.

The fix is creating an engine per test session:

@pytest.fixture
def engine():
    engine = create_engine("sqlite://", echo=False)
    SQLModel.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture
def session(engine):
    with Session(engine) as session:
        yield session

Now each test gets a fresh database (no scope defined on the fixture decorator means function scope = per test). No cleanup needed. No shared state.

Alternatively, keep the module-level engine and wrap each test in a transaction you roll back at teardown (sqlmodel's Session supports this).

If you omit engine.dispose() in the code above, you may see a ResourceWarning: unclosed database, but only when running pytest --cov. Coverage's sys.settrace() hook keeps frame locals alive longer, delaying GC of the engine.

The shared simulator problem

The database engine bug is about too much sharing. Here is the inverse: not enough sharing, which breaks in a different way.

Consider a race simulation dashboard. FakeDataSource wraps a RaceSimulator that holds the full mutable race state, driver positions, lap counter, cumulative changes, and advances it on each call:

class FakeDataSource(RaceDataSource):
    def __init__(self, data_file: Path, delay_ms: int = 100):
        drivers = self._load_drivers(data_file)
        self.simulator = RaceSimulator(drivers=drivers)  # mutable state lives here

    async def get_positions(self, fixture_id: str) -> list[Position]:
        self.simulator.tick()  # randomly swaps adjacent positions, advances lap
        return self.simulator.get_current_positions()

The FastAPI dependency looks like this:

def get_data_source() -> RaceDataSource:
    source_type = config("DATA_SOURCE", default="fake")
    if source_type == "fake":
        return FakeDataSource(data_file=..., delay_ms=...)  # new instance every call
    ...

def get_race_data_source() -> RaceDataSource:
    return get_data_source()

FastAPI calls get_race_data_source() once per request. Each browser tab that opens the SSE stream gets a brand new FakeDataSource with a brand new RaceSimulator starting at lap 1 with drivers in their initial order.

The random swaps then diverge independently: Tab A shows Verstappen in P1 at lap 12, Tab B shows Hamilton in P1 at lap 3. Neither reflects a shared reality, because there is no shared state at all.

Two fixes, from quick to idiomatic

1. cache: one line, works immediately

from functools import lru_cache

@lru_cache(maxsize=1)
def get_data_source() -> RaceDataSource:
    source_type = config("DATA_SOURCE", default="fake")
    if source_type == "fake":
        return FakeDataSource(...)
    return SportmonksDataSource()

One instance for the lifetime of the process. Simple, but hard to override in tests.

2. FastAPI app.state: idiomatic and testable

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.data_source = get_data_source()
    yield

app = FastAPI(lifespan=lifespan)

def get_race_data_source(request: Request) -> RaceDataSource:
    return request.app.state.data_source

The data source is created once at startup, shared across all requests, and easy to replace in tests via app.dependency_overrides[get_race_data_source] = lambda: test_source.

Key takeaways

The rule of thumb: if an object holds mutable state, pick its scope deliberately. Too broad (module scope) and tests leak into each other. Too narrow (per-request) and there's no shared reality. Match the scope to the object's intended lifetime.

Or put more sharply: any module-level object that holds mutable state or owns a resource (DB engines, HTTP clients, caches, queues, connection pools) should be encapsulated. Move it into a fixture, a Depends(), or app.state. Constants and pure values at module scope are fine; resources are not.

The cost of "just import it" is paid later, in test isolation, debugging, and concurrency. Under real concurrency the GIL hides this class of bug until it doesn't, see a race condition Rust wouldn't have let me write, where the same module-global pattern leaked one user's data into another user's response.

May 28, 2026 12:00 AM UTC

May 27, 2026


Kay Hayen

Nuitka Release 4.1

This is to inform you about the new stable release of Nuitka. It is the extremely compatible Python compiler, “download now”.

This release adds many new features and corrections with a focus on async code compatibility, missing generics features, and Python 3.14 compatibility and Python compilation scalability yet again.

Bug Fixes

Package Support

New Features

Optimization

Anti-Bloat

Organizational

Tests

Cleanups

Summary

This release builds on the scalability improvements established in 4.0, with enhanced Python 3.14 support, expanded package compatibility, and significant optimization work.

The --project option seems usable now.

Python 3.14 support remains experimental, but only barely made the cut, and probably will get there in hotfixes. Some of the corrections came in so late before the release, that it was just not possible to feel good about declaring it fully supported just yet.

May 27, 2026 10:00 PM UTC


PyCharm

Build a Live Object Detection App for the Reachy Mini With TensorFlow and PyCharm

This is a guest post from Iulia Feroli, founder of the Back To Engineering YouTube community.

Build a Live Object Detection App

In this tutorial, we build a live object detection app using TensorFlow and PyCharm, then deploy it onto the Reachy Mini open-source robot for real-time object tracking.

Reachy Mini is a compact open-source robot built in collaboration by Pollen Robotics, Hugging Face, and Seeed Studio. It has been going viral lately, getting mentioned in NVIDIA videos and even in the keynotes at some of their conferences. What makes it particularly interesting is that not only is all the code open-source, the body is too, which means you can print your own parts and develop your own apps to run on it.

There is an app store of community-built projects you can explore and try, and easily contribute to. Anything conversational or camera-based is especially fun to build because of the hardware it ships with: a speaker, a microphone, and a camera, plus expressive antennas for emotions.

Reachy Mini tutorial

This really highlights the unique new type of robot that the Reachy Mini embodies: It almost feels like it is a physical representation of an LLM or an AI agent, rather than a robot that has AI added to it. It does not have a body that moves around or hands to grab things, so its main selling point is really its brain. That design choice shapes what is most interesting to build with it.

Let’s learn how to build a TensorFlow object detection app and deploy it on the Reachy Mini, which will then allow us to do live object tracking. You can head over to the PyCharm channel for the full code breakdown and try it at home. All the code is in the Reachy-mini-object-detection GitHub repository.

For an introduction to the robot, you can first watch Iulia’s video here:

What you’ll learn

What we are building

The project is split into two stages.

Stage 1 is a standalone notebook that runs entirely on your laptop using your webcam. No robot needed. This is where we make sure the detection pipeline works correctly before touching any hardware.

Stage 2 is a Reachy Mini app that integrates the same model with the robot: Her head moves to follow detected objects, her antennas wiggle when she spots something new, and a live web dashboard at http://0.0.0.0:8042 shows the annotated camera feed and detections.

You can follow along with the step-by-step video tutorial:

How TensorFlow object detection works: Step by step

1. Capture an image frame from the webcam. 

2. Convert the frame into a TensorFlow tensor.

3. Run inference through the pretrained model.

4. Receive bounding boxes, labels, and confidence scores.

5. Filter low-confidence detections.

6. Draw annotated results onto the frame.

7. Display the processed image in real time.

Prerequisites

Stage 1: Building a Tensorflow object detection pipeline in PyCharm

Before connecting the robot, we want to make sure the TensorFlow part works independently. We are going to make a notebook that only executes through our object detection model and makes it run smoothly. PyCharm’s native notebook integration is a great fit here: You can inspect each step of the pipeline and visualize results inline.

The object detection model

We are using SSD MobileNet V2 from TensorFlow Hub, trained on Open Images V4. This popular model from Google provides SSD-based object detection and has been trained on a lot of open images. With a little bit of fine-tuning you can deploy it with your own use case, though for this tutorial, the general model works well without any fine-tuning at all.

It runs at around 10 FPS on CPU, which is fast enough for responsive real-time behavior on the robot.

Install dependencies

!pip install tensorflow tensorflow-hub opencv-python numpy Pillow

Load the model

import tensorflow as tf
import tensorflow_hub as hub
import numpy as np
import cv2
import time
from IPython.display import display, clear_output
from PIL import Image

MODEL_HANDLE = "https://tfhub.dev/google/openimages_v4/ssd/mobilenet_v2/1"

print(f"TensorFlow version: {tf.__version__}")
print("Loading model (first time downloads ~30MB)...")

detector = hub.load(MODEL_HANDLE)
print("Model loaded!")

The model is about 30 megabytes and gets cached locally after the first download. Because it is very generalized, it can work across a lot of different scenarios without needing additional training data, which makes it a lot easier to get started.

Detection and drawing helpers

We need two helper functions: one to run inference and return a list of detections, and one to draw the bounding boxes on the frame. These are the same functions we use later in the Reachy app.

def detect_objects(frame_bgr, min_score=0.5, max_detections=10):
    rgb = frame_bgr[:, :, ::-1]
    img_tensor = tf.image.convert_image_dtype(rgb, tf.float32)[tf.newaxis, ...]

    results = detector.signatures['default'](img_tensor)

    boxes = np.array(results["detection_boxes"])
    scores = np.array(results["detection_scores"])
    class_labels = np.array(results["detection_class_entities"])

    if boxes.ndim > 2:
        boxes = boxes[0]
    if scores.ndim > 1:
        scores = scores[0]
    if class_labels.ndim > 1:
        class_labels = class_labels[0]

    scores = np.atleast_1d(scores)
    indices = [i for i, score in enumerate(scores) if score >= min_score][:max_detections]

    detections = []
    for idx in indices:
        ymin, xmin, ymax, xmax = boxes[idx]
        label = class_labels[idx].decode('utf-8') if isinstance(class_labels[idx], bytes) else str(class_labels[idx])
        detections.append({
            "box": [ymin, xmin, ymax, xmax],
            "score": float(scores[idx]),
            "label": label
        })

    return detections


def draw_detections(frame_bgr, detections):
    h, w = frame_bgr.shape[:2]
    annotated = frame_bgr.copy()

    for det in detections:
        ymin, xmin, ymax, xmax = det["box"]
        x1, y1 = int(xmin * w), int(ymin * h)
        x2, y2 = int(xmax * w), int(ymax * h)

        color = (0, 255, 0)
        cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 2)

        label = f"{det['label']} {det['score']:.0%}"
        font_scale, thickness = 0.6, 2
        (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
        cv2.rectangle(annotated, (x1, y1 - th - 8), (x1 + tw + 4, y1), color, -1)
        cv2.putText(annotated, label, (x1 + 2, y1 - 4),
                    cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), thickness)

    return annotated

The detect_objects function runs inference using the model’s detect_objects entry point and handles flattening the batch dimension from the output tensors. Labels come back as bytes from the model, so we decode them to strings before returning.

Test on a single frame

cap = cv2.VideoCapture(0)
ret, frame = cap.read()
cap.release()

if not ret:
    print("ERROR: Could not access webcam. Make sure no other app is using it.")
else:
    print(f"Frame captured: {frame.shape}")

    t0 = time.time()
    detections = detect_objects(frame)
    elapsed = time.time() - t0

    print(f"Inference time: {elapsed:.2f}s ({1/elapsed:.1f} FPS)")
    print(f"Found {len(detections)} objects:")
    for d in detections:
        print(f"  - {d['label']}: {d['score']:.0%}")

    annotated = draw_detections(frame, detections)
    display(Image.fromarray(annotated[:, :, ::-1]))

This is the stage where you check that the model is detecting correctly and the bounding boxes are drawn in the right places. The inline image display in PyCharm’s notebook view makes it easy to see the result right there in the cell.

Running real-time TensorFlow object detection with OpenCV

Once the single-frame test looks good, you can run it continuously:

cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("ERROR: Could not open webcam.")
else:
    print("Running live detection... (interrupt kernel to stop)")
    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                break

            t0 = time.time()
            detections = detect_objects(frame)
            fps = 1.0 / max(time.time() - t0, 0.001)

            annotated = draw_detections(frame, detections)
            cv2.putText(annotated, f"{fps:.1f} FPS", (10, 30),
                        cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2)

            clear_output(wait=True)
            display(Image.fromarray(annotated[:, :, ::-1]))

            labels = ", ".join(f"{d['label']} ({d['score']:.0%})" for d in detections)
            print(f"{fps:.1f} FPS | {len(detections)} objects: {labels or 'none'}")

    except KeyboardInterrupt:
        print("Stopped.")
    finally:
        cap.release()
        print("Camera released.")

At this point we have built a notebook that works with just having object detection and we can use this with a simple camera of whatever type you have around. Now, we can wrap it up and make it into an app that we can deploy on the Reachy.


Stage 2: Deploying the TensorFlow object detection app on the Reachy Mini

The Reachy Mini app lives in the reachy_mini_object_detector/ folder and extends the detection logic with head tracking, antenna reactions, and a web dashboard. We’ve followed the guidelines for building Reachy Apps laid out in this blog post. Particularly, we can leverage a helper LLM system like Claude by giving it the predefined Agent Helper documentation.

Project structure

reachy_mini_object_detector/
├── pyproject.toml
└── reachy_mini_object_detector/
    ├── detector.py       # TF Hub model wrapper
    ├── main.py           # App: head tracking + web dashboard
    └── static/           # Web UI assets (served at :8042)

The detector.py file wraps the model and the detect_objects logic. main.py imports from it and adds everything specific to the robot.

Installing the app

From the Reachy Mini dashboard, under Apps, or by manually adding:

pip install git+https://huggingface.co/spaces/backtoengineering/reachy_mini_object_detector

How head tracking works

The app runs two loops in parallel: an inference thread that grabs frames from the robot’s camera and runs detection, and a main control loop at around 50Hz that handles head movement and antenna control.

The head tracking feature maps the detected object’s position in the frame to a yaw and pitch offset for the head. The camera has a horizontal field of view of 60 degrees and a vertical field of view of 45 degrees. When an object is at the center of the frame its center_x is 0.5, so subtracting 0.5 and multiplying by the field of view gives the angle offset to track it:

target_yaw = -(largest.center_x - 0.5) * CAMERA_FOV_H_DEG
target_pitch = (largest.center_y - 0.5) * CAMERA_FOV_V_DEG

Rather than snapping the head instantly to that target, the app uses a smoothing factor (TRACKING_ALPHA = 0.15) so the movement looks natural:

self._current_yaw += TRACKING_ALPHA * (target_yaw - self._current_yaw)
self._current_pitch += TRACKING_ALPHA * (target_pitch - self._current_pitch)

When nothing is detected, the head slowly drifts back toward center rather than freezing in place.

Antenna wiggle

The antennas wiggle when a new object class is first detected, not on every frame. The app keeps track of which classes have already been seen in _seen_classes, and when something new appears it sets a wiggle timer for 1.5 seconds. During that window, the control loop drives a sinusoidal antenna movement as follows:

phase = (t - t0) * 8.0  # fast wiggle
antenna_val = np.deg2rad(20.0 * np.sin(phase))
antennas = np.array([antenna_val, -antenna_val]

This makes the interaction feel intentional: Reachy reacts when she sees something new, rather than wiggling constantly while tracking.

The web dashboard

The app serves a live dashboard (available at http://0.0.0.0:8042) with the annotated camera feed (as an MJPEG stream), the current detection list, an FPS counter, and a toggle to enable or disable head tracking. This is useful during development because you can see exactly what the model is detecting from the robot’s perspective in real time.


Where to go next

This is a great starting point and there are a lot of directions you can take it:

You can find all the code in the Reachy-mini-object-detection repository. Everything is open-source, so feel free to build on it, adapt it, or deploy your own version.

FAQs

What is TensorFlow object detection?

TensorFlow object detection is a computer vision technique that uses machine learning models to identify and locate objects within images or video streams.

What is the best TensorFlow object detection model for real-time applications?

SSD MobileNet V2 is commonly used for real-time TensorFlow object detection because it balances inference speed and accuracy efficiently.

Can TensorFlow object detection run on CPU?

Yes. Models like SSD MobileNet V2 can run entirely on CPU, making them suitable for laptops, edge devices, and robotics projects.

What is the difference between the TensorFlow Object Detection API and TensorFlow Hub?

TensorFlow Hub provides pretrained reusable models, while the TensorFlow Object Detection API offers a larger framework for training, evaluation, and deployment workflows.

Can I train TensorFlow object detection on custom data?

Yes. You can fine-tune pretrained models using your own labelled datasets to detect custom objects.

About the author

Iulia Feroli

Iulia Feroli is the founder of the Back To Engineering community on YouTube, where she builds robots, explores physical AI, and makes complex engineering topics accessible and fun. She has a background in data science, AI, cloud architecture, and open source.

May 27, 2026 02:06 PM UTC


Real Python

Sending Emails With Python

You probably found this tutorial because you want to send emails with Python to automate confirmation messages, password resets, or scheduled notifications. Python’s standard library covers the whole pipeline, from making a server connection to building the message and sending it to one or many recipients. This tutorial walks through every step in working code.

By the end of this tutorial, you’ll understand that:

  • A safe testing setup uses a throwaway Gmail account with an app password, a local aiosmtpd debug server, or a privacy-focused provider like Posteo or Proton Mail.
  • A secure SMTP session uses .SMTP_SSL() with ssl.create_default_context(), which validates the server certificate and encrypts your credentials and message content.
  • The EmailMessage class from the email package assembles plain text, HTML alternatives, file attachments, and personalized fields through .set_content(), .add_alternative(), and .add_attachment().
  • Setting msg["reply-to"] or any other RFC 5322 header on an EmailMessage routes replies to a different mailbox than the sender address.
  • For high-volume sending, transactional email services like SendGrid, Mailgun, and Brevo provide deliverability, statistics, and API libraries that go beyond what smtplib alone offers.

Before you jump into the code, you’ll set up a throwaway email account or a local debug server so you can experiment freely without spamming real inboxes.

Get Your Code: Click here to download the free sample code you’ll use to learn how to send plain-text and HTML emails, attach files, and automate email delivery with Python.

Take the Quiz: Test your knowledge with our interactive “Sending Emails With Python” quiz. You’ll receive a score upon completion to help you track your learning progress:


Interactive Quiz

Sending Emails With Python

Use Python's standard library to send email through secure SMTP connections, attach files, include HTML content, and route replies.

Setting Up an Email Service

Email is sent from a client to an email server, and from one email server to another, using the Simple Mail Transfer Protocol, or SMTP, defined under RFC 821. Python comes with the built-in smtplib module, which implements this protocol, allowing you to programmatically send email through any accessible email server.

While you can certainly use your own email account for this tutorial, it’s recommended that you set up a throwaway email account instead. There are several free and paid email services you can use. In this tutorial, you’ll explore the following options:

  • Setting up a Gmail account for development: You’ll learn how to create a dedicated testing account and use app passwords to satisfy modern security requirements.
  • Setting up a local SMTP server: You’ll use the aiosmtpd library to run a server on your own machine, allowing you to inspect email content without sending any live messages.
  • Setting up other email accounts for development: You’ll see how to connect to alternative services like Posteo or Proton Mail to ensure your code works across different providers.

Understanding the distinction between secure (encrypted) and insecure (unencrypted) connections is vital. Most modern providers require encryption via SSL or TLS to protect your data, while the local debugging server uses no encryption. By the end of this section, you’ll know how to choose the right connection type for your specific service choice.

Setting Up a Gmail Account for Development

To set up a Gmail account for testing your code, follow these steps:

  1. Create a new Google account. You need to provide a name, a birthday, and a unique username for the account.
  2. Set up two-factor authentication for the new account.
  3. Add a new app password to allow password sign-ins to the account.

An app password is a temporary password generated by Google. Instead of using your main account password to authenticate with your username, you use the app password. You can delete and recreate app passwords whenever you like.

App passwords allow access to Gmail when modern security measures like OAuth2 aren’t available. When creating one, make sure you copy it to a secure location, as you won’t be able to review it after leaving the page.

If you don’t want to use an app password, check out Google’s documentation on how to obtain access credentials for your Python script using the OAuth2 authorization framework.

A nice feature of Gmail is that you can use the + sign to add modifiers to your email address right before the @ sign. For example, emails sent to my+person1@gmail.com and my+person2@gmail.com will both arrive at my@gmail.com. When testing email functionality, you can use this to simulate multiple addresses that all point to the same inbox.

Setting Up a Local SMTP Server

You can test email functionality by running a local Simple Mail Transfer Protocol (SMTP) debugging server with the aiosmtpd module. Rather than sending emails to a specific address, the local debug server discards the message after printing its content to the console. Running a local debugging server makes it unnecessary to deal with encryption of messages or use credentials to log in to an email server.

Note: aiosmtpd is a third-party library that replaces the former built-in smtpd module, which was initially deprecated in Python 3.4.7. Deprecation notices were repeated in 3.5.4 and 3.6.1, and the module was eventually removed in Python 3.12, as outlined in PEP 594.

Install the aiosmtpd module with the following command:

Language: Shell
$ python -m pip install aiosmtpd

Then, start a local SMTP debugging server with this command:

Language: Shell
$ python -m aiosmtpd -n

Read the full article at https://realpython.com/python-send-email/ »


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 27, 2026 02:00 PM UTC

Quiz: Sending Emails With Python

In this quiz, you’ll test your understanding of Sending Emails With Python.

By working through this quiz, you’ll revisit how to build messages with the EmailMessage class, secure your SMTP connection, attach files, send HTML alternatives, route replies to a different mailbox, and address multiple recipients at once.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 27, 2026 12:00 PM UTC


PyPy

PyPy v7.3.23 release

PyPy v7.3.23: release of python 2.7, 3.11

The PyPy team is proud to release version 7.3.23 of PyPy after the previous release on April 26, 2026. This is a bug-fix release that fixes an overeager warning about unused coroutines, and some problems around multiple inheritance in c-extensions.

This version includes a change to the bytecode interpreter to use exception tables instead of dedicated opcodes. Now the PyPy disassembly will be closer to CPython format. So far it does not impact performance.

The release includes two different interpreters:

The interpreters are based on much the same codebase, thus the double release. This is a micro release, all APIs are compatible with the other 7.3 releases.

We recommend updating. You can find links to download the releases here:

https://pypy.org/download.html

We would like to thank our donors for the continued support of the PyPy project. If PyPy is not quite good enough for your needs, we are available for direct consulting work. If PyPy is helping you out, we would love to hear about it and encourage submissions to our blog via a pull request to https://github.com/pypy/pypy.org

We would also like to thank our contributors and encourage new people to join the project. PyPy has many layers and we need help with all of them: bug fixes, PyPy and RPython documentation improvements, or general help with making RPython's JIT even better.

If you are a python library maintainer and use C-extensions, please consider making a HPy / CFFI / cppyy version of your library that would be performant on PyPy. In any case, cibuildwheel supports building wheels for PyPy.

What is PyPy?

PyPy is a Python interpreter, a drop-in replacement for CPython. It's fast (PyPy and CPython performance comparison) due to its integrated tracing JIT compiler.

We also welcome developers of other dynamic languages to see what RPython can do for them.

We provide binary builds for:

PyPy supports Windows 32-bit, Linux PPC64 big- and little-endian, Linux ARM 32 bit, RISC-V RV64IMAFD Linux, and s390x Linux but does not release binaries. Please reach out to us if you wish to sponsor binary releases for those platforms. Downstream packagers provide binary builds for debian, Fedora, conda, OpenBSD, FreeBSD, Gentoo, and more.

What else is new?

For more information about the 7.3.23 release, see the full changelog.

Please update, and continue to help us make pypy better.

Cheers, The PyPy Team

May 27, 2026 07:40 AM UTC


Python GUIs

Fixing Missing Icons in PyInstaller-Packaged PyQt6 Applications on Windows — Why your app icon disappears after packaging and how to fix it

I've packaged my PyQt application with PyInstaller, but the icon isn't showing up — both the executable icon and the running application icon are just the default Python/Windows icon. What's going on?

This is a common issue when packaging PyQt6 apps with PyInstaller on Windows. The good news is that it usually comes down to one of two straightforward causes: Windows icon caching, and missing resource files in your packaged output.

Setting the executable icon with PyInstaller

When you run PyInstaller, you can set the icon for the .exe file itself using the --icon flag:

sh
pyinstaller --windowed --icon=myicon.ico myapp.py

This embeds the icon into the executable, so it shows up in File Explorer and on the desktop. The icon file needs to be in .ico format — .png or .svg won't work here.

After building, check the dist/ folder. Your .exe should display the custom icon. But sometimes... it doesn't.

Windows icon caching

Windows caches icons aggressively. If you've previously built your app without a custom icon, Windows may continue to show the old default icon even after you've rebuilt the app with the correct one.

This still catches me out, even though I know this. You'll reflexively start checking the config assuming something is wrong, and think you're going mad.

There are a few ways to deal with this:

python
ie4uinit.exe -show

After clearing the cache, the correct icon should appear.

You can also try turning your computer off and on again, or rather restarting Windows. That will also trigger the icon cache to rebuild.

Missing icon file at runtime

Setting the executable icon with --icon only affects what shows up in File Explorer. If your application also sets a window icon in code (using setWindowIcon), that icon file needs to be available at runtime too.

For example, if your code does this:

python
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtGui import QIcon
import sys


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("My Application")


app = QApplication(sys.argv)
app.setWindowIcon(QIcon("myicon.ico"))

window = MainWindow()
window.show()

app.exec()

Then myicon.ico needs to exist in the working directory when the packaged app runs. By default, PyInstaller doesn't include data files like .ico images unless you tell it to.

You can add the icon file to your build using the --add-data flag:

sh
pyinstaller --windowed --icon=myicon.ico --add-data "myicon.ico;." myapp.py

On Linux or macOS, use : instead of ; as the separator:

sh
pyinstaller --windowed --icon=myicon.ico --add-data "myicon.ico:." myapp.py

This copies myicon.ico into the output directory alongside your executable (or into the temporary directory if you're using --onefile).

An alternative approach (not available on PyQt6) is to use the Qt Resource System to embed your icon directly into your application, which avoids the need to bundle separate icon files entirely.

Handling --onefile builds

When you use --onefile, PyInstaller extracts everything to a temporary folder at runtime. Your code needs to know how to find files relative to that temporary folder. You can handle this by detecting the base path:

python
import sys
import os

if getattr(sys, 'frozen', False):
    # Running as a PyInstaller bundle
    basedir = sys._MEIPASS
else:
    # Running as a normal script
    basedir = os.path.dirname(__file__)

Then use basedir when constructing file paths:

python
app.setWindowIcon(QIcon(os.path.join(basedir, "myicon.ico")))

Taskbar grouping with an Application User Model ID

On Windows, the taskbar groups windows by their application identity. Without an explicit identity, Windows guesses — and sometimes guesses wrong. This can cause your app to show the Python icon in the taskbar, or to group instances inconsistently depending on where they were launched from.

You can fix this by setting an Application User Model ID before creating your QApplication. This tells Windows exactly which application this is:

python
import ctypes

myappid = "com.mycompany.myapp.1.0"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)

The string can be anything, but it's conventional to use a reverse-domain format. The value just needs to be unique to your application.

With an explicit app ID set, all instances of your app will group together in the taskbar regardless of where they were launched from — whether that's your IDE, the dist/ folder, or a --onefile build.

Complete working example

Here's a complete example that handles all of the above — the runtime base path, the window icon, and the application user model ID. If you're new to building PyQt6 applications, you may want to start with creating your first window before tackling packaging.

python
import sys
import os
import ctypes

from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import Qt


# Set the app user model ID before creating QApplication (Windows only)
if sys.platform == "win32":
    myappid = "com.mycompany.myapp.1.0"
    ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)

# Determine the base directory for resource files
if getattr(sys, "frozen", False):
    basedir = sys._MEIPASS
else:
    basedir = os.path.dirname(__file__)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("My Application")
        label = QLabel("Hello, world!")
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.setCentralWidget(label)


app = QApplication(sys.argv)
app.setWindowIcon(QIcon(os.path.join(basedir, "myicon.ico")))

window = MainWindow()
window.show()

app.exec()

To package this with PyInstaller:

sh
pyinstaller --windowed --icon=myicon.ico --add-data "myicon.ico;." myapp.py

For an in-depth guide to building Python GUIs with PySide6 see my book, Create GUI Applications with Python & Qt6.

May 27, 2026 06:00 AM UTC


Python Morsels

Selecting random values in Python

Python's random module provides utilities for generating pseudorandom numbers. For cryptographically-secure randomness, use the secrets module instead.

Table of contents

  1. Generating random integers
  2. Generating random floating point numbers
  3. Selecting random items from a sequence
  4. The random utilities are only pseudorandom
  5. Cryptographically-secure randomness with the secrets module
  6. Random and SystemRandom classes
  7. Use random for pseudo-random numbers and secrets for true randomness

Generating random integers

If you need a random integer, you can use the randint function from Python's random module:

>>> from random import randint
>>> randint(1, 6)
4

This function accepts a start value and a stop value and it returns a random integer between the start and stop values inclusively.

The random module also includes a randrange function, which is named after Python's range function:

>>> from random import randrange
>>> randrange(10)
7

This function accepts the same values as range.

Either a stop value:

>>> randrange(5)
2

Or start and stop values:

>>> randrange(5, 10)
8

Or start, stop, and step values:

>>> randrange(0, 100, 10)
70

The randrange function basically chooses a random number within a given range.

When I need a random number, I usually use randint.

Generating random floating point numbers

What if you need a …

Read the full article: https://www.pythonmorsels.com/random-numbers/

May 27, 2026 04:00 AM UTC

May 26, 2026


PyCoder’s Weekly

Issue #736: Polars Sort-Merge Joins, Zen, Resolving Lazy Imports, and More (2026-05-26)

#736 – MAY 26, 2026
View in Browser »

The PyCoder’s Weekly Logo


Streaming Sort-Merge Joins in Polars

“Joins are often one of the most expensive parts of a query. Once tables get large, the join can heavily impact both runtime and memory usage… If the join keys are already sorted, Polars can now take a cheaper path: a streaming sort-merge join.”
THIJS NIEUWDORP

Tapping Into the Zen of Python

Explore the Zen of Python and its 19 guiding principles for writing readable, practical code. Learn its history, jokes, and meaning.
REAL PYTHON course

Quiz: Tapping Into the Zen of Python

REAL PYTHON

FREE Python Error Tracking From Honeybadger – all Signal, no Noise

alt

Production bugs don’t arrive one at a time. Honeybadger groups similar errors into a single issue and lets you pause or ignore alerts in a single click. More signal. Less noise. ⚡ Sign Up for Your FREE Developer Account →
HONEYBADGER sponsor

Resolve a Lazy Import Manually

Learn how to work around the Python 3.15 machinery to resolve an explicit lazy import manually.
RODRIGO GIRÃO SERRÃO

Django 6.1 Alpha 1 Released

Posted by Jacob Walls on May 20, 2026
DJANGO SOFTWARE FOUNDATION

Nuitka Python Compiler Release 4.1

NUITKA.NET

Call for Onsite Volunteers: Make EuroPython 2026 Happen

EUROPYTHON.EU

PEP 831: Frame Pointers Everywhere: Enabling System-Level Observability for Python (Final)

This PEP proposes two things:
PYTHON.ORG

PEP 808: Including Static Values in Dynamic Metadata (Accepted)

PYTHON.ORG

Articles & Tutorials

PyCon US 2026 Packaging Summit Recap

Per-talk notes from the PyCon US 2026 Packaging Summit, including: Emma Smith on Wheel 2.0 and Zstandard compression, Mike Fiedler on PyPI abuse vectors, Mahe Iram Khan on ecosystems, lightning talks on PEP 772, mobile wheels, AI accelerator variants, and the roundtable discussions.
BERNÁT GÁBOR

Slim Down Python Docker Containers

Learn how SlimToolkit can reduce a Python Docker image by analyzing what your app actually uses at runtime. This tutorial walks through slimming a Chainlit LLM chatbot image, shows where container bloat comes from, and explains how to avoid breaking lazily loaded Python frameworks.
CODECUT.AI • Shared by Khuyen Tran

Object-Oriented Python: 5-Day Live Workshop, June 8 to 12

A new live cohort for Python developers comfortable with the basics who want to design classes that hold up under change. Across five 2-hour sessions, OOP features appear at the moment a growing project actually needs them. You leave with a working app and the judgment to know when a class earns its keep →
REAL PYTHON sponsor

What Types of Exceptions Should You Catch?

The trickiest programming bugs are often caused by catching exceptions that you didn’t mean to catch or handling exceptions in ways that obfuscate the actual error that’s occurring. Which exceptions should you catch and which should you leave unhandled?
TREY HUNNER

Reverse Geocoding With Overture Maps

Mark is working on a reverse geocoder that can fetch the 2-letter ISO country code for any point on a map in a country’s boundaries. This post talks about the prototype and his progress on the project.
MARK LITWINTSCHIK

Stop Writing Edge Case Tests. Use Hypothesis Instead

An introduction to property-based testing in Python with Hypothesis: the mental shift from ‘what input should I test?’ to ‘what invariant should always hold?’
PEYTON GREEN • Shared by Anonymous

Opaque Types in Python

Learn how to use the NewType to mask a private class while still providing a public construction mechanism for the users of your library.
GLYPH LEFKOWITZ

How to Use the Claude API in Python

Learn how to use the Claude API in Python to send prompts, control responses with system instructions, and get structured JSON output.
REAL PYTHON

Quiz: How to Use the Claude API in Python

REAL PYTHON

uv Is Fantastic, but Its Package UX Is a Mess

This opinion piece talks about how uv’s CLI feels surprisingly clunky compared to its peers like pnpm or Poetry.
KEVIN RENSKERS

Python Built-in Functions: A Complete Guide

Use Python’s built-in functions for math, data types, iterables, and I/O to write shorter, more Pythonic code.
REAL PYTHON

Projects & Code

flake8-lazy: Detect Lazy-Importable Modules in Python 3.15+

GITHUB.COM/HENRYIII

django-arch-check: Static Checker for Common Django Issues

GITHUB.COM/RJ-GAMER

tdb: A Python Debugger Based on Textual

GITHUB.COM/ALDANIAL

postman2pytest: Convert Postman Collection Into pytest Suite

GITHUB.COM/GOLIKOVICHEV

agent-memory-guard: OWASP ASI06 AI Agent Memory Guard

GITHUB.COM/OWASP • Shared by Vaishnavi Gudur

Events

PyCon Italia 2026

May 27 to May 31, 2026
PYCON.IT

Weekly Real Python Office Hours Q&A (Virtual)

May 27, 2026
REALPYTHON.COM

PyLadies Amsterdam: Scalable Data Harvesting for AI

May 28, 2026
MEETUP.COM

Python Leiden User Group

May 28, 2026
PYTHONLEIDEN.NL

PyDelhi User Group Meetup

May 30, 2026
MEETUP.COM

PyLadies El Alto: Flash Talks

May 30 to May 31, 2026
MEETUP.COM


Happy Pythoning!
This was PyCoder’s Weekly Issue #736.
View in Browser »

alt

[ Subscribe to 🐍 PyCoder’s Weekly 💌 – Get the best Python news, articles, and tutorials delivered to your inbox once a week >> Click here to learn more ]

May 26, 2026 07:30 PM UTC


Real Python

Connecting LLMs to Your Data With Python MCP Servers

The Model Context Protocol (MCP) is a new open protocol that allows AI models to interact with external systems in a standardized, extensible way. In this video course, you’ll install MCP, explore its client-server architecture, and work with its core concepts: prompts, resources, and tools. You’ll then build and test a Python MCP server that queries e-commerce data and integrate it with an AI agent in Cursor to see real tool calls in action.

By the end of this video course, you’ll understand:

You’ll get hands-on experience with Python MCP by creating and testing MCP servers and connecting your MCP to AI tools. To keep the focus on learning MCP rather than building a complex project, you’ll build a simple MCP server that interacts with a simulated e-commerce database. You’ll also use Cursor’s MCP client, which saves you from having to implement your own.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 02:00 PM UTC

Quiz: Visualizing Data in Python With Seaborn

In this quiz, you’ll test your understanding of Visualizing Data in Python With Seaborn.

By working through this quiz, you’ll revisit how seaborn produces polished statistical plots, including bar plots, scatter plots, line plots, histograms, and KDE curves.

You’ll also reinforce the differences between seaborn’s classic functional interface and its newer objects interface, and you’ll see when to reach for figure-level versus axes-level functions.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 12:00 PM UTC

Quiz: Exceptions, Logging, and Debugging

In this quiz, you’ll revisit the core concepts covered in the Exceptions, Logging, and Debugging learning path:

A person sitting in front of a computer, reading logs from a program, with an alarm light going off that indicates that an issue has been logged

Learning Path

Exceptions, Logging, and Debugging

8 Resources ⋅ Skills: Python, Exceptions, Logging, Debugging, pdb, raise, Built-in Exceptions, Error Handling

You’ll be tested on the basics of Python exceptions, raising and reraising errors with the raise keyword, working with built-in exception classes like ValueError and KeyError, and configuring the logging module to track what your code does at runtime.

Take your time and revisit any topics that feel rusty before moving on.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 12:00 PM UTC

Quiz: Object-Oriented Programming (OOP) in Python

In this quiz, you’ll revisit the core concepts covered in the Object-Oriented Programming (OOP) in Python learning path:

A person with a notebook under their arm, walking towards a technical machine that has concepts of object-oriented programming noted on its parts

Learning Path

Object-Oriented Programming (OOP)

18 Resources ⋅ Skills: Python, OOP, Classes, Data Classes, Getters, Setters, Property, super(), Magic Methods, Operator Overloading, SOLID, Inheritance, Composition, Mixin Classes, Factory Pattern

You’ll test what you know about defining classes, working with instance and class attributes, controlling object instantiation with constructors, and leveraging inheritance, composition, and mixins. You’ll also check your understanding of properties, magic methods, and the SOLID design principles that lead to cleaner object-oriented code.

Take your time and revisit any topics that feel rusty before moving on.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 12:00 PM UTC

Quiz: Python Data Structures

In this quiz, you’ll revisit the core concepts covered in the Python Data Structures learning path:

A person pointing on a screen that transforms a digital data structure representation into a real-life one based on square objects

Learning Path

Python Data Structures

24 Resources ⋅ Skills: Python, Strings, Lists, Tuples, Dictionaries, Sets, List Comprehensions, range(), Bytes, Sorting

The 20 questions span strings, lists, tuples, dictionaries, sets, sorting, and bytes, giving you a way to check that you understood the most important ideas.

Take your time and revisit any topics that feel rusty before moving on to the next learning path.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 12:00 PM UTC

Quiz: Connecting LLMs to Your Data With Python MCP Servers

In this video course quiz, you’ll test your understanding of Connecting LLMs to Your Data With Python MCP Servers.

By working through this quiz, you’ll revisit core MCP concepts like the client-server architecture, tools that LLMs can call, resources that expose static data, and prompts that act as reusable templates.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 12:00 PM UTC

Quiz: Testing and Continuous Integration

In this quiz, you’ll revisit the core concepts covered in the Testing and Continuous Integration learning path:

A person in a white coat with big glasses holding a computer next to a machine that shows big signs reading fail and pass

Learning Path

Testing and Continuous Integration

10 Resources ⋅ Skills: Unit Testing, Doctest, Mock Object Library, Pytest, Continuous Integration, Docker, Code Quality, GitHub Actions, Software Testing, CI/CD

The 20 questions span testing fundamentals, the unittest framework, mock objects, pytest, code quality tools, and continuous integration with GitHub Actions. They give you a way to check that you understood the most important ideas.

Take your time and revisit any topics that feel rusty before moving on.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 12:00 PM UTC

Quiz: Files and File Streams

In this quiz, you’ll revisit the core concepts covered in the Files and File Streams learning path:

Two people working together, one is inputting data on a computer, the other one is reading a long printout

Learning Path

Files and File Streams

13 Resources ⋅ Skills: Python, Pathlib, File I/O, Serialization, Encoding, Unicode, PDF, WAV, Context Managers, ZIP Files

You’ll check your understanding of opening and reading files, navigating the file system with pathlib, managing resources with context managers and the with statement, and reading or writing WAV audio files.

Take your time and revisit any topics that feel rusty before moving on.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 26, 2026 12:00 PM UTC


Graham Dumpleton

WSGISwitchInterval in mod_wsgi 6.0.0

The first two posts in this series covered new directives in mod_wsgi 6.0.0 that change the concurrency model the interpreter runs under. WSGIPerInterpreterGIL opts a sub-interpreter into its own GIL. WSGIFreeThreading opts a process into PEP 703 free-threaded mode. This third directive, WSGISwitchInterval, is a different sort of thing. It does not change the concurrency model. It exposes a Python tuning knob that has existed since Python 3.2 and that almost nobody touches, but that I have come to think is worth touching for a meaningful class of WSGI workloads.

The post is partly about what the directive does. Mostly though it is about a measurement story, and about why having telemetry to drive tuning decisions matters more than the directive itself.

What the switch interval is

The Python GIL is the lock that serialises bytecode execution across threads in a CPython process. Only one thread at a time holds it. For other threads to make progress on Python code, the holder has to release the lock. Some releases are voluntary, for instance during I/O calls that drop the GIL while they wait. Voluntary releases are not enough on their own to schedule cleanly between several CPU-busy threads though, so the interpreter also has a scheduler that nudges the holder to give the lock up periodically. That scheduler is what the switch interval controls.

In CPython 2 the scheduler was bytecode-count based. After every N bytecodes the interpreter would check for pending signals, drop the lock, and reacquire it. The setting was sys.setcheckinterval(N), default 100 ticks. The problem with bytecode counting was that bytecodes are not equal-cost. Some operations completed in a fraction of a microsecond. Others, like calling out into a slow built-in, took milliseconds. So the actual wall-clock interval between handoffs varied widely depending on what code was running.

Python 3.2 replaced this with a time-based scheduler. Antoine Pitrou's new GIL implementation moved the handoff trigger from "after N bytecodes" to "after T seconds since the last release", controlled by sys.setswitchinterval() with a default of 5 milliseconds. That default was a reasonable compromise on the hardware that existed in 2010. It has not changed since. Fifteen years on, on hardware that runs Python several times faster per cycle, the same 5 ms can be a much larger amount of Python work than it used to be. That is the rationale for considering whether the default is still the right value for your workload.

What WSGISwitchInterval does

The directive calls sys.setswitchinterval() after interpreter initialisation, so the setting takes effect for the rest of that interpreter's life. The simplest form is at server scope.

WSGISwitchInterval 0.002

This applies to the embedded mode interpreter in Apache child processes. For daemon mode the equivalent is the switch-interval= option on WSGIDaemonProcess.

WSGIDaemonProcess my-app processes=2 threads=5 switch-interval=0.002

The directive can also appear inside the <WSGIInterpreterOptions> container introduced in the per-interpreter GIL post. If the matched sub-interpreter has its own GIL via WSGIPerInterpreterGIL, you can tune that one sub-interpreter's switch interval separately from the others in the same process.

<WSGIInterpreterOptions process-group="my-app" application-group="cpu-heavy">
    WSGIPerInterpreterGIL On
    WSGISwitchInterval 0.001
</WSGIInterpreterOptions>

Without an own-GIL on the matched sub-interpreter the directive cannot be made per-sub-interpreter, because the GIL is shared across the process and tuning it for one sub-interpreter would silently affect all of them. mod_wsgi rejects that configuration with a warning rather than silently scoping wider than the operator asked for.

Under free-threading the directive is a no-op. There is no GIL to schedule.

The default is to leave Python's own default alone. You opt in to tune.

You cannot tune what you cannot measure

The case for adjusting the switch interval rests on being able to see what happens when you change it. Python itself does not expose any direct measure of GIL contention. There is no counter you can read to ask "how much time was spent waiting for the GIL". The interpreter knows in some sense, but it does not surface the information.

mod_wsgi exposes a partial measure, surfaced as gil_wait_time. It is the time the worker thread was held up acquiring the GIL at points where mod_wsgi is doing work on the application's behalf: request dispatch, request body reads, response writes, logging. It does not see contention while the application's own Python code is running, and it cannot see contention inside C extensions that release and reacquire the GIL on their own schedule. So the value is a lower bound, not an absolute measure of contention.

That is enough to drive tuning decisions, though. The metric moves directionally with actual contention. Combined with throughput and response time, three numbers from the same telemetry stream, it is enough to tell you whether a switch interval change helped or hurt.

The rest of the post is a worked example that uses exactly those three signals.

A benchmark to make the case

The workload is a synthetic WSGI handler. Each request spends approximately 3 ms running Python code on the CPU, plus a 1 ms simulated wait standing in for a small bit of I/O, and returns a 1 KB response body. The load generator drives concurrency 10, more than enough to saturate the available workers in every configuration shown below. The workload is deliberately idealised, with no real I/O and no C extension calls, because the point is to surface the effect of GIL scheduling on pure-Python compute as clearly as possible.

All four configurations below run on the same host, same Apache, same Python, same WSGI handler. Only the process and thread counts and the switch interval change. Each step includes a small table of the key metrics so the numbers are legible even if the dashboard screenshots are too small to read, and the table grows as we go so each configuration can be compared with the ones before.

Baseline: ten processes, one thread each

This is the no-contention reference point. Each daemon process has a single worker thread, so no two threads compete for the same GIL. Whatever GIL pressure shows up here is whatever overhead the lock adds on the dispatch and I/O paths in mod_wsgi itself, with no waiting.

WSGIDaemonProcess my-app processes=10 threads=1

The result is 134k requests per minute, 4 ms mean response time, gil_wait_time effectively zero. The GIL wait time distribution is a single bar in the head bucket, which is what no-contention looks like.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none

Baseline overview dashboard, ten processes with one thread each, showing 134k rpm and 4 ms mean response time.

This is the upper bound for what the workload can do on the available cores when nothing contends with anything. Roughly 13.4k rpm per process.

Add threads: GIL contention takes over

Keep the total worker pool roughly comparable, but reshape it: two processes with five threads each. Same default 5 ms switch interval.

WSGIDaemonProcess my-app processes=2 threads=5

Throughput collapses to 37k requests per minute, about 28% of the baseline. Mean response time goes from 4 ms to 16 ms. Application time mean is now 11 ms, up from 4 ms in the baseline. Each process is now CPU/GIL-bound: five threads competing for one GIL inside the process, with cores sitting underutilised because only one thread can run Python at a time.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none
2 × 5, 5 ms 37k 16 ms 11 ms 13 ms

Overview dashboard with two processes of five threads each at the default 5 ms switch interval, showing 37k rpm and a 16 ms response time.

The shape of the contention is most visible in the GIL wait time distribution chart.

GIL wait time distribution at 5 ms switch interval, showing a head bucket plus a series of bumps at multiples of the switch interval.

The chart tells a clear story. There is a head bucket holding the requests that got their handoff immediately, then a series of bumps further out at multiples of the 5 ms switch interval. Each bump corresponds to a request that had to wait one or more switch intervals to acquire the GIL: one missed cycle, two missed cycles, three missed cycles, and so on. The bumps shrink as you move right, which is the shape of a contention pattern where missed cycles do not pile up too heavily. But the tail is fat. The percentile numbers along the top of the chart confirm this: p95 is 13 ms and p99 is 18 ms, meaning a meaningful fraction of requests are waiting several full switch intervals to make progress on Python code.

This is the textbook case for the CPU/GIL-bound label. With five threads competing for one GIL on each process, the GIL is the wall. The standard remediation is to add processes. The point of this post is that there is a second lever, which is to make each handoff cheaper rather than less frequent.

Tighten the switch interval to 2 ms

Same process and thread shape, but cut the switch interval from 5 ms to 2 ms.

WSGIDaemonProcess my-app processes=2 threads=5 switch-interval=0.002

Throughput moves from 37k to 42k requests per minute, about 13% better. Mean response time drops from 16 ms to 14 ms. The GIL wait time distribution chart is where the more interesting change shows up.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none
2 × 5, 5 ms 37k 16 ms 11 ms 13 ms
2 × 5, 2 ms 42k 14 ms 13 ms 6 ms

GIL wait time distribution at 2 ms switch interval, showing bumps that are shorter and closer together than at 5 ms.

The chart is dramatically more head-heavy than at 5 ms. The head bucket now holds the bulk of the requests, where at 5 ms it was only about a fifth of them. Most requests are getting the GIL on their first try at the new interval. The smaller bumps further out are still there, but they sit closer to the head than their counterparts at 5 ms did, because each cycle is now 2 ms wide instead of 5 ms wide. The percentile numbers in the chart header confirm what the shape is showing: p50 has dropped from 5 ms to under 1 ms, p95 from 13 ms to 6 ms, p99 from 18 ms to 10 ms. Contention is both less frequent and cheaper when it does happen, and the throughput gain on the dashboard follows from that.

A reasonable stopping point for tuning the GIL switch interval on a mixed workload is around 2 ms. The reasoning is that more frequent GIL handoffs means more context switching, and at very short intervals that overhead can start to dominate. So if you do not have telemetry that lets you see the effect on your specific workload, 2 ms is a sensible place to stop. Going lower than that is something to do only when you can measure the result and confirm that the gain is real. The benchmark workload here is not a mixed workload, and the rest of this post is the measurement story that earns the right to go further.

Tighten further to 0.1 ms

Same shape again, but switch interval down to 0.1 ms.

WSGIDaemonProcess my-app processes=2 threads=5 switch-interval=0.0001

Throughput jumps to 121k requests per minute. That is within roughly 10% of the no-contention baseline of 134k. Mean response time is back to 5 ms. Application time mean is back down to around 4.7 ms, close to its baseline value of 4.3 ms.

Config rpm response app GIL p95
10 × 1, 5 ms (baseline) 134k 4 ms 4 ms none
2 × 5, 5 ms 37k 16 ms 11 ms 13 ms
2 × 5, 2 ms 42k 14 ms 13 ms 6 ms
2 × 5, 0.1 ms 121k 5 ms 5 ms 1 ms

Overview dashboard at 0.1 ms switch interval, showing 121k rpm and 5 ms mean response time, near the no-contention baseline.

The GIL wait time distribution collapses back to essentially the head bucket.

GIL wait time distribution at 0.1 ms switch interval, with the bumps gone and the head bucket holding nearly all samples.

The bumps are gone. p95 is 1.2 ms and p99 is 1.2 ms, which is essentially "everything fits in the first bucket of the histogram". What is going on at this setting is that the switch interval is now much shorter than the per-request CPU cost. Each handoff happens many times during a single request's CPU work, so threads are interleaving at fine granularity rather than passing the GIL around in big chunks. There is no missed-cycle structure left for waiters to pile up on. Handoffs are continuous rather than periodic.

The workload is still CPU/GIL-bound in shape. Threads still spend most of their wall time holding a request without consuming CPU on it directly, because at any given instant only one thread per process can run Python. That structural fact has not changed. But the measured throughput cost of that shape has nearly vanished. The new switch interval has just made the cost of being that workload small enough not to hurt.

What this means

The default 5 ms switch interval is conservative for a pure-Python CPU-bound workload. For workloads of that shape the knob is real, and the gain can be substantial. Three observations follow from that, all of them important.

Most WSGI applications do not look like this benchmark. The typical web request spends most of its time in I/O, in database calls, in C extensions like JSON parsers or template engines, in HTTP client libraries. All of those release the GIL during their slow phase. For those workloads the default is probably fine, and tuning the switch interval will not move much.

Stopping at around 2 ms is a sensible default for a mixed workload. It is not the answer for every workload, though. If you have endpoints that are heavy on Python compute, data processing endpoints, ML preprocessing, anything that does meaningful work in pure Python before returning, those endpoints may be in the same regime as this benchmark, and the same lever can apply. The further down you go past 2 ms the more important it is to have the telemetry that confirms you are actually winning rather than guessing.

The way you find out is by measuring. Throughput, response time, and gil_wait_time on the same telemetry stream, with the switch interval as the only variable, is enough to tell you whether tuning helps for your workload.

Caveats

More frequent GIL handoffs mean more context switching. There is a cost to that, and at some interval that cost begins to dominate the gain. The benchmark workload here does not show that cost emerging at 0.1 ms, but that is partly because the workload is idealised. With real concurrency patterns and real I/O it would emerge sooner.

Tuning the switch interval down does not fix GIL contention inside C extensions that manage their own GIL acquire and release. If your contention lives inside NumPy or a database driver, this knob does not reach it.

The right framing is that this is a tuning lever for a specific class of workload, not a default to flip across the board. Use it where the measurements say it helps. Leave it alone where they say it does not.

What's next

If you run mod_wsgi and the case above is interesting for your workload, please install the 6.0.0 release candidate, try WSGISwitchInterval against your real traffic, and file issues against the GitHub project for anything that does not behave the way the documentation suggests it should.

This post has leaned heavily on telemetry from mod_wsgi-telemetry, the companion tool that records and visualises the metrics shown in the screenshots above. That tool is going to be the subject of a follow-up series. Before we get to that though, the next post will revisit the free-threading configuration from earlier in this series and look at how performance under it manifests through the same request metrics used here. The argument for tuning at all rests on having that visibility, and the screenshots here are what the tool surfaces out of the box.

For reference:

May 26, 2026 10:38 AM UTC

Free-threading vs the GIL in mod_wsgi 6.0.0

The previous post in this series walked through tuning WSGISwitchInterval to claw back throughput on a multi-threaded mod_wsgi daemon group whose workload is CPU-bound Python. Tightening the switch interval recovered most of the throughput a two-process, five-thread shape had lost compared with the ten-process, one-thread baseline. What it did not change was per-process CPU usage. Each process stayed pinned at about one core regardless of how the switch interval was tuned, because that ceiling is the GIL itself.

This post is about what happens when that ceiling is removed. PEP 703 free-threading provides a CPython build with no GIL at all, and mod_wsgi 6.0.0 exposes the opt-in for it through WSGIFreeThreading, the second directive covered in this series. I have rerun the same benchmark workload on a free-threaded Python with that directive on. The interesting metric is now CPU usage per process.

Why CPU usage is the new focus

Throughput and response time were the headline metrics for tracking the effect of switch interval changes. Both are still relevant here. But the comparison turns on something different now. With the GIL, threads in a process serialise on the lock, and the process consumes at most one core regardless of how many threads you give it. With free-threading there is no GIL, and the process can use as many cores as its threads can actually fill. If the workload is CPU-bound Python, CPU usage per process is what tells you whether the runtime is making real use of the hardware.

What disappears from the toolkit

GIL wait time was the central diagnostic in the switch-interval post. Under free-threading there is no GIL, so there is no GIL wait to measure. The histogram that showed the convoy bumps at multiples of the switch interval in the previous post goes flat by definition. What replaces it as positive evidence that the workload is genuinely parallel is the CPU usage number itself. Multiple cores per process is the new signal.

A reminder of what free-threading asks of you

The free-threading post earlier in this series went into detail on what free-threading actually requires. Briefly: it is a separate CPython build (typically named python3.14t on systems that distribute it), C extensions must declare Py_mod_gil = Py_MOD_GIL_NOT_USED or the runtime quietly re-enables the GIL for the whole process, and application code must handle concurrent execution correctly rather than being incidentally safe under GIL atomicity. None of that has changed since I wrote that post. The metrics below assume those prerequisites are satisfied and show what the upside looks like when they are.

The benchmark setup

The workload is the same as in the switch-interval post. Each request spends approximately 3 ms running Python code on the CPU, plus a 1 ms simulated wait, and returns a 1 KB response body. Concurrency 10, same host, same Apache, same WSGI handler. The only changes are that Python is the free-threaded build, mod_wsgi has WSGIFreeThreading On configured on the daemon group, and two configurations are exercised: two processes with five threads each (matching the comparison from the previous post), and one process with ten threads (a configuration that has no point under the GIL but lights up under free-threading).

Comparison: two processes, five threads each

WSGIDaemonProcess my-app processes=2 threads=5
WSGIFreeThreading On
Config rpm response CPU/proc CPU total GIL p95
10 × 1 GIL (baseline) 134k 4 ms 0.66 cores 6.6 cores none
2 × 5 GIL, default 5 ms 37k 16 ms 0.95 cores 1.9 cores 13 ms
2 × 5 GIL, 0.1 ms tuned 121k 5 ms 0.90 cores 1.8 cores 1 ms
2 × 5 free-threading 131k 4 ms 3.3 cores 6.6 cores n/a

Overview dashboard with two free-threaded processes of five threads each, showing 131k rpm and 4 ms mean response time.

Throughput jumps to 131k rpm, almost matching the ten-process baseline of 134k, and well past what 0.1 ms switch interval tuning could achieve. Response time is back to 4 ms.

The row that has changed in character is CPU per process. Under the GIL each process was pinned at about one core no matter what we did with the switch interval. Free-threading lifts that ceiling, and each process is now consuming about 3.3 cores. Total CPU is 6.6 cores, the same as the ten-process baseline, but with one fifth the processes.

The GIL p95 column has no value to report any more. The histogram that showed contention bumps for missed switch-interval cycles is now flat. There is no GIL to schedule and no wait to measure.

Comparison: one process, ten threads

WSGIDaemonProcess my-app processes=1 threads=10
WSGIFreeThreading On

Under the GIL this configuration would not really make sense. The threads would all queue for the one GIL, the process would cap at about one core, and throughput would likely be cut by half or more compared with the ten-process baseline. The exact figure depends on the workload and how the switch interval is set, but the shape is clear: one process with ten threads on a CPU-bound workload is not a configuration the GIL rewards.

Under free-threading the picture is dramatically different.

Config rpm response CPU/proc CPU total GIL p95
10 × 1 GIL (baseline) 134k 4 ms 0.66 cores 6.6 cores none
2 × 5 GIL, default 5 ms 37k 16 ms 0.95 cores 1.9 cores 13 ms
2 × 5 GIL, 0.1 ms tuned 121k 5 ms 0.90 cores 1.8 cores 1 ms
2 × 5 free-threading 131k 4 ms 3.3 cores 6.6 cores n/a
1 × 10 free-threading 134k 4 ms 6.65 cores 6.65 cores n/a

Overview dashboard with one free-threaded process of ten threads, showing 134k rpm and 6.65 cores of CPU usage in a single process.

Throughput matches the ten-process baseline at 134k rpm. Response time is 4 ms. The single process is consuming about 6.65 cores. That is the headline finding of the comparison. The ten processes of the baseline have collapsed into one process that genuinely uses about 6.65 of the available cores.

A note on the ceiling

Both the ten-process baseline and the one-process free-threading run land at the same total CPU usage of around 6.6 cores. That is not an artefact of the configurations meeting in the middle. It is the ceiling of the machine the tests are running on. The load generator is also running on the same host and is consuming some of the available CPU envelope itself. So the 134k rpm number is the ceiling of this machine under this workload, not a fundamental ceiling of either configuration. On a more capable host, or with the load generator run from a separate machine, both configurations could likely scale further. The point being made in the comparison is the shape of CPU usage across configurations, not the absolute throughput number.

What this means in practice

Free-threading is another lever in the mod_wsgi concurrency toolkit. The free-threading post earlier in this series introduced it. This post shows what it does when applied to a workload that fits.

A few operational implications follow from the numbers above.

Memory. Fewer processes means less duplicated interpreter state, fewer copies of the application code in memory, fewer per-process caches. The ten-process baseline reported around 200 MB total RSS. The one-process free-threaded run reported around 31 MB. That is a real saving for memory-constrained deployments, and it is largely independent of whether the throughput is fully utilising the hardware.

Topology. One daemon group with a thread pool is simpler to operate than ten separate processes. Fewer file descriptors, fewer accept queues, one unit to restart and reload, easier capacity reasoning.

Trade-off. Process-level isolation is less granular. A crash in a thread on a single-process pool takes the whole pool with it, where on a multi-process pool it would only take one worker. For many workloads that is a fair trade, especially if the application itself does not crash frequently in production. For others, keeping at least a handful of processes around still makes sense. Free-threading composes happily with that, and the 2 × 5 configuration above is exactly that intermediate point.

Caveats

The constraints from the free-threading post all still apply. Free-threaded CPython is a separate build and not the one most distributions ship as default. C extensions need to declare free-threading support or the GIL silently comes back on for the whole process, undoing the benefit. Application code needs to be genuinely thread-safe rather than incidentally OK because the GIL was doing the work. The free-threaded build also carries a small single-threaded overhead.

The case for adopting free-threading still rests on those prerequisites being met for your specific application. The metrics here just show what the lever does when they are.

What's next

If you run mod_wsgi and the case made above is interesting for your application, please install the 6.0.0 release candidate against a free-threaded Python build, try WSGIFreeThreading against your real workload, and file issues against the GitHub project for anything that does not behave as the documentation says it should.

This concludes the directive tour of the new concurrency-related additions in mod_wsgi 6.0.0. I will look more closely at mod_wsgi-telemetry itself, the tool that has been quietly doing the work in every screenshot and table in this series, in some future posts.

For reference:

May 26, 2026 07:00 AM UTC


Bob Belderbos

From Python Script to Production: A Django Coaching Case Study

Six weeks of 1:1 coaching. The output: a Django app in production on Fly.io, covering movies, anime, and manga, with user accounts, a save library, Docker, and CI/CD on every push. Daniele started with Python skills and a project idea. Here's what the work actually looked like.

Daniele

The starting point

The idea was a platform to discover and track movies, anime, and manga. He had enough Python to start, already building a CLI tool with a swappable data layer in our first app together. What he didn't have was experience building a web app: the real mechanics of Django, how the pieces connect, what "ready to ship" means in practice.

Self-study can get you to a prototype. It won't tell you when code that works is teaching you the wrong habits. That's what weekly PR reviews are for.

Starting with discipline, not speed

The first PR was a Python script. It called the TMDB API, parsed the results, and displayed them. Functional and already a place to build habits.

For example: the type hint on _fetch_tmdb_data said -> dict, but the function could return None. Fix the type hint. The constants TMDB_URL and headers weren't uppercased consistently. Follow PEP8 conventions. The API key loaded from an .env file, but there was no .env-template telling other developers which variables to set. Add the template.

None of these changes affect whether the script runs. All of them affect whether another developer, or Daniele himself in six months, can reason about it. That's where professional developer habits form.

Django's machinery is yours to understand

Moving from a script to Django means the framework does a lot for you. The risk is accepting what it does for you without understanding its deeper workings.

In week 2, Daniele ran uv run ty check . and got two errors: Class 'Movie' has no attribute 'objects'. Django adds the objects manager dynamically at runtime; ty is a static type checker and can't see it. He asked the right question:

"So this was because of ty/type checker flagging an error? If we have a way to instrument ty globally to recognize Django's dynamic managers, that would be better. Do we need to pull in 'django-stubs' or a similar configuration?"

The answer was: Django's dynamic ORM creates real friction with static type checkers. The pragmatic fix was to explicitly declare objects: models.Manager on the model, making the implicit explicit for both the type checker and any developer reading the code. The question itself was the point. Understanding why the error existed led to a better solution.

We also made a note to compare with other type checkers like pyrefly (v1.0 just came out) or mypy and see if they have better Django support.

The same week, the movie detail page returned 404s even though data existed. The cause: movie_list fetched from the API but didn't persist to the database. movie_detail queried the database. Nothing matched. Daniele fixed the sync logic and wrote:

"I ran across a problem earlier that was caused by not having the DB in sync. So lesson learned and now I try not to forget to run it."

Running makemigrations after every model change now became a habit.

Refactoring is how architecture emerges

By week 6, the codebase had grown. Two API functions, get_movie_list_from_api and get_services_list_from_api, had identical try/except blocks. The only difference was the endpoint and the default return value.

Extracting a private _get_from_api(endpoint, default) helper isn't a trick. It's a principle: if two pieces of code do the same thing, one is a future bug waiting to diverge.

The refactor also cleaned up the return types from list[Movie] | None to list[Movie], replacing a None sentinel with a proper empty list default.

Each review surfaced one decision that sharpened his model of how good code behaves.

Week 6: Docker, CI/CD, and a live URL

Week 6 was Docker and Fly.io. The app that ran locally needed to run in the cloud: no SQLite disappearing on container restart (or moving to Postgres), environment variables properly set, static files served correctly, no secrets hardcoded anywhere, GitHub Actions deploying on every push to main (with passing tests).

Daniele learned a lot here and shipped his app:

Daniele's movie and anime discovery platform

When Daniele shared the deploy win in my coaching Slack, he put into words something that sits at the center of anybody wanting to improve their coding/developer skills now:

"Addressing quality and best practices and security and maintainability in your code does not pair well with velocity, especially when you're learning. So I preferred to improve my code quality and to become better at it by learning properly, postponing some feature release for later. In the process I learned Django and its main mechanics, Docker and deployment in the cloud."

The velocity-vs-quality tradeoff he named isn't unique to learning Django. It's the choice every developer makes every week. My coaching gave him the framework, the discipline and persistence were his.

May 26, 2026 12:00 AM UTC