Button Input and Debouncing

How to read button presses on ESP32 with pull-up resistors and software debouncing

Buttons are the simplest way to get input from a user. Press a button to turn on a light, start a measurement, or switch between modes. But reading a button reliably on a microcontroller involves a few concepts that trip up beginners: pull-up resistors, inverted logic, and contact bounce. This article covers all three and gives you solid patterns you can reuse in every project.

🔗How a Button Works

A push button (also called a tactile switch) is a simple mechanical component. When you press it, two metal contacts touch and complete a circuit. When you release it, a spring pushes the contacts apart and the circuit breaks.

The problem is: what does the ESP32's GPIO pin read when the button is not pressed? If the pin is not connected to anything definite, it "floats" -- picking up electrical noise and reading random HIGH and LOW values. This is called a floating pin, and it is the most common mistake beginners make with buttons.

To fix this, you need a pull-up or pull-down resistor that holds the pin at a known voltage when the button is open.

🔗What You'll Need

ComponentQtyNotesBuy
ESP32 dev board1AliExpress | Amazon.de .co.uk .com
Push button (tactile)1AliExpress | Amazon.de .co.uk .com
10k ohm resistor1Optional (for external pull-down)AliExpress | Amazon.de .co.uk .com
LED (red)1For visual feedbackAliExpress | Amazon.de .co.uk .com
220 ohm resistor1For LEDAliExpress | Amazon.de .co.uk .com
Breadboard1AliExpress | Amazon.de .co.uk .com
Jumper wires~6Male-to-maleAliExpress | Amazon.de .co.uk .com

Links marked Amazon/AliExpress are affiliate links. We may earn a small commission at no extra cost to you.

🔗Wiring Option 1: External Pull-Down Resistor

In this configuration, a $10 \, \text{k}\Omega$ resistor pulls the GPIO pin to GND by default. When you press the button, the pin connects to 3.3 V.

graph LR
    V["3.3V"] --- B["Button"]
    B --- P["GPIO 14"]
    P --- R["10k resistor"]
    R --- G["GND"]
ComponentConnects to
Button pin 13.3V
Button pin 2GPIO 14
10k resistorGPIO 14 to GND
LED anode (long leg)GPIO 2 via 220 ohm resistor
LED cathode (short leg)GND

Behavior:

Button stateVoltage at GPIO 14digitalRead() returns
Not pressed0 V (pulled to GND by resistor)LOW
Pressed3.3 V (connected to 3.3V)HIGH

This is intuitive: pressed = HIGH, not pressed = LOW.

The ESP32 has built-in pull-up resistors on most GPIO pins. By enabling the internal pull-up with INPUT_PULLUP, you eliminate the need for an external resistor entirely. The wiring is simpler -- just a button between the GPIO pin and GND:

graph LR
    V["3.3V (internal)"] ---|Pull-up R| P["GPIO 14"]
    P --- B["Button"]
    B --- G["GND"]
ComponentConnects to
Button pin 1GPIO 14
Button pin 2GND
LED anode (long leg)GPIO 2 via 220 ohm resistor
LED cathode (short leg)GND

Behavior:

Button stateVoltage at GPIO 14digitalRead() returns
Not pressed3.3 V (pulled HIGH by internal resistor)HIGH
Pressed0 V (connected to GND through button)LOW

Tip: The internal pull-up method is the most common approach in ESP32 projects. It uses fewer components and works well. The only thing to remember is that the logic is inverted: LOW means pressed, HIGH means not pressed.

🔗Code Example: Basic Button Read

This sketch reads a button wired with the internal pull-up method and turns on an LED while the button is held down.

View complete sketch
#define BUTTON_PIN 14
#define LED_PIN 2

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);  // Enable internal pull-up resistor
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
  delay(1000);
  Serial.println("Button reader ready");
}

void loop() {
  int buttonState = digitalRead(BUTTON_PIN);

  if (buttonState == LOW) {        // LOW = pressed (pull-up logic)
    digitalWrite(LED_PIN, HIGH);   // Turn LED on
    Serial.println("Button pressed");
  } else {
    digitalWrite(LED_PIN, LOW);    // Turn LED off
  }

  delay(50);
}

