Skip to main content

LCD1602 / LCD2004 I2C

YouTube Video

Introduction

Character LCDs are still one of the easiest ways to add a readable user interface to a Raspberry Pi Pico project. In this tutorial, you use both the LCD1602 and LCD2004 I2C modules with the same MicroPython library.

The nice part about the I2C backpack is that it reduces the wiring down to four connections:

  • VCC
  • GND
  • SDA
  • SCL

That means you can focus on writing messages, menus, counters, sensor readouts, and project status screens instead of dealing with a full parallel LCD interface.

What Is the Difference Between LCD1602 and LCD2004?

  • LCD1602 has 16 columns x 2 rows
  • LCD2004 has 20 columns x 4 rows

Both displays are based on the same HD44780-style character controller family, so the programming model is very similar. The main difference is the number of rows and columns you pass into the library.

Components Needed

ComponentQuantity
Raspberry Pi Pico or Pico W1
Micro USB cable1
Breadboard1
Jumper wiresSeveral
I2C LCD1602 or LCD2004 module1

Wiring

LCD1602 Wiring

Wire Diagram

LCD2004 Wiring

LCD1602 wiring

Typical Raspberry Pi Pico wiring:

  • VCC -> 5V or VBUS on the Pico
  • GND -> GND
  • SDA -> GP0
  • SCL -> GP1

If you prefer a different I2C bus or different GPIO pins, that is fine. Just make sure the machine.I2C(...) setup in your code matches your wiring.

Check the I2C Address First

Before trying to display text, it is a good idea to scan the I2C bus and confirm the address of the module.

from machine import Pin, I2C

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
print('I2C devices found:', [hex(device) for device in i2c.scan()])
Shell Output

Common I2C LCD addresses:

  • 0x27
  • 0x3F

Your library already checks for both of these by default and will fall back to the first detected device if needed.

LCD Library

Save this as nerdcave_lcd.py on your Pico:

import machine
import time


class LCD:
def __init__(self, i2c, addr=None, cols=16, rows=2, blen=1):
self.bus = i2c
self.addr = self._scan_address(addr)
self.cols = cols
self.rows = rows
self.blen = blen

# Row offsets for different LCD sizes
if rows == 1:
self.row_offsets = [0x00]
elif rows == 2:
self.row_offsets = [0x00, 0x40]
elif rows == 4:
self.row_offsets = [0x00, 0x40, 0x14, 0x54]
else:
raise ValueError("Unsupported LCD size")

self._init_lcd()

# --------------------------
# Initialization
# --------------------------
def _init_lcd(self):
self.send_command(0x33)
time.sleep(0.005)
self.send_command(0x32)
time.sleep(0.005)
self.send_command(0x28) # 4-bit, 2 line
time.sleep(0.005)
self.send_command(0x0C) # Display ON, cursor OFF
time.sleep(0.005)
self.clear()

self.openlight()

def _scan_address(self, addr):
devices = self.bus.scan()
if not devices:
raise Exception("No I2C devices found")

if addr is not None:
if addr in devices:
return addr
raise Exception(f"LCD at 0x{addr:02X} not found")

for default in (0x27, 0x3F):
if default in devices:
return default

return devices[0]

# --------------------------
# Low-level I2C
# --------------------------
def _write_word(self, data):
if self.blen:
data |= 0x08
else:
data &= 0xF7
self.bus.writeto(self.addr, bytearray([data]))

def _write_byte(self, data, mode):
high = data & 0xF0
low = (data & 0x0F) << 4

self._write_4bits(high | mode)
self._write_4bits(low | mode)

def _write_4bits(self, data):
self._write_word(data | 0x04) # EN = 1
time.sleep(0.0005)
self._write_word(data & ~0x04) # EN = 0
time.sleep(0.0001)

def send_command(self, cmd):
self._write_byte(cmd, 0x00)

def send_data(self, data):
self._write_byte(data, 0x01)

# --------------------------
# High-level functions
# --------------------------
def clear(self):
self.send_command(0x01)
time.sleep(0.002)

def home(self):
self.send_command(0x02)
time.sleep(0.002)

def openlight(self):
self._write_word(0x08)

def backlight(self, state=True):
self.blen = 1 if state else 0
self._write_word(0x00)

def set_cursor(self, col, row):
if row >= self.rows:
row = self.rows - 1
if col >= self.cols:
col = self.cols - 1

addr = 0x80 + self.row_offsets[row] + col
self.send_command(addr)

def write(self, col, row, text):
self.set_cursor(col, row)
for char in text:
self.send_data(ord(char))

def message(self, text):
row = 0
col = 0

for char in text:
if char == "\n":
row += 1
col = 0
if row >= self.rows:
break
self.set_cursor(col, row)
else:
if col >= self.cols:
continue
self.send_data(ord(char))
col += 1

How This Library Works

