ESP32 Digital Input Handling

Reading Push Buttons with Internal Pull-Up Resistors, Avoiding Repeated Triggers, and Implementing Software Debouncing

1. Introduction

This guide explains how to connect two mechanical push-button switches to an ESP32 development board and read their state using digital input pins. We will cover the wiring, the role of pull-up resistors, how to write firmware using the Arduino framework, and — most importantly — how to solve a very common beginner problem: a single button press being detected as multiple presses due to switch "bounce" and fast microcontroller loop execution.

What you will learn: Wiring switches to GPIO pins, using INPUT_PULLUP mode, reading pin state with digitalRead(), and eliminating false/repeated triggers using delays, blocking while loops, and debounce timing.

2. Circuit Setup

Two push buttons are connected directly to the ESP32. Each switch has two terminals: one terminal connects to a GPIO pin, and the other terminal connects to GND. No external resistor is required because we will enable the ESP32's internal pull-up resistor in software.

SwitchGPIO PinOther Terminal
Switch 1GPIO 12GND
Switch 2GPIO 27GND

Schematic Diagram

                 3.3V (Internal Pull-Up, enabled in code)
                   |
                  [R] internal
                   |
      GPIO12 ------+-------- SWITCH 1 --------GND
                   |
      GPIO27 ------+-------- SWITCH 2 --------GND

  Idle state   : Pin reads HIGH (pulled up internally)
  Pressed state: Pin reads LOW  (switch connects pin to GND)
ESP32
GPIO 12
──
Switch 1
──
GND
ESP32
GPIO 27
──
Switch 2
──
GND
On a breadboard, each switch leg simply straddles two rows: one row wired to the GPIO pin, the other row wired to the ground rail. If you are using a simple interface/expansion board with built-in tactile switches, the wiring is already done for you internally — you only need to know which GPIO each switch is mapped to.

3. Understanding Pull-Up Resistors

A digital input pin left "floating" (not connected to anything definite) can randomly read HIGH or LOW due to electrical noise. A pull-up resistor solves this by weakly connecting the pin to a HIGH voltage (3.3V) when nothing else is driving it. When the switch is pressed, it overrides this weak pull-up and forces the pin to GND (LOW).

External vs. Internal Pull-Up

ModeWiring NeededBehavior
INPUT External pull-up/pull-down resistor required Pin floats if nothing is connected — unreliable without extra hardware
INPUT_PULLUP None — resistor is built into the ESP32 chip Pin reads HIGH by default, LOW when switch is pressed
Key takeaway: By setting a pin mode to INPUT_PULLUP, the ESP32 enables its internal resistor. This means the pin is HIGH at rest, and only goes LOW when the button is pressed and connects the pin to GND — eliminating the need for any external resistor.

4. Writing the Basic Sketch

The sketch below declares both switch pins, configures them as pull-up inputs, and continuously checks their state inside loop().

Full Example Code — "Switch Test"

// ---------- Switch Test Sketch ----------

const int switch1 = 12;   // GPIO connected to Switch 1
const int switch2 = 27;   // GPIO connected to Switch 2

void setup() {
  Serial.begin(115200);            // Start serial communication
  pinMode(switch1, INPUT_PULLUP);  // Enable internal pull-up on Switch 1
  pinMode(switch2, INPUT_PULLUP);  // Enable internal pull-up on Switch 2
}

void loop() {
  if (digitalRead(switch1) == LOW) {
    Serial.println("Switch 1 pressed");
    delay(500);
  }

  if (digitalRead(switch2) == LOW) {
    Serial.println("Switch 2 pressed");
    delay(500);
  }
}
        

Step-by-Step Explanation

  1. Declare two constants holding the GPIO numbers used for the switches (12 and 27).
  2. Inside setup(), initialize the Serial Monitor at 115200 baud for debugging output.
  3. Configure both pins as INPUT_PULLUP so the internal resistor keeps them HIGH at rest.
  4. Inside loop(), use digitalRead() to check each pin's state.
  5. When a pin reads LOW, it means the corresponding switch is currently pressed.
  6. Print a confirmation message to the Serial Monitor whenever a press is detected.

Commands Used in This Sketch

Serial.begin(115200); // Initialize serial communication pinMode(switch1, INPUT_PULLUP); // Configure GPIO 12 as pull-up input pinMode(switch2, INPUT_PULLUP); // Configure GPIO 27 as pull-up input digitalRead(switch1); // Read current state of Switch 1 digitalRead(switch2); // Read current state of Switch 2 Serial.println("Switch 1 pressed"); // Print message to Serial Monitor delay(500); // Pause execution for 500 milliseconds

5. The Repeat-Trigger Problem Important

