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:
VCCGNDSDASCL
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?
LCD1602has16 columns x 2 rowsLCD2004has20 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
| Component | Quantity |
|---|---|
| Raspberry Pi Pico or Pico W | 1 |
| Micro USB cable | 1 |
| Breadboard | 1 |
| Jumper wires | Several |
| I2C LCD1602 or LCD2004 module | 1 |
Wiring
LCD1602 Wiring

LCD2004 Wiring

Typical Raspberry Pi Pico wiring:
VCC -> 5VorVBUSon the PicoGND -> GNDSDA -> GP0SCL -> 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()])

Common I2C LCD addresses:
0x270x3F
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:
colsandrowslet the same class work with both16x2and20x4displays.row_offsetshandles 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
SDAandSCLare 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=2LCD2004 -> 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.