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
| Component | Qty | Notes | Buy |
|---|---|---|---|
| ESP32 dev board | 1 | AliExpress | Amazon.de .co.uk .com | |
| Push button (tactile) | 1 | AliExpress | Amazon.de .co.uk .com | |
| 10k ohm resistor | 1 | Optional (for external pull-down) | AliExpress | Amazon.de .co.uk .com |
| LED (red) | 1 | For visual feedback | AliExpress | Amazon.de .co.uk .com |
| 220 ohm resistor | 1 | For LED | AliExpress | Amazon.de .co.uk .com |
| Breadboard | 1 | AliExpress | Amazon.de .co.uk .com | |
| Jumper wires | ~6 | Male-to-male | AliExpress | 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"]| Component | Connects to |
|---|---|
| Button pin 1 | 3.3V |
| Button pin 2 | GPIO 14 |
| 10k resistor | GPIO 14 to GND |
| LED anode (long leg) | GPIO 2 via 220 ohm resistor |
| LED cathode (short leg) | GND |
Behavior:
| Button state | Voltage at GPIO 14 | digitalRead() returns |
|---|---|---|
| Not pressed | 0 V (pulled to GND by resistor) | LOW |
| Pressed | 3.3 V (connected to 3.3V) | HIGH |
This is intuitive: pressed = HIGH, not pressed = LOW.
🔗Wiring Option 2: Internal Pull-Up (Recommended)
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"]| Component | Connects to |
|---|---|
| Button pin 1 | GPIO 14 |
| Button pin 2 | GND |
| LED anode (long leg) | GPIO 2 via 220 ohm resistor |
| LED cathode (short leg) | GND |
Behavior:
| Button state | Voltage at GPIO 14 | digitalRead() returns |
|---|---|---|
| Not pressed | 3.3 V (pulled HIGH by internal resistor) | HIGH |
| Pressed | 0 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
| Code | Purpose |
|---|---|
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 == LOW | Checks 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
- Every loop iteration, the sketch reads the raw button state.
- If the raw state is different from the previous raw state, the debounce timer resets.
- 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. - 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
| Problem | Likely cause | Solution |
|---|---|---|
| Random HIGH/LOW readings without pressing | Floating pin (no pull-up or pull-down) | Use INPUT_PULLUP or add an external pull-up/pull-down resistor |
| One press registers multiple times | Contact bounce | Add software debouncing (50 ms delay using millis()) |
| Button does not respond at all | Wrong pin mode or wrong GPIO number | Verify 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 HIGH | With INPUT_PULLUP, pressed = LOW. Check for buttonState == LOW to detect a press |
Button works but delay() makes sketch unresponsive | Using delay() for debouncing | Switch to millis()-based debouncing so the main loop is not blocked |
| Button on GPIO 0 triggers boot mode | GPIO 0 is a strapping pin | Avoid 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:
- Digital vs Analog: GPIO Pins Explained -- Learn about analog input with potentiometers and analog output with PWM, expanding beyond simple on/off control.
- PWM on ESP32 -- Use button presses to step through brightness levels or control motor speed.
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.