Connecting a standard 16x2 parallel LCD to a microcontroller traditionally eats up six GPIO pins and requires a messy web of jumper wires. By adding a PCF8574 I2C backpack (usually pre-soldered or a $2 add-on), you reduce the wiring to just four lines: VCC, GND, SDA, and SCL. This guide covers the exact hardware specs, wiring, and Python code to get an lcd display to raspberry pi working reliably, specifically targeting the Raspberry Pi 5 architecture and the latest Raspberry Pi OS (Bookworm) environment.

Hardware Specs and Module Variants

Not all I2C backpacks are identical. The most common point of failure in LCD projects is buying a backpack with an unexpected I2C address or mismatched voltage logic. The Raspberry Pi 5 GPIO operates at 3.3V, but the HD44780 LCD controller and its backlight require 5V for proper brightness and contrast. Fortunately, the PCF8574 I/O expander accepts 3.3V logic HIGH signals from the Pi while being powered by the 5V rail.

Table 1: I2C Backpack and LCD Module Specifications
Component Variant Default I2C Address Operating Voltage Key Characteristics & Gotchas
PCF8574T Backpack 0x27 (39 decimal) 5V (Logic tolerates 3.3V) Most common. Address pins A0-A2 pulled HIGH. If you bridge them to GND, address shifts down to 0x20.
PCF8574AT Backpack 0x3F (63 decimal) 5V (Logic tolerates 3.3V) Less common. Base address is higher. Often sold in multi-packs to allow multiple LCDs on one bus.
1602A 5V LCD (Standard) N/A (Parallel) 5V Requires 5V for backlight. Contrast pin (V0) needs ~0.5V. The backpack's blue trimpot handles this.
1602A 3.3V LCD (Rare) N/A (Parallel) 3.3V Specifically designed for 3.3V logic. Backlight is dimmer. Do not mix with standard 5V backpacks without level shifters.
Callout Tip: The Raspberry Pi 5 uses the RP1 southbridge chip. While the primary I2C bus (I2C1) remains on GPIO 2 and GPIO 3, the internal routing has changed compared to the Pi 4. Always use the physical pin numbers or the updated gpiozero/lgpio libraries rather than legacy Broadcom (BCM) hardcoding if you are writing custom pin-mapping scripts.

Pin Mapping and Wiring Steps

