MicroPython SSD1306 OLED Animation on Raspberry Pi Pico
Arduino is still the default in many schools, but the Raspberry Pi Pico + MicroPython combo is easier when students already know Python. The Pico has far more RAM than an Uno, so OLED animations stop being a PROGMEM puzzle and become a normal bytearray loop.
This guide covers I2C wiring, the official-style ssd1306 driver with framebuf,
text, shapes, and a multi-frame animation. No photos — just pins, code, and the mistakes that waste a lab period.
What you need
- Raspberry Pi Pico or Pico W
- MicroPython UF2 already flashed
- 0.96" I2C SSD1306 (3.3V-friendly — Pico IO is 3.3V)
- Thonny or another MicroPython IDE
ssd1306.pydriver on the Pico filesystem (see below)
Wiring (Pico I2C0 defaults we will use)
| OLED | Pico | Notes |
|---|---|---|
| VCC | 3V3 | Do not use VBUS 5V unless the module is clearly 5V-tolerant and level-safe |
| GND | GND | Common ground |
| SDA | GP4 (pin 6) | I2C0 SDA |
| SCL | GP5 (pin 7) | I2C0 SCL |
You can move to other I2C-capable pins; just change the constructor. If the screen stays blank, run an I2C scan in MicroPython before blaming the driver.
I2C scanner (MicroPython)
from machine import Pin, I2C
i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
print("Devices:", [hex(a) for a in i2c.scan()])
Expect 0x3c (sometimes 0x3d). Empty list → wiring or power. Fix that first.
Driver file: ssd1306.py
Copy the standard MicroPython ssd1306.py (from the MicroPython repository
drivers/display/ssd1306.py) onto the Pico as /ssd1306.py. Thonny: File → Save as →
Raspberry Pi Pico. Without this file, import ssd1306 fails.
The driver subclasses framebuf.FrameBuffer, so you get text, pixel,
line, rect, fill_rect, blit, etc.
Hello World
from machine import Pin, I2C
from ssd1306 import SSD1306_I2C
import time
i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
oled = SSD1306_I2C(128, 64, i2c, addr=0x3c)
oled.fill(0)
oled.text("Hello Pico", 0, 0)
oled.text("SSD1306 OK", 0, 16)
oled.show()
while True:
time.sleep(1)
Always call oled.show() after drawing — same idea as Arduino’s display.display().
Shapes and a simple HUD
oled.fill(0)
oled.rect(0, 0, 128, 64, 1) # border
oled.fill_rect(4, 4, 40, 10, 1) # header bar
oled.text("TEMP", 6, 5) # note: text on filled bar needs inverse trick
# easier: text outside fill
oled.fill(0)
oled.rect(0, 0, 128, 64, 1)
oled.text("Temp C", 4, 4)
oled.text("24.6", 4, 24)
oled.hline(4, 40, 120, 1)
oled.fill_rect(4, 44, 72, 8, 1) # fake bar
oled.show()
MicroPython’s built-in font is 8×8. For bigger digits, either scale by drawing blocks or blit a custom framebuffer glyph.
Multi-frame animation with bytearrays
Each 128×64 monochrome frame is 1024 bytes (MVLSB layout used by the driver). On Pico you can
keep many frames in RAM. Example with three tiny programmatic frames (no external files):
from machine import Pin, I2C
from ssd1306 import SSD1306_I2C
import framebuf
import time
i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
oled = SSD1306_I2C(128, 64, i2c)
def make_ball_frame(x):
buf = bytearray(1024)
fb = framebuf.FrameBuffer(buf, 128, 64, framebuf.MONO_VLSB)
fb.fill(0)
fb.text("Pico OLED", 0, 0)
fb.fill_rect(x, 28, 12, 12, 1)
return buf
frames = [make_ball_frame(x) for x in (10, 40, 70, 100, 70, 40)]
while True:
for buf in frames:
oled.blit(framebuf.FrameBuffer(buf, 128, 64, framebuf.MONO_VLSB), 0, 0)
# Faster path: copy into oled buffer then show
oled.show()
time.sleep_ms(80)
Cleaner pattern used in production sketches: draw directly into oled each frame instead of
prebuilding buffers, unless you need playback of imported GIF frames.
Playing exported animation frames
If you export MicroPython / framebuf data from
oledanimationmaker.com, you typically get a list of
bytearray literals or a flat bytes object plus frame count. Skeleton:
# frames_data = [ bytes([...]), bytes([...]), ... ] # from exporter
FRAME_W, FRAME_H = 128, 64
FRAME_SIZE = FRAME_W * FRAME_H // 8
while True:
for raw in frames_data:
fb = framebuf.FrameBuffer(bytearray(raw), FRAME_W, FRAME_H, framebuf.MONO_VLSB)
oled.fill(0)
oled.blit(fb, 0, 0)
oled.show()
time.sleep_ms(100)
Confirm the exporter’s bit order matches MONO_VLSB. If the image looks sliced or mirrored,
try the other mono formats only after checking width/height first — wrong size is the usual culprit.
Partial updates and speed
oled.show()sends the whole buffer over I2C — fine for 128×64.- Raise I2C
freqto 400000 (shown above). Some modules tolerate 1_000_000; not all do. - Do not
print()every frame to USB — it stalls animation in Thonny. - Sleep with
time.sleep_ms, not busy loops.
Pico vs Arduino Uno for OLED animation
| Uno + Arduino C++ | Pico + MicroPython | |
|---|---|---|
| RAM | 2 KB (tight) | 264 KB (comfortable) |
| Flash for frames | PROGMEM juggling | Normal arrays / files |
| Language | C++ sketches | Python |
| Startup | Upload sketch | Paste/run or main.py |
| Classroom fit | Classic electronics courses | CS / Python courses |
Using main.py so it runs on power-up
Save your animation as main.py on the Pico. On reset it auto-runs. Keep a way to recover
(hold BOOTSEL, reflash, or interrupt in Thonny) if an infinite loop locks USB — rare but teach students
the escape hatch.
Troubleshooting
- OSError on construct: wrong addr or no ACK — run scanner.
- ImportError ssd1306: file not on device or wrong name/case.
- Garbled image: SH1106 module or wrong framebuffer format.
- Works in REPL once, fails as main.py: exception at import — wrap setup in try/except and show error text on OLED if you want self-debug.
- Brownout when powering OLED from 3V3 pin with other loads: power OLED from a solid 3.3V supply and common GND.
Complete minimal animation project
from machine import Pin, I2C
from ssd1306 import SSD1306_I2C
import time
i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
oled = SSD1306_I2C(128, 64, i2c)
x, dir_ = 0, 1
while True:
oled.fill(0)
oled.text("MicroPython", 0, 0)
oled.fill_rect(x, 28, 16, 16, 1)
oled.show()
x += dir_ * 4
if x < 0 or x > 112:
dir_ = -dir_
time.sleep_ms(40)
FAQ
Can I use CircuitPython instead?
Yes — APIs differ slightly (displayio). This article targets MicroPython’s ssd1306 + framebuf path.
Does Pico W change anything for OLED?
Same I2C pins work. WiFi uses power — watch brownouts on weak USB hubs.
Can I load frames from the filesystem?
Yes. Store raw 1024-byte files or a single blob and read with open(). Good for longer animations without huge main.py files.
The guide uses GP4 and GP5, but my scanner is empty. Should I try GP0?
Only if the Dupont wires are actually on GP0 and GP1. This article’s examples construct I2C(0, sda=Pin(4), scl=Pin(5)). A script written for GP0/GP1 will not see a display wired to GP4/GP5. Match the constructor to the pins, then expect 0x3c or 0x3d.
Why is the animation smooth in Thonny and jerky from main.py?
A print() every frame waits on USB when Thonny is open, and an exception before oled.show() leaves the panel black when USB is not attached. Print a frame count every 30 frames, and draw an error string on the OLED inside except so a bad import is visible without the REPL.
Can I clock this I2C bus at 1 MHz for more FPS?
Some SSD1306 modules tolerate freq=1000000. Many clones ACK at 400 kHz and then paint garbage or stay black at 1 MHz. Keep 400000 unless you have tested that exact panel. One 128×64 frame is 1024 bytes, which is already a short transfer at 400 kHz.
The sprite looks sliced or shifted two columns. Is framebuf wrong?
Check width and height first: the buffer must be 1024 bytes for 128×64 MONO_VLSB. If the size is right and the whole image is shifted, the module is often an SH1106 (132×64 RAM with a column offset), not a bad bytearray. The MicroPython ssd1306 driver will not apply that offset for you.
A bounded sprite with a real frame clock
The tiny loop earlier in this page proves the panel works. This one is what I leave running on a demo Pico: the box stays on screen, the period is measured with ticks_ms, and USB prints do not stall every frame. Wiring stays on the pins already used above — SDA GP4, SCL GP5, VCC on 3V3, address 0x3c.
from machine import Pin, I2C
from ssd1306 import SSD1306_I2C
import time
i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
oled = SSD1306_I2C(128, 64, i2c, addr=0x3C)
x, step = 0, 4
last = time.ticks_ms()
frames = 0
t_report = last
while True:
now = time.ticks_ms()
if time.ticks_diff(now, last) < 33:
continue
last = now
oled.fill(0)
oled.text("Pico 33ms", 0, 0)
oled.fill_rect(x, 26, 16, 16, 1)
oled.show()
x += step
if x > 112 or x < 0:
step = -step
x += step
frames += 1
if time.ticks_diff(now, t_report) >= 1000:
# one number per second, not one print per frame
print("fps", frames)
frames = 0
t_report = now
ticks_diff is the safe subtraction on Pico; a plain now - last will mis-fire after the millisecond counter wraps. The 16×16 box uses x from 0 through 112 so it never draws past column 127. oled.show() pushes the whole framebuffer — 1024 bytes for 128×64 — which is the same idea as Arduino’s display.display(). Skipping show() leaves the previous picture up and looks like a frozen animation.
If you later blit exported frames, keep them as MONO_VLSB and do not convert them to a second RAM copy “just in case” unless you have measured a stall. The Pico has the RAM (264 KB) that an Uno does not, so a few dozen 1024-byte frames are fine. Hundreds of frames belong in files, not in a giant main.py that Thonny times out while saving.
Symptoms that look like a bad animation
i2c.scan()returns[]. Wires are on different pins thanPin(4)andPin(5), SDA and SCL are swapped, or VCC is on VBUS (5 V) into a module that is not happy there. Move VCC to 3V3 (physical pin 36) and keep GND common. A blank list is wiring; the frame loop cannot fix it.- Scan prints
0x3c, constructor raisesOSError. Theaddr=argument does not match, or a second device is sitting on the same address. Passaddr=0x3cexplicitly.0x3dis the other common module. There is no address resistor setting inside MicroPython. - Scan works,
show()runs, glass stays black. Contrast or the wrong controller. Try a fulloled.fill(1)thenoled.show(). If the panel is still black, power and ACK are fine and the init sequence is wrong — often an SH1106 sold as SSD1306. If the white field is only a bright strip, that is the same diagnosis. - Picture tears or the Pico resets when Wi-Fi starts. On Pico W the radio current dips a weak USB hub. The animation did not overflow. Use a powered hub or a short cable, and do not start the CYW43 stack in the middle of a tight
show()loop until the 3V3 rail is solid. ImportError: no module named 'ssd1306'only after reboot. The file was saved to your PC, not to the Pico, or it is namedSSD1306.py. MicroPython imports are case-sensitive. Save as/ssd1306.pyon the device.- FPS prints 30 in the REPL and the motion looks like 8. You are printing every frame as well as counting. USB text is slower than the OLED transfer. Keep the once-per-second print from the example above.
Pico, Pico W, and a port to ESP32
Stay on GP4 and GP5 for every sketch in this article unless you move the wires and the constructor together. GP0 and GP1 are a valid I2C0 pair on the Pico pin mux, and they are the wrong pair if the OLED is already plugged into pins 6 and 7 (GP4 and GP5). I2C1 is a different peripheral; I2C(0, ...) will not drive it.
| Board | SDA / SCL in this project | What changes |
|---|---|---|
| Pico | GP4 / GP5, 3V3 | Baseline. 264 KB RAM, so frame lists are ordinary bytearrays |
| Pico W | Same GP4 / GP5 | Same OLED code. Wi-Fi adds current; a cheap hub browns out show() |
| ESP32 running MicroPython | Not GP4. Use GPIO21 / GPIO22 | New Pin numbers, same ssd1306.py and 1024-byte frames. Power the module from 3.3 V |
An Arduino Uno port is a library change, not a frame-size change: you still have 1024 bytes per 128×64 image, but you do not have 264 KB of RAM, so those frames move to PROGMEM and the loop becomes drawBitmap plus display(). SPI instead of I2C is only worth it if you need a higher frame rate than this 33 ms loop. At 400 kHz, I2C already keeps up with a 30 fps demo on the Pico.
Reset on these 4-pin modules is not a Pico GPIO. If your breakout brings RES out and you have grounded it, the scanner can still be empty or the panel can ACK and stay black. Leave RES to the module’s own pull-up.
Related
Export MicroPython-friendly frames
Build the animation visually, then adapt the byte arrays into the blit loop above.
Open the free tool →