esp32 beginner 20 min

ESP32: GPIO basics, digital read, digital write, and the pull-up trick

How to read and write the ESP32's GPIO pins, when to use the internal pull-ups, and the input-only pins that catch everyone once.

Code available for: ESP32 ArduinoArduino CMicroPython
Published Aug 25, 2026

GPIO is the part of the ESP32 you spend 90% of your time on. Read a button, light an LED, drive a relay, talk to a sensor. All GPIO. Get this foundation right and every project gets easier. Get it wrong and you spend a Saturday debugging a floating pin that is reading random noise.

This tutorial covers the basics: digital write (output), digital read (input), the internal pull-up trick that lets you wire a button with no resistors, and the four pins that are input-only and cannot do output.

What you need

  • An ESP32 dev board
  • An LED and a 220 ohm resistor (any color)
  • A momentary pushbutton (the four-leg tactile kind)
  • Three jumper wires
  • The toolchain install from the previous tutorial

The GPIO map you need to memorize

The ESP32 has 34 GPIO pins, but not all of them are usable for general work.

Wire key: GPIO
GPIO rangeNotes
GPIO 0Boot pin. Avoid unless you know why.
GPIO 1, 3Used for serial debug. Avoid.
GPIO 2Boot pin, often has the onboard LED.
GPIO 4-5General purpose. Safe.
GPIO 6-11Connected to internal flash. Never use these.
GPIO 12-15Boot pins. General purpose is OK if you are careful about boot state.
GPIO 16-33General purpose. Safe.
GPIO 34, 35, 36, 39Input only. Cannot drive output.
GPIO 37, 38Not exposed on most dev boards.

The pins that say “Never use these” (GPIO 6-11) are physically connected to the ESP32’s flash chip. Using them as GPIO crashes the program.

The pins that are input-only (GPIO 34-39, minus 37-38 which are not exposed) cannot be set as outputs. The IDE silently ignores pinMode() on them if you ask for OUTPUT. The fix is to pick a different pin.

The exact safe-pin list depends on your board. Most dev boards expose GPIO 4, 5, 13, 14, 16, 17, 18, 19, 21, 22, 23, 25, 26, 27, 32, 33. Use one of those for first projects. You will avoid every weird boot mode and every input-only trap.

Wiring

The blink circuit:

ESP32 GPIO 4  --[ 220 ohm ]-- LED anode (long leg) -- LED cathode (short leg) -- GND
ESP32 GPIO 5  --[ button ]-- GND  (yes, just one wire + the button)

That is the entire circuit. The button needs no resistor because of the internal pull-up (covered below).

The code: output

const int LED_PIN = 4;

void setup() {
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_PIN, HIGH);
  delay(500);
  digitalWrite(LED_PIN, LOW);
  delay(500);
}

Upload. The LED blinks at 1 Hz. HIGH is 3.3V, LOW is 0V. The 220 ohm resistor limits current to about 10 mA, which is bright enough for most LEDs and well within the ESP32’s per-pin spec.

The current limit per GPIO is 40 mA absolute max, 20 mA recommended. Going over 20 mA for sustained periods damages the pin over time.

The code: input with internal pull-up

const int LED_PIN = 4;
const int BTN_PIN = 5;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  pinMode(BTN_PIN, INPUT_PULLUP);   // <-- the magic
}

void loop() {
  if (digitalRead(BTN_PIN) == LOW) {
    digitalWrite(LED_PIN, HIGH);
  } else {
    digitalWrite(LED_PIN, LOW);
  }
}

Upload. Press the button. The LED lights up. Release. The LED goes off.

The trick is INPUT_PULLUP. It enables the ESP32’s internal pull-up resistor (about 45k ohms) on GPIO 5. This holds the pin HIGH when the button is not pressed. When you press the button, the pin connects to GND through the switch contacts, pulling it LOW.

Read that again. Pressed = LOW. Released = HIGH. This is the opposite of what feels natural. Most button tutorials on Arduino use the same convention for the same reason: it lets you skip the external resistor.

