Articles › Microcontrollers and code
Reliable buttons on an ESP32: pull-ups and debounce with millis()
Why a floating input reads garbage, pull-up vs pull-down, how long switches bounce, a millis() debounce sketch, and the strapping pins to keep buttons off.
- An input pin connected to nothing floats and reads HIGH or LOW at random. It always needs a pull-up or pull-down.
- INPUT_PULLUP with the button wired to GND is the simplest setup, but the logic is inverted: pressed reads LOW.
- Switch contacts bounce for about 1.6 ms on average, but some take far longer. Use a 20–50 ms debounce time.
- On the original ESP32, avoid GPIO0, 2, 5, 12 and 15, and remember GPIO34–39 have no internal pull-ups. Other chips: check their datasheet.
A button looks like the simplest part in any circuit, yet wired straight in it usually shows two faults: it reads random values when nobody touches it, and one press counts several times. The first is fixed with a pull-up or pull-down resistor, the second with debouncing. This article covers both, with code and a list of pins to keep buttons off.
The examples use an ESP32 DevKit (ESP32-WROOM-32), Arduino IDE 2 and the arduino-esp32 core.
Floating pins: why it reads garbage
A GPIO set as an input has a very high resistance. Connected to nothing, its voltage is set by whatever noise is around, such as a hand coming close or a neighbouring wire. The program reads HIGH, then LOW, then HIGH. That state is called floating.
The fix is a resistor that gives the pin a definite default:
- Pull-up: resistor from the pin to 3.3 V, button from the pin to GND. Not pressed = HIGH, pressed = LOW.
- Pull-down: resistor from the pin to GND, button from the pin to 3.3 V. Not pressed = LOW, pressed = HIGH.
The ESP32 has pull-up and pull-down resistors built in. The arduino-esp32 docs give them as about 45 kΩ, enabled with pinMode().
| Mode | External resistor needed | Not pressed reads | Pressed reads | When to use |
|---|---|---|---|---|
INPUT | Yes (or it floats) | Depends on circuit | Depends on circuit | Pins 34–39, or long wires where you want a lower resistance |
INPUT_PULLUP | No | HIGH | LOW | The usual choice, button to GND |
INPUT_PULLDOWN | No | LOW | HIGH | When you want pressed to read HIGH |
Current through the button
When pressed, current flows from 3.3 V through the pull-up to GND. By Ohm’s law:
- Internal pull-up of about 45 kΩ: 3.3 ÷ 45,000 ≈ 0.073 mA
- External 10 kΩ: 3.3 ÷ 10,000 = 0.33 mA
Both are tiny and make no difference to a battery. For a button on the board with short leads, the internal pull-up is enough. If the button sits at the end of tens of centimetres of wire and picks up noise, switch to a lower external value such as 10 kΩ.
How long a button bounces
The metal contacts inside a button don’t touch once and settle. They strike and spring apart several times before they stay closed. A microcontroller reads fast enough to see every one of those, so it counts several presses.
Jack Ganssle measured the bounce of a range of switches:
- About 1.6 ms of bounce on average
- Most finished within about 6.2 ms
- One bad switch bounced for up to 157 ms on release
He recommends a debounce period in the 20–50 ms range, and found that a 100 ms delay is clearly noticeable while 50 ms still feels instant.
Debouncing doesn’t make a button feel slow. Keep it at 50 ms or less and nobody notices the delay.
A worked example: with 30 ms and a user pressing as fast as possible, each press has a held phase and a released phase, each of which must be stable for at least 30 ms, so 60 ms per press → at most about 1,000 ÷ 60 ≈ 16 presses per second, far faster than a finger can go.
A debounce sketch with millis()
The idea: read the pin; if the reading changed, note the time; accept the new value only once it has stayed the same for longer than the debounce time. This sketch toggles an LED once per press.
const int BUTTON_PIN = 4; // button from GPIO4 to GND
const int LED_PIN = 23; // LED + resistor from GPIO23 to GND
const unsigned long DEBOUNCE_MS = 30;
int stableState = HIGH; // accepted state (pull-up: not pressed = HIGH)
int lastReading = HIGH; // previous raw reading
unsigned long lastChange = 0; // when the raw reading last changed
bool ledOn = false;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
int reading = digitalRead(BUTTON_PIN);
if (reading != lastReading) { // still bouncing or just changed: restart the timer
lastChange = millis();
lastReading = reading;
}
if (millis() - lastChange >= DEBOUNCE_MS && reading != stableState) {
stableState = reading; // stable long enough: accept it
if (stableState == LOW) { // pull-up: pressed = LOW
ledOn = !ledOn;
digitalWrite(LED_PIN, ledOn);
}
}
// other work can run here, nothing is blocked by delay()
}
Two deliberate details:
millis() - lastChangesubtractsunsigned longvalues, so it stays correct whenmillis()wraps back to 0 (after about 49.7 days). Don’t writemillis() > lastChange + DEBOUNCE_MS.- It acts only on the change to LOW (the press edge), not on every loop while the button is held.
For the LED’s resistor on pin 23, see wiring an LED to an ESP32.
Pins to keep buttons off (strapping pins)
Strapping pins are read by the chip at power-up or reset to decide how to boot. A button or circuit that pulls one to the wrong level at that moment can stop the board booting or drop it into download mode. They differ by chip:
| Chip | Strapping pins | Also worth knowing |
|---|---|---|
| ESP32 (original) | GPIO0, 2, 5, 12, 15 | GPIO34–39 are input-only with no internal pull-up/pull-down; add your own resistor. Pins wired to the internal flash are off limits. |
| ESP32-C3 | GPIO2, 8, 9 | GPIO18/19 are USB by default |
| ESP32-S3 | GPIO0, 3, 45, 46 | GPIO19/20 are USB by default |
The table comes from Espressif’s datasheet and hardware design guidelines. For another chip, or a board where the maker wired a pin to something else (an on-board LED, say), check that board’s documents first. Good button pins on the original ESP32 include GPIO4, 13, 14, 18, 19, 21–23, 25–27, 32 and 33 (leave GPIO1 and 3 for the UART used to upload code).
Common mistakes
- No pull-up or pull-down. It triggers on its own, or when a hand comes near.
- INPUT_PULLUP on pins 34–39. Those pins have no internal resistors, so the pin still floats.
- Forgetting the inverted logic. Writing
if (digitalRead(pin) == HIGH)and wondering why it fires when not pressed. - A button on a strapping pin. The board won’t boot if the button is held at power-up.
- Too short a debounce, such as 2 ms, less than many switches bounce.
- Interrupts without debouncing. One press calls the handler several times.
Sources
- A Guide to Debouncing — Jack Ganssle
- A Guide to Debouncing, Part 2 — Jack Ganssle
- GPIO API — Arduino-ESP32 documentation (Espressif)
- ESP32 Series Datasheet — Espressif
- ESP32-C3 Hardware Design Guidelines: Schematic Checklist — Espressif
- ESP32-S3 Hardware Design Guidelines: Schematic Checklist — Espressif
FAQ
Can I just use delay(50) after reading the button instead of millis()?
In a sketch that does nothing else, yes. But delay() stops the whole program, so LEDs, sensors or Wi-Fi that need updating freeze meanwhile. With millis() the rest of the program keeps running.
With an interrupt, one press counts several times. Why?
The interrupt fires on every change of the pin, bounces included. Debounce inside the interrupt too, for example by remembering the time of the last accepted edge and ignoring edges that come sooner than your debounce time.
With a button on GPIO0 the board won't boot or won't take an upload.
GPIO0 is a strapping pin. If it is pulled LOW at power-up, the chip enters download mode instead of running your program. Move the button to a pin that isn't a strapping pin.
Circuits and code were tested on sample boards. Boards differ in pin layout, so check your board's pinout before you power anything.