To interface a rotary encoder with a Raspberry Pi, use the gpiozero library with the native lgpio pin factory to handle hardware-level debouncing, wire the CLK and DT pins to GPIO 17 and 27, and power the module strictly from the 3.3V pin. This guide targets the Raspberry Pi 5 (4GB variant) running Raspberry Pi OS Bookworm (64-bit), addressing the RP1 southbridge chip's specific GPIO handling requirements.
Hardware Selection: Which Encoder to Buy?
Not all rotary encoders are built for microcontroller logic levels. The ubiquitous KY-040 is cheap and includes a breakout board with pull-up resistors, but its mechanical contacts suffer from severe switch bounce. High-end optical encoders eliminate bounce entirely but require 5V logic, which will fry the Pi 5's 3.3V GPIO pins if connected directly. Here is a data-dense comparison of common modules available in 2026 to help you select the right hardware.
| Model / Variant | Type | Resolution (PPR) | Detents | Logic Voltage | Approx. Price |
|---|---|---|---|---|---|
| KY-040 Module | Mechanical | 15 PPR (30 edges) | 30 | 3.3V - 5V | $1.50 |
| Bourns PEC11R | Mechanical | 24 PPR (48 edges) | 24 | 5V Max (Needs divider for Pi) | $4.20 |
| SparkFun COM-15083 | Mechanical | 12 PPR (24 edges) | 24 | 3.3V - 5V | $6.95 |
| HEDS-5540 (Optical) | Optical | 500 PPR (2000 edges) | 0 (Smooth) | 5V Only (Requires level shifter) | $45.00 |
Recommendation: For 90% of Pi projects (volume knobs, menu navigation), the KY-040 module is the correct choice. It natively supports 3.3V logic when powered from the Pi's 3.3V rail, and the module's PCB includes the necessary 10kΩ pull-up resistors, saving you from wiring a messy breadboard array.
Pin Mapping and Wiring Procedure
The Raspberry Pi 5's RP1 chip is highly sensitive to overvoltage. Never connect a 5V power source to the Pi 5 GPIO pins. By powering the KY-040 from the 3.3V pin, the module's internal pull-ups will pull the CLK and DT lines to 3.3V, which is perfectly safe for the RP1.
Parts List
- Raspberry Pi 5 (4GB or 8GB) with active cooling
- KY-040 Rotary Encoder Module (with breakout board)
- Female-to-Female jumper wires (22 AWG silicone preferred for flexibility)
- Half-size breadboard (optional, for adding decoupling capacitors)
Pinout Table
| KY-040 Pin | Function | Raspberry Pi 5 Pin | BCM / GPIO Number |
|---|---|---|---|
| GND | Ground Reference | Pin 6 | GND |
| + (VCC) | Power (3.3V ONLY) | Pin 1 | 3V3 |
| SW | Push-button Switch | Pin 15 | GPIO 22 |
| DT | Data (Quadrature B) | Pin 13 | GPIO 27 |
| CLK | Clock (Quadrature A) | Pin 11 | GPIO 17 |
Python Code: Debounced Reading via gpiozero
Legacy tutorials often use the RPi.GPIO library with software interrupts. On the Pi 5 running Bookworm, RPi.GPIO is deprecated and unstable. We use gpiozero, which automatically leverages the lgpio backend on the Pi 5 for hardware-timed edge detection, virtually eliminating missed steps caused by OS scheduling jitter.
Ensure your environment is set up: sudo apt update && sudo apt install python3-gpiozero python3-lgpio
#!/usr/bin/env python3
"""
Rotary Encoder Reader for Raspberry Pi 5
Target OS: Raspberry Pi OS Bookworm (64-bit)
Library: gpiozero (using native lgpio backend)
"""
from gpiozero import RotaryEncoder, Button
from signal import pause
import sys
import logging
# Configure logging for debugging
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# --- PIN DEFINITIONS ---
# Using BCM numbering (standard for gpiozero)
CLK_PIN = 17 # Physical Pin 11
DT_PIN = 27 # Physical Pin 13
SW_PIN = 22 # Physical Pin 15
def main():
try:
# Initialize encoder. max_steps=0 allows infinite rotation tracking.
# wrap=False prevents the counter from resetting to 0 at max_steps.
logging.info(f"Initializing RotaryEncoder on GPIO {CLK_PIN} and {DT_PIN}")
encoder = RotaryEncoder(CLK_PIN, DT_PIN, max_steps=0, wrap=False)
# Initialize push-button. pull_up=True relies on internal Pi pull-ups.
# bounce_time=0.05 (50ms) filters out mechanical switch chatter.
logging.info(f"Initializing Button on GPIO {SW_PIN}")
button = Button(SW_PIN, pull_up=True, bounce_time=0.05)
# --- CALLBACK FUNCTIONS ---
def on_rotate():
# encoder.steps tracks the net quadrature state changes
print(f"[ROTATE] Current Steps: {encoder.steps}")
def on_press():
print("[BUTTON] Pressed! Resetting step counter to 0.")
encoder.steps = 0
# Bind callbacks
encoder.when_rotated = on_rotate
button.when_pressed = on_press
print("Monitoring rotary encoder. Press Ctrl+C to exit.")
print("-" * 40)
# Keep the script alive to listen for hardware interrupts
pause()
except KeyboardInterrupt:
logging.info("\nCtrl+C detected. Exiting gracefully.")
sys.exit(0)
except Exception as e:
logging.error(f"Fatal runtime error: {type(e).__name__}: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
Debugging: Missed Steps and Pin Conflicts
Rotary encoders are notorious for generating phantom interrupts and throwing GPIO allocation errors. If your script crashes or the step counter jumps erratically, follow this diagnostic path.
Exact Error: Pin Allocation Failures
If you run the script and immediately see this traceback:
gpiozero.exc.GPIOPinInUse: pin 17 is already in use
Or, at the lower lgpio level:
lgpio.error: GPIO busy
Ranked Causes and Fixes:
- Background Daemon Conflict (Most Likely): You have a legacy
pigpioddaemon running, or another Python script crashed without releasing the GPIO lines. Fix: Runsudo systemctl stop pigpiodandsudo killall python3, then retry. - I2C/SPI Overlay Conflict: GPIO 17 or 27 might be claimed by an active device tree overlay (like an SPI display). Fix: Check
/boot/firmware/config.txtand comment out conflictingdtoverlaylines. - Unclean Exit in Previous Run: The OS hasn't reclaimed the file descriptors. Fix: A simple reboot of the Pi 5 will clear the RP1 GPIO state machine.
The First Three Things to Check When Steps are Missed
If the code runs but the step counter stutters, skips numbers, or registers backward steps when turning forward, check these three physical layer issues:
- Verify 3.3V Power Delivery: Use a multimeter to measure the voltage between the KY-040's VCC and GND pins. If it reads 4.9V+, you plugged it into the 5V pin. This won't immediately kill the Pi 5 if the module has series resistors, but it will cause the RP1 chip to reject the logic HIGH thresholds erratically. It must read 3.28V - 3.32V.
- Check for Floating Pins (Missing Pull-ups): If you are using a bare encoder, measure the voltage on the CLK and DT pins while idle. They should read a solid 3.3V. If they read 0V or fluctuate, your pull-up resistors are missing or broken, causing the Pi to trigger interrupts on electromagnetic noise.
- Inspect Jumper Wire Integrity: Quadrature encoding requires microsecond timing. A loose Dupont jumper wire on the breadboard will cause one channel (CLK or DT) to drop out mid-rotation. When the Pi reads the state of the remaining channel, it calculates the wrong direction. Swap the CLK and DT jumper wires with known-good silicone cables.
Extending and Simplifying the Build
Depending on your end goal, you may need to scale this project up for a user interface or scale it down to save CPU cycles on a headless server.
How to Extend: Adding an I2C OLED Display
To turn this into a standalone menu navigator, add a 0.96-inch SSD1306 I2C OLED display. Wire the display's SDA to GPIO 2 (Pin 3) and SCL to GPIO 3 (Pin 5). Using the luma.oled library, you can update the screen inside the on_rotate() callback. Warning: I2C writes take roughly 2-5ms. If you turn the encoder quickly, the I2C bus will bottleneck, causing the UI to lag behind the physical knob. To fix this, decouple the UI update from the interrupt by pushing encoder.steps to a thread-safe queue and having a separate 30FPS render loop pull from it.
How to Simplify: Polling for Low-Priority Tasks
If you are building a simple thermostat where the knob is only turned once an hour, hardware interrupts are overkill and can complicate your main application loop. You can simplify the build by abandoning gpiozero.RotaryEncoder entirely and using a basic polling loop with gpiozero.DigitalInputDevice. Read the CLK pin every 50ms; if it transitions from HIGH to LOW, check the state of the DT pin to determine direction. This eliminates callback threading issues and makes the code trivial to integrate into a synchronous Pygame or Tkinter application, at the cost of missing steps if the user spins the knob violently.
For deeper theory on how the 90-degree phase shift in quadrature signals allows microcontrollers to determine direction, refer to the All About Circuits encoder guide. For official pinout and library documentation, always consult the Raspberry Pi GPIO documentation and the gpiozero API reference.






