ESP32: read buttons and debounce them in software
Wire a button to an ESP32 and write the debounce code that keeps your state from flickering. Includes the pattern I use in every project.
A button is the simplest input. It is also the source of more weird bugs than any other component. The button is not “broken.” The button is bouncing, and your code is reading the bounce.
This tutorial covers the wiring, the pull-up resistor (or why you do not need one on the ESP32), and the debounce pattern I copy-paste into every project.
What you need
- ESP32 dev board
- A momentary pushbutton (the four-leg tactile switches are fine)
- A wire, or just the button’s leads
No external resistors needed. The ESP32 has internal pull-ups.
Wiring
Connect one leg of the button to GPIO 4. Connect the other leg to GND.
That is the entire wiring. The internal pull-up (enabled in code) holds the GPIO high when the button is not pressed. When you press the button, the GPIO goes low. When you release, it goes high again.
The bouncing problem
When you press a button, the contacts do not close cleanly. They bounce for a few milliseconds, which means the GPIO sees high-low-high-low-high before settling low. If you read the GPIO every millisecond, you will see multiple presses for one physical press.
You can see this with a quick sketch:
#define BTN_PIN 4
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
}
void loop() {
if (digitalRead(BTN_PIN) == LOW) {
Serial.println("Pressed");
delay(50);
}
}
Press the button once. You might see “Pressed” printed 2-4 times. That is the bounce.
The debounce pattern
There are a lot of debounce libraries. I have tried most of them. The one I actually use in production is a state-machine pattern in plain code, because I can read it later and remember what it does.
#define BTN_PIN 4
enum ButtonState { IDLE, PRESSED, RELEASED };
ButtonState state = IDLE;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE_MS = 30;
bool buttonPressed = false; // set true on press, you clear it
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
}
void loop() {
bool reading = digitalRead(BTN_PIN) == LOW; // LOW = pressed
switch (state) {
case IDLE:
if (reading) {
state = PRESSED;
lastChange = millis();
}
break;
case PRESSED:
if (millis() - lastChange > DEBOUNCE_MS) {
if (reading) {
// confirmed press
buttonPressed = true;
state = RELEASED;
} else {
// it was a bounce, go back to idle
state = IDLE;
}
}
break;
case RELEASED:
if (!reading) {
// wait for the release to settle
state = IDLE;
lastChange = millis();
}
break;
}
if (buttonPressed) {
Serial.println("Press detected");
buttonPressed = false;
}
}
The pattern:
- IDLE: waiting for a press. When we see
LOW, move to PRESSED and start a timer. - PRESSED: waiting for the debounce window to expire. After 30 ms, check the pin again. If still pressed, it was a real press. If not, it was a bounce.
- RELEASED: waiting for the button to be released. Once it is, go back to IDLE.
30 ms is a good default. Mechanical buttons usually bounce for 5-15 ms. Membrane switches and cheaper buttons can bounce longer.
Edge-triggered vs. level-triggered
The pattern above is edge-triggered: it fires once on the press. Some projects want level-triggered behavior (e.g. “while the button is held, keep doing X”). For that, check the raw reading:
if (digitalRead(BTN_PIN) == LOW) {
// button is currently being held
}
Multiple buttons
The pattern scales. One state, lastChange, and reading per button:
#define BTN1_PIN 4
#define BTN2_PIN 5
#define BTN3_PIN 18
struct Button {
int pin;
bool pressed;
unsigned long lastChange;
int state; // 0=IDLE, 1=PRESSED, 2=RELEASED
};
Button buttons[] = {
{BTN1_PIN, false, 0, 0},
{BTN2_PIN, false, 0, 0},
{BTN3_PIN, false, 0, 0},
};
const int NUM_BUTTONS = 3;
void setup() {
Serial.begin(115200);
for (int i = 0; i < NUM_BUTTONS; i++) {
pinMode(buttons[i].pin, INPUT_PULLUP);
}
}
void loop() {
for (int i = 0; i < NUM_BUTTONS; i++) {
Button &b = buttons[i];
bool reading = digitalRead(b.pin) == LOW;
switch (b.state) {
case 0:
if (reading) { b.state = 1; b.lastChange = millis(); }
break;
case 1:
if (millis() - b.lastChange > 30) {
if (reading) { b.pressed = true; b.state = 2; }
else { b.state = 0; }
}
break;
case 2:
if (!reading) { b.state = 0; b.lastChange = millis(); }
break;
}
if (b.pressed) {
Serial.print("Button "); Serial.print(i); Serial.println(" pressed");
b.pressed = false;
}
}
}
Pull-up vs. pull-down
I use pull-up (button to GND) because:
- The ESP32’s internal pull-ups work fine. Pull-downs are weaker.
- Wiring is simpler: one GPIO, one GND, no resistor.
- Pressed = LOW is a common convention, and the code reads more clearly when “pressed” is the active-low state.
When the button is on a long wire
If the button is more than a meter or so from the ESP32, you can get noise on the line. Two fixes:
- Add a small ceramic capacitor (100 nF) across the button.
- Use shielded cable for the button wire.
For most projects (e.g. a button on a project box), neither is needed. I have used 3 m of unshielded wire for a button and it worked fine.
What to build next
- A menu system with multiple buttons (up, down, select).
- A wake-from-sleep button (combine with the deep sleep tutorial).
- A long-press handler (different actions for short vs. long press).
The wake-from-sleep version is in the book ESP32 Low Power. The menu system is in the book ESP32 UI Patterns.