The trap is checking the pin value in a while True loop. Every beginner does it. You wire up a button, then write a loop that reads the pin state a thousand times a second, burning CPU cycles to detect a press that happens maybe once every few seconds. It works, technically. But it's the hardware equivalent of hitting refresh on a web page instead of opening a WebSocket. The Pi's processor has built-in edge detection — circuitry designed to watch for the exact moment a signal transitions from LOW to HIGH or HIGH to LOW. Using a polling loop instead of edge detection is like writing a cron job that queries the database every second instead of setting up a trigger.
Polling a GPIO pin in a tight loop is the hardware equivalent of hitting refresh on a web page instead of opening a WebSocket.
I've seen this pattern where teams build a monitoring device — say, a button that triggers an alert — and the polling loop chews through 30% of the Pi's CPU doing nothing useful. On a Pi Zero with a single core, that's the difference between a responsive system and one that stutters every time the OS needs to run a background process.
Before we get to the code, you need to understand a hardware concept that has no direct software equivalent: the floating pin.
When a GPIO pin is configured as an input and nothing is connected to it, the pin is "floating." It's not connected to 3.3V (HIGH) or ground (LOW). It picks up electrical noise from the environment — nearby wires, radio interference, the Pi's own processor switching — and the value you read will flicker randomly between HIGH and LOW. This isn't a bug. It's physics. An unconnected wire is an antenna.
The solution is a pull-up or pull-down resistor:
The good news: the Raspberry Pi has internal pull-up and pull-down resistors built into every GPIO pin, configurable in software. And gpiozero enables them automatically. When you create a Button object, it activates the internal pull-up resistor by default, so the pin reads HIGH when the button is not pressed and LOW when pressed.
A floating input pin is an antenna. It reads random noise. Pull-up and pull-down resistors give the pin a defined default state, and gpiozero configures them for you automatically.
from gpiozero import Button
# Internal pull-up enabled by default
# Pin reads HIGH when button is NOT pressed, LOW when pressed
button = Button(17)
# Explicitly specifying pull-up (same as default)
button_explicit = Button(17, pull_up=True)
# Using pull-down instead (pin reads LOW by default, HIGH when pressed)
button_pulldown = Button(17, pull_up=False)
Stop polling. Start listening. Hardware interrupts detect signal edges — the exact moment a pin transitions from LOW to HIGH or HIGH to LOW — in microseconds, without burning CPU cycles. Your job is to register a callback and get out of the way.
The Pi's BCM chip supports hardware edge detection on every GPIO pin. When enabled, the chip watches the pin in dedicated hardware and fires an interrupt — a signal to the CPU — the instant the pin changes state. This happens in microseconds, compared to the milliseconds (at best) your Python polling loop achieves.
gpiozero exposes three event-driven interfaces:
wait_for_press() — Blocking WaitThe simplest pattern. The script blocks at this call until the button is physically pressed:
from gpiozero import Button, LED
button = Button(17)
led = LED(27)
print("Waiting for button press...")
button.wait_for_press()
led.on()
print("Button pressed! LED is on.")
button.wait_for_release()
led.off()
print("Button released. LED is off.")
This is clean for sequential scripts — "wait for this, then do that." But it doesn't scale to multiple inputs. If you're waiting for button A, you can't simultaneously check button B.
when_pressed / when_released — Event CallbacksThis is the pattern you'll use in real projects. Register a function that runs when the event fires:
from gpiozero import Button, LED
from signal import pause
button = Button(17)
led = LED(27)
def handle_press():
led.on()
print("Pressed — LED on")
def handle_release():
led.off()
print("Released — LED off")
button.when_pressed = handle_press
button.when_released = handle_release
print("Listening for button events. Ctrl+C to exit.")
pause()
The callbacks run in a background thread managed by gpiozero. The pause() call keeps the main thread alive. This pattern handles multiple buttons, sensors, and inputs simultaneously because each callback is independent.
A common real-world requirement: press a button to toggle an LED on, press again to toggle it off. This is where beginners write spaghetti — tracking state in global variables, debouncing manually, handling edge cases around rapid presses. gpiozero makes it one line:
from gpiozero import Button, LED
from signal import pause
button = Button(17)
led = LED(27)
button.when_pressed = led.toggle
print("Press to toggle. Ctrl+C to exit.")
pause()
That's the entire program. led.toggle is a method reference — when the button fires its when_pressed event, it calls led.toggle(), which flips the LED's state. No state variable, no if/else, no boolean tracking.
The toggle pattern in gpiozero is one line because the library does what good libraries do: it hides the state machine you'd otherwise write yourself.
Mechanical buttons bounce. When you press a tactile switch, the metal contacts don't make a single clean connection. They vibrate — bouncing on and off several times in the first few milliseconds before settling. To your finger, it's one press. To the Pi's edge detection, it's five or ten rapid transitions.
Without debouncing, your toggle code would fire multiple times per press. The LED would flicker and land in an unpredictable state — sometimes on, sometimes off, depending on whether the bounce count was even or odd.
gpiozero handles this with the bounce_time parameter:
from gpiozero import Button, LED
from signal import pause
# 200ms debounce — ignore transitions within 200ms of each other
button = Button(17, bounce_time=0.2)
led = LED(27)
button.when_pressed = led.toggle
print("Debounced toggle. Ctrl+C to exit.")
pause()
The bounce_time=0.2 parameter tells gpiozero to ignore any state changes within 200 milliseconds of the first detected edge. This filters out the mechanical bounce while still responding to intentional presses. The default bounce_time is None (no debounce), which is why beginners hit this problem — the default assumes you want raw edge detection.
For tactile push buttons, 50-200ms works. For toggle switches (the kind you flip up and down), 100-300ms. For reed switches (used with magnets), 20-50ms. If you set it too high, rapid intentional presses get swallowed. Start at 200ms and lower it if the button feels unresponsive.
The bounce_time parameter is software debouncing — the Pi's processor ignores rapid transitions in code. There's also hardware debouncing: a small capacitor (0.1uF) placed across the button terminals smooths out the electrical bounce before the signal even reaches the GPIO pin. The capacitor charges and discharges slowly enough that the rapid bounces get filtered into a single clean transition.
Which should you use? For prototyping and most Pi projects, software debouncing is fine. The CPU cost is negligible, and it's adjustable without rewiring. Hardware debouncing is better when you're reading high-frequency signals (like a rotary encoder) where the software debounce window would swallow legitimate rapid transitions, or when you're building a production product and want the cleanest possible signal at the hardware level.
For the projects in this book, software debouncing with bounce_time is sufficient. If you ever find yourself tuning the debounce window and still getting unreliable reads, add the capacitor.
Real products distinguish between a short press and a long press — think of holding your phone's power button versus tapping it. gpiozero's Button supports this with the hold_time and when_held properties:
from gpiozero import Button, LED
from signal import pause
button = Button(17, bounce_time=0.2, hold_time=2)
led = LED(27)
def on_press():
led.on()
print("Short press — LED on")
def on_release():
led.off()
print("Released — LED off")
def on_hold():
print("LONG PRESS detected (held for 2+ seconds)")
# In a real project: trigger shutdown, reset settings, enter pairing mode
led.blink(on_time=0.1, off_time=0.1)
button.when_pressed = on_press
button.when_released = on_release
button.when_held = on_hold
print("Tap for on/off, hold 2s for long press. Ctrl+C to exit.")
pause()
The hold_time=2 parameter means when_held fires after the button has been continuously pressed for two seconds. This is a real product pattern — Raspberry Pi projects that act as appliances (media players, sensor hubs, home automation controllers) use long press for power-off or configuration mode, and short press for normal operation.
Here's a practical script that ties together everything in this chapter — a button that counts presses, displays the count, and toggles an LED on every fifth press:
from gpiozero import Button, LED
from signal import pause
button = Button(17, bounce_time=0.2)
led = LED(27)
press_count = 0
def on_press():
global press_count
press_count += 1
print(f"Press #{press_count}")
if press_count % 5 == 0:
led.toggle()
state = "ON" if led.is_lit else "OFF"
print(f" -> LED toggled {state} (every 5th press)")
button.when_pressed = on_press
print("Press the button. LED toggles every 5 presses. Ctrl+C to exit.")
pause()
The global press_count pattern works for single-threaded callbacks on a Pi. But if you're building something more complex — multiple buttons, network requests in callbacks, concurrent sensor reads — switch to a class-based design or a threading.Lock. gpiozero callbacks run in background threads, and unsynchronized global state is a race condition waiting to happen.
This pattern scales directly to real projects: a door sensor that counts entries, a production-line counter that tracks items on a conveyor, or an emergency stop button that latches a shutdown state. The GPIO input is the same — what changes is the callback logic.
Real hardware projects rarely have a single button. Here's the pattern for handling multiple independent inputs:
from gpiozero import Button, LED
from signal import pause
button_a = Button(17, bounce_time=0.2)
button_b = Button(27, bounce_time=0.2)
led_red = LED(22)
led_green = LED(23)
button_a.when_pressed = led_red.toggle
button_b.when_pressed = led_green.toggle
print("Button A toggles red. Button B toggles green. Ctrl+C to exit.")
pause()
Each button-LED pair is independent. Pressing A doesn't affect B. This is the event-driven model working as designed — each input has its own callback, and the callbacks don't share state unless you explicitly connect them.
A Button in gpiozero is really just "a pin that reads HIGH or LOW." The library doesn't care whether the signal comes from a human pressing a switch or from a sensor triggering. Any device that outputs a digital signal — HIGH or LOW, 3.3V or ground — works with the same Button class:
Button (or better, use gpiozero's MotionSensor class which is a thin wrapper), and register when_pressed to trigger a camera capture, an alert, or a log entry.from gpiozero import MotionSensor
from signal import pause
from datetime import datetime
pir = MotionSensor(4) # PIR sensor on GPIO 4
def on_motion():
print(f"[{datetime.now().strftime('%H:%M:%S')}] Motion detected!")
def on_no_motion():
print(f"[{datetime.now().strftime('%H:%M:%S')}] Motion stopped.")
pir.when_motion = on_motion
pir.when_no_motion = on_no_motion
print("PIR sensor active. Ctrl+C to exit.")
pause()
The abstraction is the same: a pin changes state, a callback fires. Whether the trigger is a finger on a button, a body moving past a sensor, or a door swinging open, the code structure is identical. This is why mastering digital input with a button prepares you for every binary sensor you'll ever connect to a Pi.
Event-driven GPIO is the same paradigm as event-driven web programming. Register handlers, let the framework dispatch events, and keep your callbacks small and fast. The only difference is the events come from physical switches instead of HTTP requests.
Place a tactile push button across the center channel of your breadboard. Connect one side to GPIO 17 and the other side to ground. No external resistor needed — gpiozero's Button class enables the internal pull-up automatically. Run the wait_for_press() example and confirm it detects your press.
Write a raw polling loop that prints every state change with a timestamp. Press the button slowly and observe multiple rapid transitions in the output — that's mechanical bounce. Then add bounce_time=0.2 and watch the noise disappear. Seeing bounce with your own eyes is worth more than any explanation.
Wire a button and an LED. Implement the one-line toggle pattern. Press the button ten times and confirm the LED alternates cleanly between on and off. If it doesn't toggle cleanly, your bounce time is too low — increase it.
Implement the press-counting script that toggles the LED every fifth press. Run it for 25 presses and verify the LED toggles exactly five times. This exercises your understanding of callbacks, state management, and debouncing in one small program.
Run a tight polling loop (while True: if button.is_pressed: ...) and check CPU usage with top or htop. Then run the event-driven version with pause() and check again. On a Pi Zero, the difference is dramatic — 30% CPU versus less than 1%. That's the cost of ignoring hardware interrupts.
Register handlers, let the framework dispatch events, and keep your callbacks small and fast — event-driven GPIO is the same paradigm as event-driven web programming.