If you need to drive a standard 16x2 character screen with an ESP32, the most reliable approach is using an I2C backpack (PCF8574) wired to GPIO 21 (SDA) and GPIO 22 (SCL). Because the ESP32 is a 3.3V logic device and the LCD requires 5V for the backlight, you must use a logic level shifter to prevent frying the ESP32's GPIO pins. The exact board targeted in this guide is the ESP32 DevKit V1 (38-pin variant), and the complete, compilable LCD display code provided below includes built-in I2C bus error handling to prevent watchdog crashes.

The Verdict: Choosing the Right LCD Interface

Before writing a single line of code, you have to decide how the microcontroller will talk to the screen. Hobbyists usually choose between three interfaces for HD44780-compatible displays. Here is the decision path to pick the right one for your bench:

Interface Pins Required 3.3V Safe? Best For
Parallel 4-Bit (Raw HD44780) 6 to 10 No (Needs 5V logic) 5V Arduinos (Uno/Mega) with spare pins
SPI TFT (ST7789/ILI9341) 5 Yes High-speed graphics, custom UIs, >30 FPS
I2C with PCF8574 Backpack 2 Yes (with level shifter) Simple text, low pin count, ESP32/ESP8266
Default Recommendation: Choose the I2C 1602 with PCF8574 backpack. It frees up your ESP32's GPIOs for sensors, requires only two data wires, and the LiquidCrystal_I2C library abstracts away the timing quirks of the HD44780 controller.

Parts List and ESP32 Pin Mapping

This build assumes you are using the most common off-the-shelf modules. Total BOM cost is roughly $12.

  • Microcontroller: ESP32 DevKit V1 (38-pin variant, ESP32-WROOM-32 module)
  • Display: 16x2 Character LCD (HD44780 controller) with PCF8574T I2C backpack pre-soldered
  • Level Shifter: BSS138 Bidirectional Logic Level Converter (4-channel)
  • Wiring: 22 AWG solid core jumper wires

The 3.3V vs 5V Logic Trap

The HD44780 LCD and its backlight require 5V. If you power the PCF8574 backpack with 5V, the module's onboard pull-up resistors will pull the SDA and SCL lines to 5V. Feeding 5V into the ESP32's 3.3V-tolerant GPIO pins will degrade or destroy them. You must shift the logic levels.

ESP32 to I2C LCD Pin Mapping (via BSS138 Level Shifter)
ESP32 DevKit V1 Pin Level Shifter (Low Voltage Side) Level Shifter (High Voltage Side) PCF8574 Backpack Pin
3V3 LV (Low Voltage Ref) - -
VIN (5V) - HV (High Voltage Ref) VCC
GND GND (LV Side) GND (HV Side) GND
GPIO 21 (SDA) TX1 / RX1 TX1 / RX1 SDA
GPIO 22 (SCL) TX2 / RX2 TX2 / RX2 SCL
Bench Tip: If you don't have a BSS138 level shifter on hand, you can power the backpack from the ESP32's 3.3V pin. The LCD logic will work, but the backlight will be very dim. To fix the dimness without 5V, desolder the backlight jumper on the back of the PCB and wire the LED anode directly to 5V through a 100Ω current-limiting resistor.

Complete ESP32 LCD Display Code

This code targets the ESP32 DevKit V1 using the Arduino IDE (board package: esp32 by Espressif Systems). It requires the LiquidCrystal_I2C library by Frank de Brabander (install via Arduino Library Manager). Unlike basic tutorials, this script includes I2C bus verification. If the display disconnects or the bus locks up, the code catches the error instead of triggering a hardware watchdog reset.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

// --- PIN DEFINITIONS & CONFIG ---
#define SDA_PIN 21
#define SCL_PIN 22
#define I2C_ADDRESS 0x27 // Change to 0x3F if using a PCF8574A backpack
#define LCD_COLS 16
#define LCD_ROWS 2

// Initialize the library with the I2C address and dimensions
LiquidCrystal_I2C lcd(I2C_ADDRESS, LCD_COLS, LCD_ROWS);

unsigned long lastUpdate = 0;
const long updateInterval = 1000; // Update every 1 second

void setup() {
  Serial.begin(115200);
  delay(500); // Allow serial monitor to connect
  Serial.println("ESP32 I2C LCD Booting...");

  // Initialize I2C bus with explicit ESP32 pins
  Wire.begin(SDA_PIN, SCL_PIN);
  
  // Verify I2C device presence before initializing LCD
  Wire.beginTransmission(I2C_ADDRESS);
  byte error = Wire.endTransmission();
  
  if (error == 0) {
    Serial.println("I2C LCD found at configured address.");
  } else {
    Serial.print("ERROR: I2C LCD not found! Error code: ");
    Serial.println(error);
    Serial.println("Check wiring, level shifter, and I2C address (0x27 vs 0x3F).");
    // Halt execution to prevent I2C bus lockups and watchdog panics
    while(1) { delay(1000); } 
  }

  // Initialize LCD
  lcd.init();
  lcd.backlight();
  
  // Custom character example (Thermometer icon)
  byte thermometer[8] = {
    B00100,
    B01010,
    B01010,
    B01010,
    B01110,
    B11111,
    B11111,
    B01110
  };
  lcd.createChar(0, thermometer);
  
  lcd.setCursor(0, 0);
  lcd.print("System Online");
  delay(1500);
  lcd.clear();
}