🔗How It Works

CodePurpose
pinMode(BUTTON_PIN, INPUT_PULLUP)Configures the pin as input and activates the internal pull-up resistor
digitalRead(BUTTON_PIN)Returns HIGH (not pressed) or LOW (pressed)
buttonState == LOWChecks for a press -- remember, with a pull-up, LOW means the button is connecting the pin to GND

Upload this sketch, open the Serial Monitor at 115200 baud, and press the button. The LED should light up while you hold the button, and "Button pressed" should appear in the monitor.

Notice that while the button is held down, "Button pressed" prints repeatedly -- once per loop iteration (every ~50 ms). This is because the sketch checks the button's level (is it pressed right now?) rather than detecting the edge (did it just change?). We will fix this later with edge detection.

🔗The Bouncing Problem

If you use the basic sketch above to count button presses, you will notice something strange: a single press often registers as 2, 3, or even 10 presses. This is not a software bug -- it is a mechanical reality.

When the metal contacts inside a button come together, they do not make a single clean connection. They bounce -- rapidly making and breaking contact several times over a few milliseconds before settling. A typical button bounce lasts $1$ to $10 \, \text{ms}$.

To the human finger, this feels like one press. But the ESP32 is fast enough to read the pin thousands of times per second, so it sees each bounce as a separate press-release cycle.

This matters for:

  • Counters -- A single press increments by 5 instead of 1
  • Toggles -- The state flips back and forth unpredictably
  • Menu navigation -- Skips through multiple options at once

The solution is debouncing -- ignoring rapid changes that happen within a short time window.

🔗Software Debouncing

The most common debouncing technique is simple: after detecting a state change, ignore all further changes for a short period (typically $50 \, \text{ms}$). If the button is still in the new state after the delay, accept the change as real.

View complete sketch
#define BUTTON_PIN 14
#define LED_PIN 2

int buttonState = HIGH;          // Current debounced state
int lastRawState = HIGH;         // Previous raw reading
unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 50;  // 50 ms debounce window

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
  delay(1000);
  Serial.println("Debounced button reader ready");
}

void loop() {
  int rawState = digitalRead(BUTTON_PIN);

  // If the raw reading changed, reset the debounce timer
  if (rawState != lastRawState) {
    lastDebounceTime = millis();
  }

  // If enough time has passed since the last change, accept the reading
  if ((millis() - lastDebounceTime) > debounceDelay) {
    if (rawState != buttonState) {
      buttonState = rawState;

      if (buttonState == LOW) {
        Serial.println("Button pressed (debounced)");
      } else {
        Serial.println("Button released (debounced)");
      }
    }
  }

  // LED follows debounced state
  digitalWrite(LED_PIN, buttonState == LOW ? HIGH : LOW);

  lastRawState = rawState;
}

🔗How the Debounce Works

  1. Every loop iteration, the sketch reads the raw button state.
  2. If the raw state is different from the previous raw state, the debounce timer resets.
  3. Only when the raw state has been stable for longer than debounceDelay ($50 \, \text{ms}$) does the sketch accept it as the new button state.
  4. Bounces (rapid changes within the 50 ms window) keep resetting the timer, so they are never accepted.

This approach uses millis() instead of delay(), which means the rest of your code is not blocked while waiting for the debounce period. This is important for projects that need to do other things while monitoring buttons.

🔗Edge Detection: Press vs Release

In most real projects, you do not want to react continuously while a button is held -- you want to react once when it is pressed, or toggle a state each time it is pressed. This requires edge detection: comparing the current state to the previous state to find the moment of change.

  • Falling edge (HIGH to LOW): the moment the button is pressed (with pull-up wiring)
  • Rising edge (LOW to HIGH): the moment the button is released
View complete sketch
#define BUTTON_PIN 14
#define LED_PIN 2

int buttonState = HIGH;
int lastButtonState = HIGH;
bool ledOn = false;
unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 50;
int lastRawState = HIGH;

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
  delay(1000);
  Serial.println("Toggle LED with button press");
}