Important ideas in your library:

  • cols and rows let the same class work with both 16x2 and 20x4 displays.
  • row_offsets handles the internal DDRAM layout for different LCD sizes.
  • _scan_address() automatically finds the LCD on the I2C bus.
  • _write_byte() and _write_4bits() send data in 4-bit mode through the I2C backpack.
  • message() lets you write multi-line text using \n.
  • write(col, row, text) gives you more control over exact cursor position.

Example 1 - LCD1602 Hello World

This is the simplest starting example for a 16x2 screen.

from machine import Pin, I2C
from nerdcave_lcd import LCD
import time

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
lcd = LCD(i2c, cols=16, rows=2)

lcd.message("Hello World!\nRaspberry Pi Pico")
time.sleep(3)
lcd.clear()

Example 2 - LCD2004 Hello World

For a 20x4 display, the code is almost the same. The key change is cols=20, rows=4.

from machine import Pin, I2C
from nerdcave_lcd import LCD
import time

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
lcd = LCD(i2c, cols=20, rows=4)

lcd.message("LCD2004 Demo\nLine 2\nLine 3\nLine 4")
time.sleep(4)
lcd.clear()

Example 3 - Write Text at a Specific Position

Use write(col, row, text) when you want layout control.

from machine import Pin, I2C
from nerdcave_lcd import LCD
import time

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
lcd = LCD(i2c, cols=16, rows=2)

lcd.write(0, 0, "Top Left")
lcd.write(5, 1, "Bottom")
time.sleep(3)
lcd.clear()

Example 4 - Counter Example

This is a good first project pattern because it shows how to update just part of the screen.

from machine import Pin, I2C
from nerdcave_lcd import LCD
import time

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
lcd = LCD(i2c, cols=16, rows=2)

lcd.write(0, 0, "Counting:")

for i in range(101):
lcd.write(0, 1, "Value: {:3d} ".format(i))
time.sleep(0.1)

Example 5 - Backlight Control

Your library includes a backlight helper.

from machine import Pin, I2C
from nerdcave_lcd import LCD
import time

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
lcd = LCD(i2c, cols=16, rows=2)

lcd.message("Backlight ON")
time.sleep(2)

lcd.backlight(False)
time.sleep(2)

lcd.backlight(True)
lcd.clear()
lcd.message("Backlight ON")

Example 6 - Temperature or Sensor Display Layout

This kind of layout is useful when you start showing live data from a sensor.

from machine import Pin, I2C
from nerdcave_lcd import LCD
import time

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
lcd = LCD(i2c, cols=20, rows=4)

temperature = 24.6
humidity = 58.2

lcd.write(0, 0, "Weather Station")
lcd.write(0, 1, "Temp: {:.1f} C".format(temperature))
lcd.write(0, 2, "Humidity: {:.1f}%".format(humidity))
lcd.write(0, 3, "Status: OK")

Code Explanation

1. Create the I2C bus

i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)

This initializes I2C bus 0 using GP0 for SDA and GP1 for SCL.

2. Create the LCD object

lcd = LCD(i2c, cols=16, rows=2)

For LCD1602, use:

lcd = LCD(i2c, cols=16, rows=2)

For LCD2004, use:

lcd = LCD(i2c, cols=20, rows=4)

3. Display multi-line text

lcd.message("Hello\nWorld")

The \n moves to the next row.

4. Display positioned text

lcd.write(0, 1, "Bottom row")

This writes text starting at column 0, row 1.

5. Clear the screen

lcd.clear()

This removes the current contents and returns the display to a blank screen.

Common Problems

Nothing shows on the display

  • check power and ground
  • check that SDA and SCL are wired correctly
  • adjust the contrast potentiometer on the I2C backpack
  • run an I2C scan to confirm the device address

Wrong address

If the display is not found automatically, pass the address manually:

lcd = LCD(i2c, addr=0x27, cols=16, rows=2)

or

lcd = LCD(i2c, addr=0x3F, cols=20, rows=4)

Text wraps strangely on LCD2004

This usually happens when the row/column size is wrong. Make sure you are using:

lcd = LCD(i2c, cols=20, rows=4)

Backlight is on but no text appears

  • contrast may be set incorrectly
  • address may be wrong
  • the display may be powered, but I2C communication is failing

Suggested Images to Add Later

Good images for this page would be:

  • LCD1602 breadboard photo
  • LCD2004 breadboard photo
  • screenshot/photo of hello world output
  • screenshot/photo of a 4-line LCD2004 demo

Summary

In this tutorial, you used one MicroPython library to control both LCD1602 and LCD2004 I2C character displays with the Raspberry Pi Pico. The main thing that changes between the two modules is the display size:

  • LCD1602 -> cols=16, rows=2
  • LCD2004 -> cols=20, rows=4

With this setup, you can build menus, counters, status panels, and clean text interfaces for all kinds of Raspberry Pi Pico projects.