void loop() {
  unsigned long currentMillis = millis();
  
  if (currentMillis - lastUpdate >= updateInterval) {
    lastUpdate = currentMillis;
    
    // Simulate sensor data
    float tempC = 22.5 + (random(-10, 10) / 10.0);
    
    // Check I2C bus health before writing to prevent Core 1 panics
    Wire.beginTransmission(I2C_ADDRESS);
    if (Wire.endTransmission() == 0) {
      lcd.setCursor(0, 0);
      lcd.write((uint8_t)0); // Print custom thermometer char
      lcd.print(" Temp: ");
      lcd.print(tempC, 1);
      lcd.print("C ");
      
      lcd.setCursor(0, 1);
      lcd.print("Uptime: ");
      lcd.print(currentMillis / 1000);
      lcd.print("s ");
    } else {
      Serial.println("WARNING: I2C bus lost. Attempting reset...");
      Wire.end();
      Wire.begin(SDA_PIN, SCL_PIN);
    }
  }
}

Troubleshooting: First Three Things to Check

When your LCD display code compiles but the screen stays blank, don't start rewriting the sketch. Hardware and I2C addressing cause 95% of failures. Here are the first three things to check, ranked by probability.

1. The I2C Address Mismatch (0x27 vs 0x3F)

Symptom: The Serial Monitor prints ERROR: I2C LCD not found! Error code: 2 or the screen backlight turns on but no text appears.
Cause: Manufacturers use two different I2C expander chips. The PCF8574 defaults to 0x27. The PCF8574A defaults to 0x3F.
Fix: Run an I2C scanner sketch. If the scanner outputs I2C device found at address 0x3F, change line 7 in the code above to #define I2C_ADDRESS 0x3F.

2. The Contrast Potentiometer

Symptom: The backlight is on, row 1 shows solid white rectangular blocks, and row 2 is blank.
Cause: The LCD contrast voltage (V0) is misaligned. The code is actually working perfectly, but the liquid crystals are biased incorrectly.
Fix: Take a small Phillips screwdriver and turn the blue trimpot on the back of the PCF8574 backpack. Turn it counter-clockwise until the white blocks disappear and the text becomes crisp.

3. ESP32 Watchdog Panic from I2C Lockup

Symptom: The Serial Monitor outputs the exact error string: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1) followed by a register dump and a reboot.
Cause: The I2C bus has locked up due to electrical noise, missing pull-up resistors, or a loose SDA wire. The ESP32's Wire library waits indefinitely for an I2C acknowledgment that never comes, triggering the hardware watchdog timer (WDT).
Fix: Ensure your BSS138 level shifter has pull-ups on both sides. If using long wires (>15cm), drop the I2C clock speed by adding Wire.setClock(10000); immediately after Wire.begin() in the setup function.

Safety Callout: Never hot-swap I2C connections while the ESP32 is powered. The PCF8574 chip can latch up if VCC is applied before SDA/SCL, drawing excessive current and potentially damaging the ESP32's 3.3V voltage regulator. Always wire the bus with the USB cable unplugged.

Extending and Simplifying the Build

Once you have the baseline LCD display code running, you can adapt it to fit your specific project constraints.

How to Simplify

If you are running low on flash memory or just need a static dashboard, strip out the custom character generation (lcd.createChar) and the dynamic I2C health checks in the loop(). For a pure static display, move all lcd.print() commands into setup() and leave the loop() entirely empty. This reduces CPU wakeups and saves battery life in portable ESP32 builds.

How to Extend

  • Upgrade to a 20x4 Display: The 20x4 LCD uses the exact same PCF8574 backpack and HD44780 controller. Simply change the defines to #define LCD_COLS 20 and #define LCD_ROWS 4. The library automatically handles the non-linear memory addressing of the 4-row variant.
  • Add a Rotary Encoder: Use the ESP32Encoder library to read a KY-040 rotary encoder on GPIO 34 and 35. Map the encoder ticks to a menu system, using the LCD to display the selected parameter. Because the I2C bus is relatively slow (100kHz), avoid updating the LCD more than 10 times per second to prevent UI lag.
  • Network Integration: Since the ESP32 has built-in WiFi, use the WiFi.h library to fetch a local IP address or MQTT payload, and print it to the second row of the LCD. This turns the module into a standalone networked status monitor.

For deeper technical specifications on ESP32 GPIO constraints and I2C timing, refer to the official Espressif GPIO API Reference and the Arduino Wire Library Documentation. If you are mapping out multiple I2C sensors on the same bus alongside the LCD, consult the Adafruit I2C Address List to ensure your BME280 or MPU6050 doesn't conflict with the PCF8574's default address.