When you upload and test the sketch above, holding a button down for a longer time causes the message to print repeatedly, once every delay(500) interval, for as long as the button remains pressed. This happens because the loop() function keeps re-checking the pin state continuously.

Without any delay at all: if you remove the delay() call entirely, a single button press can trigger the message many times in rapid succession — sometimes 5, 8, or more repetitions — because the ESP32's loop executes extremely fast and the button remains physically closed for many loop cycles.
Button held down over time:
HIGH ─┐                             ┌── HIGH
      │  LOW (pressed)              │
      └─────────────────────────────┘
       ↑        ↑        ↑        ↑
     loop()   loop()   loop()   loop()   <- each iteration re-detects "pressed"
     print    print    print    print       = multiple prints for ONE press

6. Solutions to the Repeat-Trigger Problem

6.1 Adding a Delay After Detection

A simple delay(500) placed right after detecting a press slows down how often the loop can re-check the pin, reducing (but not eliminating) repeated prints while the button is held.

6.2 Blocking with a while Loop

A more effective approach is to trap the program inside a while loop for as long as the button remains pressed. The program does nothing until the button is released, guaranteeing that the action executes exactly once per press.

void loop() {
  if (digitalRead(switch1) == LOW) {
    Serial.println("Switch 1 pressed");
    while (digitalRead(switch1) == LOW); // wait here until released
  }

  if (digitalRead(switch2) == LOW) {
    Serial.println("Switch 2 pressed");
    while (digitalRead(switch2) == LOW); // wait here until released
  }
}
        
How it works: The empty while loop (note the semicolon instead of a code block) does nothing but repeatedly re-check the pin. As long as the button stays pressed (pin is LOW), the program is "stuck" here. The moment the button is released (pin returns HIGH), the while condition becomes false and the program continues.

Commands Used in This Solution

while (digitalRead(switch1) == LOW); // Block until Switch 1 is released while (digitalRead(switch2) == LOW); // Block until Switch 2 is released
Limitation: This method is "blocking" — the ESP32 cannot do anything else (like checking other sensors or updating a display) while it waits for the button to be released. For most simple test programs this is fine, but in larger projects a non-blocking debounce technique is preferred.

7. Software Debouncing

Even with the blocking while loop, you may occasionally still see a double-print when releasing the button. This is caused by contact bounce — a mechanical switch does not make or break contact instantly. Instead, the metal contacts physically vibrate for a few milliseconds before settling, producing several rapid HIGH/LOW transitions that the ESP32 (running extremely fast) can misinterpret as multiple presses.

Real signal during a single press (zoomed in on the transition):

HIGH ─┐   ┌─┐ ┌───────────────── (bounce noise on contact)
      │   │ │ │
      └───┘ └─┘
      Contact "bounces" for ~5-20 ms before settling to a clean LOW

A short debounce delay (commonly around 20 milliseconds) after detecting a state change is enough time for the mechanical contact to fully settle, preventing false re-triggers.

Final Debounced Sketch

const int switch1 = 12;
const int switch2 = 27;
const int debounceDelay = 20;   // milliseconds

void setup() {
  Serial.begin(115200);
  pinMode(switch1, INPUT_PULLUP);
  pinMode(switch2, INPUT_PULLUP);
}

void loop() {
  if (digitalRead(switch1) == LOW) {
    delay(debounceDelay);              // wait for contact to settle
    Serial.println("Switch 1 pressed");
    while (digitalRead(switch1) == LOW);
    delay(debounceDelay);              // debounce the release too
  }

  if (digitalRead(switch2) == LOW) {
    delay(debounceDelay);
    Serial.println("Switch 2 pressed");
    while (digitalRead(switch2) == LOW);
    delay(debounceDelay);
  }
}
        

Commands Used in This Sketch

const int debounceDelay = 20; // Debounce time in milliseconds delay(debounceDelay); // Wait for switch contact to stabilize on press while (digitalRead(switch1) == LOW); // Hold until Switch 1 is released delay(debounceDelay); // Wait for switch contact to stabilize on release
Result: With debouncing in place, pressing and releasing the button — no matter how it is physically pressed — produces exactly one clean message, with no duplicate or missed events.

8. Summary

ConceptPurpose
INPUT_PULLUPEnables internal resistor so pin idles HIGH without external hardware
digitalRead()Reads current pin state (HIGH or LOW)
delay()Simple way to slow down repeated detection
while (pin == LOW);Blocks execution until button is released — ensures single trigger per press
Debounce delay (~20 ms)Filters out mechanical contact "bounce" noise for clean, reliable readings
These fundamentals — pull-up configuration, edge detection, and debouncing — apply not only to the ESP32 but to virtually every microcontroller platform when reading mechanical switches or buttons. Mastering them here will make working with any digital input in future projects far more reliable.