pico advanced 45 min

Pico: use PIO to drive WS2812B LEDs (NeoPixels)

The Pico's Programmable I/O can drive WS2812B LEDs without blocking your code. A reusable PIO program you can drop into any project.

Code available for: MicroPythonArduino C
Published Aug 5, 2026

The Pico has Programmable I/O (PIO), which is the killer feature for LED projects. PIO is a tiny co-processor that handles bit-banged protocols in hardware, so your MicroPython script does not get blocked while the LEDs are updating.

This tutorial covers the PIO program for WS2812B, how to wire it up, and how to drive a strip from MicroPython.

What you need

  • Raspberry Pi Pico
  • WS2812B strip (any length)
  • 330 ohm resistor on the data line
  • 470uF capacitor across the strip’s power pads
  • 5V power supply rated for the strip

Wiring

Pico VBUS (5V)  ---- 5V+ on power supply
                ---- 5V on WS2812B strip
Pico GND        ---- GND- on power supply
                ---- GND on WS2812B strip
Pico GPIO 0 --[330R]-- DIN on WS2812B strip

The resistor and capacitor are not optional for reliability.

The code

MicroPython (Pico)

Save this as ws2812b.py on the Pico:

import rp2
from machine import Pin
import time

@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT,
             autopull=True, pull_thresh=24)
def ws2812():
    T1 = 2
    T2 = 5
    T3 = 3
    wrap_target()
    label("bitloop")
    out(x, 1)               .side(0)    [T3 - 1]
    jmp(x_not_y, "do_zero") .side(1)    [T1 - 1]
    jmp("do_one")           .side(1)    [T2 - 1]
    label("do_zero")
    nop()                   .side(0)    [T2 - 1]
    label("do_one")
    wrap()

class WS2812B:
    def __init__(self, num_leds, pin):
        self.num_leds = num_leds
        self.sm = rp2.StateMachine(0, ws2812, freq=8_000_000,
                                    sideset_base=Pin(pin))
        self.sm.active(1)
        self.buf = bytearray(num_leds * 3)

    def __setitem__(self, index, color):
        offset = index * 3
        self.buf[offset] = (color >> 16) & 0xff   # red
        self.buf[offset + 1] = (color >> 8) & 0xff  # green
        self.buf[offset + 2] = color & 0xff         # blue

    def __getitem__(self, index):
        offset = index * 3
        return (self.buf[offset] << 16) | (self.buf[offset + 1] << 8) | self.buf[offset + 2]

    def fill(self, color):
        for i in range(self.num_leds):
            self[i] = color

    def write(self):
        self.sm.put(self.buf, 8)

# Usage:
NUM_LEDS = 60
strip = WS2812B(NUM_LEDS, 0)

def wheel(pos):
    if pos < 85:
        return (255 - pos * 3, pos * 3, 0)
    elif pos < 170:
        pos -= 85
        return (0, 255 - pos * 3, pos * 3)
    else:
        pos -= 170
        return (pos * 3, 0, 255 - pos * 3)

while True:
    for j in range(255):
        for i in range(NUM_LEDS):
            strip[i] = wheel((i * 256 // NUM_LEDS + j) & 255)
        strip.write()
        time.sleep_ms(10)

Run it. The strip should show a rainbow cycle.

Arduino (Pico)

The Pico’s Arduino core has its own PIO API. Most projects do not need PIO directly: the Adafruit NeoPixel library handles the WS2812B protocol with regular GPIO bit-banging, and it works on the Pico out of the box. Install it through the Arduino IDE (Sketch >> Include Library >> Manage Libraries >> search Adafruit NeoPixel).

The Arduino code is the same as the ESP32 and Uno versions. Pin numbers in the Pico Arduino core map to the GPIO number directly, so PIN 0 is GPIO 0.

#include <Adafruit_NeoPixel.h>

#define LED_PIN    0
#define NUM_LEDS   60

Adafruit_NeoPixel strip(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);

uint32_t wheel(byte pos) {
  if (pos < 85) {
    return strip.Color(255 - pos * 3, pos * 3, 0);
  } else if (pos < 170) {
    pos -= 85;
    return strip.Color(0, 255 - pos * 3, pos * 3);
  } else {
    pos -= 170;
    return strip.Color(pos * 3, 0, 255 - pos * 3);
  }
}

void setup() {
  strip.begin();
  strip.show();
}

void loop() {
  for (long j = 0; j < 256; j++) {
    for (int i = 0; i < NUM_LEDS; i++) {
      strip.setPixelColor(i, wheel((i * 256 / NUM_LEDS + j) & 255));
    }
    strip.show();
    delay(10);
  }
}

The Adafruit NeoPixel library uses bit-banging, not PIO. It is accurate enough for short to medium strips (about 500 LEDs). For larger strips or for freeing the CPU during updates, you would write a PIO program directly via rp2040.pio headers in the Pico Arduino core. That is more advanced; the library version is fine for most projects.

How PIO works

PIO is a tiny state machine that runs on the chip separately from the main CPU. You write a small assembly program (the @rp2.asm_pio() function) that describes what to do with one or two GPIO pins. The Pico’s hardware runs this program in parallel with your MicroPython code.

For the WS2812B protocol:

  • Each LED needs 24 bits (8 bits per color).
  • Each bit is encoded by the duration of the high signal: 0.4 us high for a “0” bit, 0.8 us high for a “1” bit.
  • The total period for one bit is 1.25 us.

The PIO program above runs at 8 MHz, with timing values tuned to produce the right pulse widths. The out instruction pulls a bit from the FIFO queue, and the side-set pin toggles to produce the protocol.

Why PIO is better than bit-banging

Without PIO, you would have to toggle the GPIO pin at exactly the right microseconds in MicroPython. With PIO, the hardware does it, freeing the CPU for your application code.

Practical differences:

  • CPU usage: 0% with PIO, 80%+ with bit-banging on a 60-LED update.
  • Timing accuracy: PIO is exact. Bit-banging has jitter that causes occasional glitches.
  • Multi-tasking: with PIO, you can run other code while the LEDs update. With bit-banging, your code is blocked.

For a 60-LED strip, the difference is huge. For 8 LEDs, either works.

Color order

WS2812Bs come in two color orders: GRB (most common) and RGB. The library above assumes GRB. If your colors are wrong (e.g. you ask for red and get green), swap the byte order:

def __setitem__(self, index, color):
    offset = index * 3
    self.buf[offset] = (color >> 8) & 0xff    # green first
    self.buf[offset + 1] = (color >> 16) & 0xff  # then red
    self.buf[offset + 2] = color & 0xff        # then blue

The WS2812B datasheet says GRB, but some strips are labeled RGB or BGR. When in doubt, test with a single color and check what comes out.

Brightness control

The strip uses 8 bits per color (0-255). For indoor eye-candy, drop the max to 60 or so to save power and avoid blinding yourself:

def brightness(color, factor):
    r = (color >> 16) & 0xff
    g = (color >> 8) & 0xff
    b = color & 0xff
    factor = factor / 255.0
    return (int(r * factor) << 16) | (int(g * factor) << 8) | int(b * factor)

# Usage:
strip[i] = brightness(wheel(...), 80)   # ~30% brightness

What to build next

  • A music-reactive LED strip (add a microphone and FFT).
  • An 8x8 or 16x16 LED matrix and run text across it.
  • A “fire” effect with random red/orange flickering.

The matrix version is in the book Pico LED Projects. The music-reactive strip is one of the next tutorials on this site.