Raspberry Pi Pico Push Button Input with Interrupts and Debouncing

Reading a push button looks trivial until you try it: an unconnected input floats and reads random values, a single press registers as five presses, and a loop that is busy elsewhere misses short taps completely. This tutorial fixes all three problems on the Raspberry Pi Pico. You will use the internal pull-up resistor, compare polling with interrupts, and write a debounced interrupt handler that counts presses reliably and toggles an LED.

Inputs, Floating Pins and Pull Resistors

A GPIO configured as an input has a very high impedance, so if nothing drives it the voltage drifts with noise and your hand's capacitance. A pull resistor gives it a defined idle level. The RP2040 and RP2350 have internal pull-up and pull-down resistors on every GPIO, roughly 50 kilo-ohm, enabled from software.

WiringPin settingIdle readingPressed reading
Button between GPIO and GNDPin(14, Pin.IN, Pin.PULL_UP)10
Button between GPIO and 3V3(OUT)Pin(14, Pin.IN, Pin.PULL_DOWN)01

The pull-up arrangement (button to ground) is the most common because a short to ground is harmless, and it avoids running 3.3 V wires around the board. Its logic is inverted: pressed reads 0.

Circuit

PartConnection
Push button AOne leg to GP14 (physical pin 19), other leg to GND (pin 18)
Push button BOne leg to GP15 (physical pin 20), other leg to GND (pin 18)
LED + 330 ohm resistorGP16 (pin 21) through resistor to LED anode, cathode to GND (pin 23)

Four-leg tactile switches have two pairs of legs that are always connected. Place the switch across the breadboard's centre gap and use legs on diagonally opposite corners to be sure you are switching through the contacts.

Polling: The Simple Approach

Polling means checking the pin in a loop. It is fine for simple programs, and detecting the change from 1 to 0 (an edge) rather than the level prevents the LED toggling continuously while the button is held.

poll_button.py - toggle an LED on each press by polling for a falling edge
# poll_button.py - edge detection by polling
from machine import Pin
import time

button = Pin(14, Pin.IN, Pin.PULL_UP)   # pressed = 0
led = Pin(16, Pin.OUT)

last = button.value()
while True:
    now = button.value()
    if last == 1 and now == 0:          # falling edge = new press
        led.toggle()
        time.sleep_ms(30)               # crude debounce: ignore bounce
    last = now
    time.sleep_ms(5)

The weakness is obvious when the loop also has slow work to do, such as reading a sensor that takes 250 ms. A press shorter than that can be missed entirely.

Interrupts with Pin.irq and Debouncing

An interrupt makes the hardware call a function the moment the pin changes, whatever the main program is doing. Mechanical contacts bounce for a few milliseconds when they close, producing several edges, so the handler must ignore edges that arrive too soon after the previous accepted one. The program below counts presses on two buttons: button A increments and toggles the LED, button B resets the count.

irq_buttons.py - debounced interrupt handlers for two buttons with a press counter
# irq_buttons.py - interrupt-driven buttons with time-based debounce
from machine import Pin
import time

DEBOUNCE_MS = 50          # ignore edges closer together than this

btn_a = Pin(14, Pin.IN, Pin.PULL_UP)
btn_b = Pin(15, Pin.IN, Pin.PULL_UP)
led = Pin(16, Pin.OUT)

press_count = 0
reset_flag = False
last_a = 0
last_b = 0

def on_a(pin):
    # Called on the falling edge of GP14 (button pressed)
    global press_count, last_a
    now = time.ticks_ms()
    if time.ticks_diff(now, last_a) > DEBOUNCE_MS:
        last_a = now
        press_count += 1
        led.toggle()

def on_b(pin):
    # Only set a flag; the main loop does the printing
    global reset_flag, last_b
    now = time.ticks_ms()
    if time.ticks_diff(now, last_b) > DEBOUNCE_MS:
        last_b = now
        reset_flag = True

btn_a.irq(trigger=Pin.IRQ_FALLING, handler=on_a)
btn_b.irq(trigger=Pin.IRQ_FALLING, handler=on_b)

shown = -1
while True:
    if reset_flag:
        reset_flag = False
        press_count = 0
        print('Counter reset')
    if press_count != shown:
        shown = press_count
        print('Presses:', shown)
    time.sleep_ms(250)    # simulate slow work; presses are still counted

How the Interrupt Code Works

  • btn_a.irq(trigger=Pin.IRQ_FALLING, handler=on_a) registers the handler for the high-to-low edge, which is the press on a pull-up circuit. Use Pin.IRQ_RISING for the release, or OR both together to catch either.
  • time.ticks_ms() and time.ticks_diff() handle counter wrap-around correctly. Never subtract tick values directly.
  • Handlers keep work minimal: update a counter, toggle a pin or set a flag. Printing, sensor reads and network calls belong in the main loop.
  • On the rp2 port, pin handlers are soft interrupts by default, scheduled by MicroPython shortly after the edge, so they may allocate memory. If you pass hard=True for lower latency, the handler must not allocate memory, which rules out creating lists, strings or floats inside it.
  • Even with a 250 ms sleep in the main loop, every press is counted because the hardware captured the edge.

50 ms is a reasonable debounce window for most tactile switches. Cheap or worn switches may need 80 to 100 ms. Hardware debouncing, a 100 nF capacitor across the switch with the pull-up resistor forming an RC filter, reduces bounce further and is worth adding when wires to the button are long.

Troubleshooting

  • Count jumps by 2 or 3 per press: increase DEBOUNCE_MS, or add the 100 nF capacitor.
  • Random presses with nothing touched: the pull-up is missing or the wire is on another pin. Check Pin.PULL_UP and the GPIO number.
  • Button always reads 0: the switch is rotated 90 degrees on the breadboard so those legs are permanently connected.
  • NameError or count never changes: a global declaration is missing inside the handler.
  • MemoryError in handler: you used hard=True and created an object inside it; switch to a soft IRQ or pre-allocate.

Summary

Buttons need a defined idle level, which the Pico's internal pull-ups provide. Polling works for simple loops, while Pin.irq catches every press regardless of what the main program is doing. A time-based debounce using ticks_diff turns noisy contacts into clean single events, and keeping interrupt handlers short avoids hard-to-find bugs. Next, you will use PWM to fade an LED smoothly instead of switching it on and off.