The reason pinMode(BTN_PIN, INPUT_PULLUP) works:

  • No external resistor needed. Saves wiring.
  • The pin cannot float (always reads HIGH or LOW, never random noise).
  • Pressed-vs-released is unambiguous.

The reason pinMode(BTN_PIN, INPUT) (no pull-up) does not work:

  • The pin is floating when the button is not pressed.
  • Reads random noise. Your code sees “pressed” when you did not press.
  • This is the bug that causes 80% of “my button does not work” complaints.

Always use INPUT_PULLUP for buttons unless you have a specific reason not to. Same applies to limit switches, reed switches, and most other momentary-contact inputs.

The code: debounce

The next bug you will hit is button bounce. The button makes and breaks contact a few times before settling. Your code sees multiple presses for one physical press. The fix is software debouncing.

The pattern I use in every project:

const int LED_PIN = 4;
const int BTN_PIN = 5;

enum ButtonState { IDLE, PRESSED, RELEASED };
ButtonState state = IDLE;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE_MS = 30;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
  bool reading = digitalRead(BTN_PIN) == LOW;

  switch (state) {
    case IDLE:
      if (reading) {
        state = PRESSED;
        lastChange = millis();
      }
      break;
    case PRESSED:
      if (millis() - lastChange > DEBOUNCE_MS) {
        if (reading) {
          // confirmed press
          digitalWrite(LED_PIN, !digitalRead(LED_PIN));   // toggle
          state = RELEASED;
        } else {
          state = IDLE;
        }
      }
      break;
    case RELEASED:
      if (!reading) {
        state = IDLE;
        lastChange = millis();
      }
      break;
  }
}

Press the button. The LED toggles. Press it again. The LED toggles back. One press, one toggle, no bounce.

The pattern is a state machine. Three states:

  • IDLE: waiting for a press. When we see LOW, move to PRESSED.
  • PRESSED: waiting 30 ms for the bounce to settle. After that, if still LOW, it was a real press. Otherwise it was noise.
  • RELEASED: waiting for the button to be released before we accept another press.

30 ms is enough for most buttons. Cheaper buttons and membrane switches might need 50-100 ms.

Reading from input-only pins (GPIO 34-39)

If you need an analog sensor or a button on GPIO 34, the code is the same except you cannot drive them as output. pinMode(34, INPUT_PULLUP) works for reading.

void setup() {
  pinMode(34, INPUT);   // GPIO 34 has no internal pull-up
  // no INPUT_PULLUP option for input-only pins
}

void loop() {
  int v = digitalRead(34);
  // ...
}

GPIO 34-39 do not have internal pull-ups. If you wire a button to one of these, you need an external 10k pull-up resistor. Most of the time, just use GPIO 4 or another general-purpose pin instead.

What you learned

  • The ESP32 has 34 GPIO pins, but 6 of them (6-11) are off-limits, 4 (34-39 minus 37-38) are input-only, and 5 (0, 1, 2, 3, 12-15) have boot-mode caveats.
  • digitalWrite(pin, HIGH) sets a pin to 3.3V. LOW is 0V.
  • INPUT_PULLUP enables the internal pull-up resistor so you can wire a button with no external resistor.
  • Buttons need software debounce to work reliably. The state-machine pattern above is the simplest version that actually works.

When something breaks

  • Button reads as pressed when nothing is pressed. No pull-up. Add INPUT_PULLUP or wire an external 10k resistor.
  • Button reads as pressed every 2-3 presses. Debounce is too short for your button. Try 50 or 100 ms.
  • LED does not light. Check the wiring polarity. The LED’s longer leg is the anode; that side goes to the GPIO side, not to GND.
  • Code does not compile. You used a GPIO number that does not exist (e.g. GPIO 37, 38 on a board that does not expose them). Check the silkscreen.

What to build next

  • The button debounce tutorial goes deeper on the state-machine pattern and shows you how to handle long-press vs short-press.
  • The analog ADC tutorial reads a potentiometer or analog sensor, which is the other half of GPIO (analog, not just digital).
  • The deep sleep tutorial uses wake-on-button as a way to save battery on projects that only need to run when you press something.