void loop() {
  int rawState = digitalRead(BUTTON_PIN);

  // Reset debounce timer on any change
  if (rawState != lastRawState) {
    lastDebounceTime = millis();
  }
  lastRawState = rawState;

  // Accept the state after debounce period
  if ((millis() - lastDebounceTime) > debounceDelay) {
    if (rawState != buttonState) {
      buttonState = rawState;

      // Falling edge: button was just pressed
      if (buttonState == LOW) {
        ledOn = !ledOn;  // Toggle LED state
        digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
        Serial.printf("LED toggled %s\n", ledOn ? "ON" : "OFF");
      }
    }
  }
}

🔗How It Works

The key line is the falling-edge check:

if (buttonState == LOW) {
  ledOn = !ledOn;
}

This runs only once -- at the exact moment the debounced state transitions from HIGH to LOW (the press). While the button is held, buttonState remains LOW but no longer differs from its previous value, so the toggle does not fire again.

Each press flips ledOn between true and false, toggling the LED. Release the button and nothing happens. Press again and it toggles back.

Tip: This debounce-and-edge-detect pattern is the foundation of almost every button-based project. Whether you are building a menu system, a counter, or a mode selector, you will use this same structure. It is worth understanding thoroughly.

🔗Multiple Buttons

When your project has more than one button, you can use arrays to avoid duplicating the debounce logic for each button. Here is the pattern:

#define NUM_BUTTONS 3
const int buttonPins[NUM_BUTTONS] = {14, 27, 26};

int buttonStates[NUM_BUTTONS];
int lastRawStates[NUM_BUTTONS];
unsigned long lastDebounceTimes[NUM_BUTTONS];
const unsigned long debounceDelay = 50;

void setup() {
  Serial.begin(115200);
  delay(1000);
  for (int i = 0; i < NUM_BUTTONS; i++) {
    pinMode(buttonPins[i], INPUT_PULLUP);
    buttonStates[i] = HIGH;
    lastRawStates[i] = HIGH;
    lastDebounceTimes[i] = 0;
  }
}

void loop() {
  for (int i = 0; i < NUM_BUTTONS; i++) {
    int rawState = digitalRead(buttonPins[i]);

    if (rawState != lastRawStates[i]) {
      lastDebounceTimes[i] = millis();
    }
    lastRawStates[i] = rawState;

    if ((millis() - lastDebounceTimes[i]) > debounceDelay) {
      if (rawState != buttonStates[i]) {
        buttonStates[i] = rawState;

        if (buttonStates[i] == LOW) {
          Serial.printf("Button %d pressed (GPIO %d)\n", i, buttonPins[i]);
        }
      }
    }
  }
}

This scales to any number of buttons. Just add the GPIO pin to the buttonPins array and increase NUM_BUTTONS.

🔗Troubleshooting

ProblemLikely causeSolution
Random HIGH/LOW readings without pressingFloating pin (no pull-up or pull-down)Use INPUT_PULLUP or add an external pull-up/pull-down resistor
One press registers multiple timesContact bounceAdd software debouncing (50 ms delay using millis())
Button does not respond at allWrong pin mode or wrong GPIO numberVerify pinMode() is set correctly and double-check the GPIO number on your board's pinout
Logic seems inverted (LED on when not pressed)Using pull-up but checking for HIGHWith INPUT_PULLUP, pressed = LOW. Check for buttonState == LOW to detect a press
Button works but delay() makes sketch unresponsiveUsing delay() for debouncingSwitch to millis()-based debouncing so the main loop is not blocked
Button on GPIO 0 triggers boot modeGPIO 0 is a strapping pinAvoid GPIO 0 for buttons (it controls boot mode). Use GPIO 14, 27, 26, or other safe pins

🔗What's Next?

Now that you can reliably read buttons, you have the building blocks for user input in any project. Here are some good next steps:

Project idea: Build a button-controlled menu on an OLED display. Use one button to cycle through options and another to select. Combine the debounce pattern from this article with the SSD1306 OLED display guide.