OLED Animation Maker OLED Maker
📘 Arduino OLED Tutorial · SSD1306

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 p…

By Ashish Jul 11, 2026 ~23 min Pico, MicroPython
📖

Clear tutorial

Written for makers — wiring, code, and common mistakes.

🔌

Hardware ready

SSD1306 / SH1106 friendly with pin tables where needed.

💻

Copy-paste code

Working sketches you can upload in Arduino IDE.

✨

Free maker tool

Preview & export more animations at oledanimationmaker.com.

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.py driver on the Pico filesystem (see below)

Wiring (Pico I2C0 defaults we will use)

OLEDPicoNotes
VCC3V3Do not use VBUS 5V unless the module is clearly 5V-tolerant and level-safe
GNDGNDCommon ground
SDAGP4 (pin 6)I2C0 SDA
SCLGP5 (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 freq to 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
RAM2 KB (tight)264 KB (comfortable)
Flash for framesPROGMEM jugglingNormal arrays / files
LanguageC++ sketchesPython
StartupUpload sketchPaste/run or main.py
Classroom fitClassic electronics coursesCS / 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 than Pin(4) and Pin(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 raises OSError. The addr= argument does not match, or a second device is sitting on the same address. Pass addr=0x3c explicitly. 0x3d is 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 full oled.fill(1) then oled.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 named SSD1306.py. MicroPython imports are case-sensitive. Save as /ssd1306.py on 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.

BoardSDA / SCL in this projectWhat changes
PicoGP4 / GP5, 3V3Baseline. 264 KB RAM, so frame lists are ordinary bytearrays
Pico WSame GP4 / GP5Same OLED code. Wi-Fi adds current; a cheap hub browns out show()
ESP32 running MicroPythonNot GP4. Use GPIO21 / GPIO22New 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 →