We will power the backpack from the Pi 5's 5V rail to ensure the LCD backlight turns on, while using the 3.3V I2C data lines for communication. The Pi 5 has onboard 1.8kΩ pull-up resistors on the I2C lines to 3.3V, which is perfectly within the PCF8574's logic HIGH threshold (VCC * 0.7, which is 3.5V, but in practice, the chip reliably reads 3.3V as HIGH on the Pi's I2C bus).

Table 2: Raspberry Pi 5 to PCF8574 I2C Backpack Pin Mapping
Pi 5 Physical Pin Pi 5 GPIO (BCM) Function PCF8574 Backpack Pin Wire Color (Standard)
Pin 2 N/A (5V Power) 5V Power VCC Red
Pin 6 N/A (Ground) Ground GND Black
Pin 3 GPIO 2 I2C1 SDA SDA Blue
Pin 5 GPIO 3 I2C1 SCL SCL Yellow

Wiring Procedure

  1. De-energize the Pi: Disconnect the USB-C power supply from the Raspberry Pi 5 before connecting jumper wires to the GPIO header.
  2. Connect Power: Plug the red jumper into Physical Pin 2 (5V) and the black jumper into Physical Pin 6 (GND). Connect the other ends to the VCC and GND pins on the I2C backpack.
  3. Connect Data Lines: Plug the blue jumper into Physical Pin 3 (SDA) and the yellow jumper into Physical Pin 5 (SCL). Connect them to the corresponding SDA and SCL pins on the backpack. Note: If your backpack labels them backwards, swap them. The Pi's I2C1 pins are fixed, but some cheap backpacks mislabel the silk screen.
  4. Adjust Contrast: Locate the small blue potentiometer (trimpot) on the back of the I2C backpack. Use a small Phillips screwdriver to turn it fully counter-clockwise. You will adjust this later once the code is running.
  5. Power Up: Reconnect the USB-C power supply and boot into Raspberry Pi OS.

Python Code for Raspberry Pi 5 (Bookworm OS)

Target Board Variant: Raspberry Pi 5 (8GB) running Raspberry Pi OS (Bookworm 64-bit).

Bookworm strictly enforces PEP 668, meaning you cannot use pip install globally without breaking system packages. Furthermore, the legacy smbus library is deprecated. We will use smbus2 inside a Python virtual environment. This code includes a custom, lightweight HD44780 driver class so you aren't reliant on abandoned third-party LCD libraries.

Step 1: Enable I2C and Setup Environment

Open the terminal and run sudo raspi-config. Navigate to Interface Options > I2C and enable it. Reboot, then verify the bus with sudo i2cdetect -y 1. You should see 27 or 3f in the grid.

Next, set up the virtual environment:

mkdir ~/lcd_project && cd ~/lcd_project
python3 -m venv venv
source venv/bin/activate
pip install smbus2

Step 2: The Python Script

Save the following code as lcd_demo.py. It includes robust error handling for I2C bus failures.

import time
import sys
from smbus2 import SMBus

# --- CONFIGURATION ---
I2C_BUS = 1
# Change to 0x3F if you have a PCF8574AT backpack
I2C_ADDRESS = 0x27 

# HD44780 Commands
LCD_CLEARDISPLAY = 0x01
LCD_RETURNHOME = 0x02
LCD_ENTRYMODESET = 0x04
LCD_DISPLAYCONTROL = 0x08
LCD_FUNCTIONSET = 0x20
LCD_SETDDRAMADDR = 0x80

# Flags for display entry mode
LCD_ENTRYLEFT = 0x02
LCD_ENTRYSHIFTDECREMENT = 0x00

# Flags for display on/off control
LCD_DISPLAYON = 0x04
LCD_CURSOROFF = 0x00
LCD_BLINKOFF = 0x00

# Flags for function set
LCD_4BITMODE = 0x00
LCD_2LINE = 0x08
LCD_5x8DOTS = 0x00

# Backpack Pin Mapping (Standard PCF8574 to HD44780)
RS = 0x01
RW = 0x02
EN = 0x04
BL = 0x08  # Backlight pin
D4 = 0x10
D5 = 0x20
D6 = 0x40
D7 = 0x80

class I2C_LCD:
    def __init__(self, bus_num, addr):
        self.bus = SMBus(bus_num)
        self.addr = addr
        self.backlight = BL
        self.initialize()

    def write_i2c_block(self, data):
        try:
            self.bus.write_byte(self.addr, data)
        except OSError as e:
            print(f"I2C Write Failed: {e}")
            sys.exit(1)

    def pulse_enable(self, data):
        self.write_i2c_block(data | EN | self.backlight)
        time.sleep(0.0005)
        self.write_i2c_block((data & ~EN) | self.backlight)
        time.sleep(0.0001)

    def send_nibble(self, data, mode):
        high = (data & 0xF0) | mode | self.backlight
        low = ((data << 4) & 0xF0) | mode | self.backlight
        self.write_i2c_block(high)
        self.pulse_enable(high)
        self.write_i2c_block(low)
        self.pulse_enable(low)

    def send_byte(self, data, mode):
        self.send_nibble(data, mode)
        self.send_nibble(data, mode)

    def initialize(self):
        # Wake up sequence for 4-bit mode
        time.sleep(0.05)
        self.write_i2c_block(0x30 | self.backlight)
        time.sleep(0.005)
        self.write_i2c_block(0x30 | self.backlight)
        time.sleep(0.005)
        self.write_i2c_block(0x30 | self.backlight)
        time.sleep(0.001)
        self.write_i2c_block(0x20 | self.backlight) # Set 4-bit mode
        self.pulse_enable(0x20 | self.backlight)
        
        # Function set: 2 lines, 5x8 dots
        self.send_byte(LCD_FUNCTIONSET | LCD_4BITMODE | LCD_2LINE | LCD_5x8DOTS, 0)
        # Display on, cursor off
        self.send_byte(LCD_DISPLAYCONTROL | LCD_DISPLAYON | LCD_CURSOROFF | LCD_BLINKOFF, 0)
        # Clear display
        self.clear()
        # Entry mode: left to right
        self.send_byte(LCD_ENTRYMODESET | LCD_ENTRYLEFT | LCD_ENTRYSHIFTDECREMENT, 0)

    def clear(self):
        self.send_byte(LCD_CLEARDISPLAY, 0)
        time.sleep(0.002)

    def set_cursor(self, col, row):
        row_offsets = [0x00, 0x40, 0x14, 0x54]
        if row > 1:
            row = 1
        self.send_byte(LCD_SETDDRAMADDR | (col + row_offsets[row]), 0)

    def write_string(self, text):
        for char in text:
            self.send_byte(ord(char), RS)

if __name__ == "__main__":
    try:
        lcd = I2C_LCD(I2C_BUS, I2C_ADDRESS)
        lcd.set_cursor(0, 0)
        lcd.write_string("ElectricalFlux")
        lcd.set_cursor(0, 1)
        lcd.write_string("Pi 5 I2C Ready!")
        print("Message sent to LCD. Press Ctrl+C to exit.")
        while True:
            time.sleep(1)
    except FileNotFoundError as e:
        print(f"Error: I2C bus not found. Did you enable I2C in raspi-config?\nDetails: {e}")
    except OSError as e:
        if e.errno == 121:
            print("OSError: [Errno 121] Remote I/O error. Check wiring and I2C address.")
        else:
            print(f"Unexpected I2C Error: {e}")
    except KeyboardInterrupt:
        print("\nExiting...")
        sys.exit(0)

Debugging: Fixing "OSError: [Errno 121] Remote I/O error"

When working with I2C on the Raspberry Pi, the most notorious failure is the OSError: [Errno 121] Remote I/O error. This error means the Pi's I2C controller attempted to communicate with the target address, but the device did not acknowledge (ACK) the request on the bus.

The First Three Things to Check

  1. Verify the I2C Address: Run i2cdetect -y 1 in the terminal. If the grid is empty, your wiring is wrong or the backpack is dead. If you see 3f instead of 27, change the I2C_ADDRESS variable in the Python script to 0x3F.
  2. Check SDA/SCL Swap: Many cheap PCF8574 backpacks from overseas marketplaces have the SDA and SCL silk-screen labels swapped. Swap the blue and yellow wires on the Pi GPIO header and run i2cdetect again.
  3. Verify Common Ground: If you are powering the LCD from a separate 5V bench supply (instead of the Pi's 5V pin), you must connect the ground of the bench supply to the ground of the Raspberry Pi. Without a common ground reference, the 3.3V logic signals will float, causing Errno 121.

Other Ranked Causes for Errno 121

  • Cable Length/Capacitance: Standard Dupont wires longer than 30cm introduce too much capacitance for the I2C bus at 100kHz. Keep I2C runs under 20cm, or lower the bus speed to 10kHz in /boot/firmware/config.txt by adding dtparam=i2c_baudrate=10000.
  • Missing Pull-ups: The Pi 5 has onboard pull-ups, but if you are using a custom HAT or a Pi Zero 2 W, you may need external 4.7kΩ pull-up resistors between SDA/SCL and 3.3V.
  • Dead PCF8574 Chip: If the backlight turns on but i2cdetect shows nothing, the I/O expander chip may have been fried by a previous 5V-to-GPIO short. Replace the backpack.

Extending and Simplifying the Build

How to Extend the Project

Once the LCD is rendering text, the natural next step is displaying live sensor data. Because I2C is a bus, you can wire a BME280 temperature/humidity sensor to the exact same SDA and SCL pins. The BME280 typically uses address 0x76 or 0x77, which won't conflict with the LCD's 0x27. You can update the Python script to read the sensor via the smbus2 library and push the formatted string to lcd.write_string() inside a while loop.

For networked projects, integrate the paho-mqtt library. Subscribe to an MQTT topic (e.g., home/lab/status) and update the LCD whenever a new payload arrives, turning your Pi into a dedicated smart-home status dashboard.

How to Simplify the Build

If soldering header pins to a raw 1602 LCD or dealing with Dupont wire spaghetti is a barrier, switch to a Grove I2C LCD module (like the Seeed Studio Grove - LCD RGB Backlight). These modules use a keyed 4-pin connector that prevents reversed wiring, feature an integrated I2C chip (often an STM32 or custom ASIC instead of the PCF8574), and allow software-controlled RGB backlight colors. They cost roughly $8-$12 (compared to $4 for the raw components) but eliminate the contrast trimpot adjustment and address-guessing entirely.

Alternatively, if you only need to display system stats (CPU temp, IP address) and don't want to write Python, install oled-stats or use a pre-configured Docker container like lcdproc, which handles the I2C daemonization and screen rendering natively in the background.