The trap is thinking the blinking LED is beneath you. Every hardware tutorial starts with it, and every experienced developer skips it, the same way a senior backend engineer would skip a "Hello, World" tutorial. The difference is that "Hello, World" teaches you exactly one thing — that your toolchain works. The blinking LED teaches you five: how GPIO output modes work, why timing matters at the hardware level, what happens to a pin when Python's garbage collector cleans up your objects, how current flows through a real circuit, and why the LED burns out if you forget the resistor. Skip this, and you'll debug the same five problems across every project in this book.
The blinking LED teaches you five things at once — skip it and you'll debug all five separately across every project in this book.
Every GPIO pin is a promise between your code and the circuit. HIGH means the pin outputs 3.3 volts. LOW means the pin drops to ground. Violate the contract — draw too much current, apply the wrong voltage — and you don't get an exception. You get a burned trace on a $45 board.
When you configure a pin as an output and set it HIGH, the Pi's Broadcom chip connects that pin internally to the 3.3V rail through a transistor. Current flows from the pin, through your circuit, and back to ground. The maximum current any single GPIO pin can source is about 16 milliamps. Exceed that and you're past the chip's rated spec — the pin may work for a while, then fail silently, or it may fail immediately.
An LED needs about 10-20mA to light up visibly. That's within spec, but only if you include a current-limiting resistor. Without the resistor, the LED's internal resistance is so low that the pin tries to source far more current than it can handle. The Pin doesn't complain. It just heats up and eventually stops working.
This is the fundamental difference between software interfaces and hardware interfaces. In software, a contract violation raises an error. In hardware, a contract violation releases smoke.
A GPIO pin at HIGH outputs 3.3 volts with a maximum of 16mA. That's a hard limit enforced by physics, not by a runtime — exceed it and the pin dies without raising an exception.
The circuit is simple. One wire from GPIO 17 to one leg of a 220-ohm resistor. The other leg of the resistor connects to the long leg (anode) of the LED. The short leg (cathode) of the LED connects to a ground pin on the Pi. That's three connections total.
Why GPIO 17? No special reason — it's a general-purpose pin that isn't reserved for any alternate function by default. Any GPIO pin works. I use 17 throughout this part because consistency reduces wiring errors, and GPIO 17 is physically easy to find (it's the fourth pin down on the left column of the header, counting from the top).
It doesn't matter. Current through a series circuit is the same at every point — whether the resistor is between the GPIO pin and the LED, or between the LED and ground, the current is identical. Convention puts it before the LED (closer to the power source), but electrically there's zero difference. Pick one and be consistent.
Python has two major GPIO libraries: RPi.GPIO (low-level, manual setup/cleanup) and gpiozero (high-level, Pythonic). This book uses gpiozero as the default because it handles pin mode configuration, cleanup, and common patterns like blinking in a single line. When you need finer control — PWM frequency tuning, precise timing — you'll drop to RPi.GPIO. But for 90% of projects, gpiozero is the right abstraction.
Here's the simplest possible output program:
from gpiozero import LED
from time import sleep
led = LED(17)
led.on()
print("LED is on")
sleep(3)
led.off()
print("LED is off")
Run that on your Pi with python3 led_on.py, and the LED lights up for three seconds, then turns off. Three things happened that are worth understanding:
LED(17) configured GPIO 17 as an output pin and set it LOW.led.on() set the pin to HIGH — 3.3 volts appears at the pin, current flows through the resistor and LED to ground, and the LED emits light.led.off() set the pin back to LOW — 0 volts, no current, no light.led.on() is not a metaphor. It's a system call that changes a hardware register, and 3.3 volts physically appears on a metal pin.
A blink is just on() and off() with sleep() in between, repeated in a loop. The gpiozero library has a built-in blink() method, but writing the loop manually first teaches you the timing model:
from gpiozero import LED
from time import sleep
led = LED(17)
try:
while True:
led.on()
sleep(0.5)
led.off()
sleep(0.5)
except KeyboardInterrupt:
print("Stopped by user")
The try/except KeyboardInterrupt pattern is important. Without it, Ctrl+C kills the script but leaves the LED in whatever state it was in — possibly on. The gpiozero library handles pin cleanup automatically when the LED object is garbage collected, but relying on the garbage collector for hardware state is like relying on __del__ for closing database connections. It works until it doesn't.
Now the same thing with gpiozero's built-in method:
from gpiozero import LED
from signal import pause
led = LED(17)
led.blink(on_time=0.5, off_time=0.5)
pause() # Keeps the script alive; blink runs in a background thread
The blink() method spawns a background thread. The pause() call from the signal module keeps the main thread alive indefinitely (until you hit Ctrl+C). Without pause(), the script would reach the end, Python would exit, the LED object would be garbage collected, and the LED would turn off. This is the single most common gotcha in gpiozero: the script must stay alive for background operations to continue.
This deserves its own section because every beginner hits it and most tutorials hand-wave the explanation.
When a Python script exits — whether it reaches the end of the file, hits an unhandled exception, or gets killed — the Python interpreter runs garbage collection. The gpiozero library registers cleanup handlers that set all pins back to their default state (input mode, no output) when the LED object is destroyed. This means the pin goes LOW, and the LED turns off.
This is actually correct behavior. Leaving GPIO pins in an active state after your script exits is dangerous. If you were driving a motor or a relay instead of an LED, a stuck HIGH pin could mean a motor running uncontrolled or a solenoid staying energized. Cleanup on exit is a safety feature, not a bug.
If you genuinely want the LED to stay on after the script exits — say, for a status indicator on a headless Pi — you need a different approach. Run the script as a systemd service that never exits, or use a latching relay that maintains state without continuous GPIO output.
The time.sleep() function in your blink loop isn't a precision instrument. Python's sleep() tells the Linux kernel "don't wake me for at least this many seconds," but the actual wake-up time depends on process scheduling. If the Pi is under load — running a web server, processing camera frames, indexing files — a sleep(0.5) might actually sleep for 0.502 or 0.510 seconds.
For LED blinking, this jitter is invisible. For anything timing-critical — generating precise signal protocols, driving stepper motors at exact speeds, or creating audio frequencies — it's fatal. The right tool for precision timing is hardware PWM (Chapter 8) or a dedicated microcontroller like the Pico or an Arduino. The Pi's strength is running Linux with Python. Microsecond-level timing is not what Linux was designed for.
from gpiozero import LED
from time import sleep, perf_counter
led = LED(17)
# Measure actual timing accuracy
target_sleep = 0.5
times = []
for _ in range(20):
start = perf_counter()
led.toggle()
sleep(target_sleep)
elapsed = perf_counter() - start
times.append(elapsed)
avg = sum(times) / len(times)
jitter = max(times) - min(times)
print(f"Target: {target_sleep:.3f}s Avg: {avg:.4f}s Jitter: {jitter:.4f}s")
Run that script and you'll see the jitter firsthand. On an idle Pi 4, it's typically under 2ms. On a loaded Pi Zero, it can exceed 15ms. This is why the blink rate of your LED sometimes feels uneven when you're also running an SSH session and apt-get in the background.
Python's time.sleep() is a minimum, not a guarantee. For LED blinking, the jitter is invisible. For signal protocols and motor control, it's catastrophic. Know which category your project falls into before choosing your timing strategy.
Here's a more practical pattern — a script that accepts a blink count and a speed, runs the sequence, then exits cleanly:
from gpiozero import LED
from time import sleep
import sys
def blink_led(pin, count, on_time=0.5, off_time=0.5):
"""Blink an LED a specific number of times, then turn it off."""
led = LED(pin)
for i in range(count):
led.on()
sleep(on_time)
led.off()
sleep(off_time)
print(f"Blink {i + 1}/{count}")
led.close()
print("Done. LED off, pin released.")
if __name__ == "__main__":
count = int(sys.argv[1]) if len(sys.argv) > 1 else 5
speed = float(sys.argv[2]) if len(sys.argv) > 2 else 0.3
blink_led(17, count, on_time=speed, off_time=speed)
Run it: python3 blink_control.py 10 0.2 — ten fast blinks, then a clean exit. The led.close() call is explicit cleanup — it releases the pin immediately rather than waiting for garbage collection. In short scripts this doesn't matter, but in long-running applications that create and destroy LED objects dynamically, failing to close them leaks file descriptors to /dev/gpiomem.
When you call led.on(), the BCM2835 chip sets GPIO 17's function select register to output mode and its output set register to HIGH. Electrically, an internal MOSFET transistor connects the pin to the 3.3V rail. Current flows from the pin, through your jumper wire, through the 220-ohm resistor (which drops about 1.3V), through the LED (which drops about 2.0V and emits photons in the process), and back to the Pi's ground pin. The total voltage drops across the resistor and LED sum to 3.3V — Kirchhoff's voltage law, which is just conservation of energy applied to circuits.
Once you can blink one LED, scaling to multiple is straightforward — and it introduces a pattern that matters for real projects: coordinated output across multiple pins.
from gpiozero import LED
from time import sleep
red = LED(17)
yellow = LED(27)
green = LED(22)
def traffic_cycle(duration_green=5, duration_yellow=2, duration_red=5):
"""Run one traffic light cycle."""
green.on()
print("GREEN - Go")
sleep(duration_green)
green.off()
yellow.on()
print("YELLOW - Caution")
sleep(duration_yellow)
yellow.off()
red.on()
print("RED - Stop")
sleep(duration_red)
red.off()
try:
while True:
traffic_cycle()
except KeyboardInterrupt:
print("Traffic light stopped")
This is the same pattern you'd use for controlling relays, status indicators on an enclosure, or sequenced motor operations. The abstraction holds: each GPIO pin is an independent output channel, and you coordinate them with timing in your Python logic.
Blinking an LED in a loop is a tutorial exercise. Using LEDs as diagnostic indicators in a real application is production engineering. I've seen this pattern where a headless Pi running in a closet has no screen, no keyboard, and the only way to know its state is the LED patterns visible through the enclosure's ventilation slots.
Here's a pattern I use for communicating system state through a single LED:
from gpiozero import LED
from time import sleep
status_led = LED(17)
def indicate_status(led, pattern):
"""
Flash an LED in a pattern to communicate status.
Pattern is a list of (on_seconds, off_seconds) tuples.
"""
for on_time, off_time in pattern:
led.on()
sleep(on_time)
led.off()
sleep(off_time)
# Define status patterns
BOOT_OK = [(0.1, 0.1)] * 3 + [(0.0, 0.5)] # 3 quick flashes
WIFI_CONNECTED = [(0.5, 0.5)] # Steady pulse
ERROR = [(0.05, 0.05)] * 10 + [(0.0, 1.0)] # Fast strobe, then pause
READY = [(1.0, 0.0)] # Solid on
# Usage in an application startup sequence
indicate_status(status_led, BOOT_OK)
print("System booted")
indicate_status(status_led, WIFI_CONNECTED)
print("Network connected")
indicate_status(status_led, READY)
print("Ready for operation")
This is a real deployment technique. Server rack equipment, IoT sensors, industrial controllers — they all use LED blink patterns to communicate state because an LED is the cheapest, most reliable output device that doesn't require a display, a network connection, or a log reader. One LED, three blink patterns, and you can diagnose most startup failures from across the room.
Digital output is the foundation of every hardware project. Master the LED — the simplest output device — and you understand the electrical contract that governs every relay, motor driver, solenoid, and actuator you'll ever wire to a Pi.
Connect GPIO 17 to a 220-ohm resistor, then to an LED's anode (long leg), then cathode (short leg) to ground. Run the three-line on/off script. Confirm the LED lights up. If it doesn't, check the LED polarity — flip it — and check that your breadboard row connections are correct.
Write the while True blink loop with try/except KeyboardInterrupt. Experiment with different sleep values — 0.1 (fast strobe), 1.0 (slow pulse), 0.05 (so fast it looks constantly on). Notice how at very short intervals, the LED appears always-on because your eye can't track the switching. That's the intuition you'll need for PWM in Chapter 8.
Run the blink script, then kill it with Ctrl+C while the LED is on. Observe that the LED turns off. Now write a script that uses RPi.GPIO without calling GPIO.cleanup() and kill it mid-blink — the LED stays on. This is why gpiozero's auto-cleanup matters.
Wire three LEDs (red, yellow, green) to GPIOs 17, 27, and 22. Run the traffic cycle script. Adjust the timing to match a real intersection near you. This exercise forces you to coordinate multiple outputs with sequenced timing — the same pattern used in industrial control.
Implement blink_control.py with command-line arguments for count and speed. Run it with different parameters. Add a third argument for the GPIO pin number so you can control any LED from the same script. This builds the habit of writing reusable hardware scripts instead of one-off throwaway files.
Master the LED and you understand the electrical contract that governs every relay, motor driver, and actuator you'll ever connect to a Pi.