pico beginner 30 min

Pico: drive a 28BYJ-48 stepper motor with a ULN2003 driver

Wire a 28BYJ-48 stepper to a Raspberry Pi Pico with the ULN2003 board and drive it with MicroPython. Precise position control for about $3 in parts.

Code available for: MicroPython
Published Sep 22, 2026

The 28BYJ-48 is the stepper I reach for when the job is “move this thing to a specific angle” and the thing is light. It costs about $2 with the driver board included, it has a gear reduction inside that gives you 2048 steps per revolution at the output shaft, and it is nearly impossible to damage from a wiring mistake. A servo tells you nothing about where it actually is. This motor counts its steps, so you always know.

The trap I hit: my first version turned smoothly but stopped about 15 degrees short of where I asked every single time. Not a wiring problem, a speed problem. I was stepping too fast for the motor to keep up, it was losing steps, and because a stepper is an open-loop device it had no idea. Slow the step interval down and the error disappears. That is the whole secret of this motor: it is geared and slow on purpose.

What you need

Needed

  • Raspberry Pi Pico with MicroPython installed (see the Pico setup tutorial)
  • 28BYJ-48 stepper motor (5V version, not the 12V variant)
  • ULN2003 driver board (the little blue board with the white 4-pin connector, sold bundled with the motor)
  • 6 female-to-male jumper wires
  • 5V power for the motor (e.g. the Pico’s VBUS pin is fine for bench testing, a separate 5V supply is better for anything that runs hours)

Nice to have

  • Soldering iron and solder (only if you solder headers, not needed here)
  • Helping hands or a third hand clip for holding wires while you probe
  • Multimeter (for the “is this actually 5V” check before you blame code)
  • Anti-static wristband

Wiring

The ULN2003 board has four inputs (IN1-IN4), a power pair, and the white plug the motor connects to. The motor plug only fits one way.

Wire key: GPIOVCC5VGND
ULN2003 pinPico pin
IN1GP0 (physical pin 1)
IN2GP1 (physical pin 2)
IN3GP2 (physical pin 4)
IN4GP3 (physical pin 5)
VCC (or +)VBUS (physical pin 40, 5V from USB)
GNDGND (physical pin 38)

Two things worth knowing:

The 28BYJ-48 draws about 240 mA while stepping and stalls around 320 mA. The Pico’s VBUS passes USB power through, and a 500 mA USB port handles one motor fine. If the motor gets warm and the board resets when it starts, your USB source is weak: power the ULN2003 board from a separate 5V supply and tie the grounds together.

The coil order matters. The motor’s blue and pink wires are one coil, yellow and orange are the other. The stepping sequence in the code below assumes IN1-IN4 match the board’s silk-screen labels. If your motor runs hot and just vibrates, the sequence order is your first suspect, not the wiring.

Install

Nothing to install. machine.Pin and time are built into MicroPython. If you are running this from Thonny: Thonny >> Tools >> Manage packages only matters if you later want micropython-async, which is optional here.

The code

This is a complete program. Save it as main.py on the Pico and it runs on power-up:

from machine import Pin
import time

IN1 = Pin(0, Pin.OUT)   # GP0
IN2 = Pin(1, Pin.OUT)   # GP1
IN3 = Pin(2, Pin.OUT)   # GP2
IN4 = Pin(3, Pin.OUT)   # GP3

coils = [IN1, IN2, IN3, IN4]

# Half-step sequence: one coil, two coils, next coil, two coils...
# This is the sequence that gives 4096 steps per output revolution.
SEQ = [
    (1, 0, 0, 0),
    (1, 1, 0, 0),
    (0, 1, 0, 0),
    (0, 1, 1, 0),
    (0, 0, 1, 0),
    (0, 0, 1, 1),
    (0, 0, 0, 1),
    (1, 0, 0, 1),
]

STEPS_PER_REV = 4096      # half-stepping, 32 coil steps x 64:1 gearbox
STEP_DELAY_MS = 2         # 2 ms/step = ~500 steps/s = ~7 RPM (theoretical)

def step_forward(n):
    """Advance the sequence n half-steps clockwise."""
    global _phase
    for _ in range(n):
        _phase = (_phase + 1) % 8
        for coil, val in zip(coils, SEQ[_phase]):
            coil.value(val)
        time.sleep_ms(STEP_DELAY_MS)

def step_backward(n):
    global _phase
    for _ in range(n):
        _phase = (_phase - 1) % 8
        for coil, val in zip(coils, SEQ[_phase]):
            coil.value(val)
        time.sleep_ms(STEP_DELAY_MS)

_phase = 0

def release():
    """De-energize all coils (the motor holds nothing when idle)."""
    for coil in coils:
        coil.value(0)

# Demo: one full turn clockwise, pause, one full turn back
step_forward(STEPS_PER_REV)
time.sleep(1)
step_backward(STEPS_PER_REV)
release()

Run it and the shaft turns one slow full revolution, pauses, and comes back. That is 8192 half-steps, and you can watch the shaft move in tiny distinct nudges if you look closely. That is normal: 2048 steps per revolution means each step is 0.088 degrees at the shaft.

Now the version you will actually use: absolute position control. A stepper only knows relative motion, so the program tracks where it is:

class Stepper28BYJ:
    def __init__(self, pins, step_delay_ms=2):
        self.coils = [Pin(p, Pin.OUT) for p in pins]
        self.seq = [
            (1, 0, 0, 0), (1, 1, 0, 0), (0, 1, 0, 0), (0, 1, 1, 0),
            (0, 0, 1, 0), (0, 0, 1, 1), (0, 0, 0, 1), (1, 0, 0, 1),
        ]
        self.delay = step_delay_ms
        self.phase = 0
        self.pos = 0            # current position in half-steps

    def _write(self):
        for coil, val in zip(self.coils, self.seq[self.phase]):
            coil.value(val)

    def move(self, target):
        """Move to an absolute position in half-steps (0-4095)."""
        target %= 4096
        delta = target - self.pos
        direction = 1 if delta >= 0 else -1
        for _ in range(abs(delta)):
            self.phase = (self.phase + direction) % 8
            self._write()
            time.sleep_ms(self.delay)
        self.pos = target

    def release(self):
        for coil in self.coils:
            coil.value(0)

stepper = Stepper28BYJ((0, 1, 2, 3))
while True:
    stepper.move(1024)    # 90 degrees
    time.sleep(1)
    stepper.move(2048)    # 180 degrees
    time.sleep(1)
    stepper.move(0)       # home
    time.sleep(3)

One practical note on speed: 2 ms per half-step works out to about 7 RPM in theory, and MicroPython’s loop overhead lands it closer to 5. This motor tops out around 500 steps per second before it starts missing steps and losing position. If you need fast, you do not want this motor, you want a NEMA 17 with a proper driver (the Arduino stepper tutorial on this site covers that comparison). Slow and precise is the 28BYJ-48’s whole personality.

Half-step vs full-step, and the 2048/4096 math

The motor inside has 32 steps per revolution. The gearbox is 64:1 (about 63.683:1 in reality, which is why 4096 is not perfectly exact over long runs). Half-stepping doubles the resolution, so you get 4096 half-steps per revolution, and that is what the code above uses. Full-stepping (energizing one coil at a time) gives 2048 steps and slightly more torque per step.

If you only ever run for a few hundred revolutions, the 0.5 percent gearbox error never matters (e.g. a camera slider that travels back and forth all day is fine). If you are tracking long-term accumulated position, re-home at a known stop end and the error resets to zero.

What you learned

  • A stepper moves in countable steps; position control is just bookkeeping.
  • The 28BYJ-48 is 32 motor steps, 64:1 gearbox, 4096 half-steps per shaft revolution.
  • Speed costs torque and accuracy on this motor. Slow is correct.
  • Half-step sequences alternate one coil and two coils to divide each full step in two.

When something breaks

  • The motor vibrates and heats up but never turns. The coil sequence is out of order for your wiring. Swap IN2 and IN3 in the pins tuple (that mirrors the most common 28BYJ-48 variant). Also confirm you are using the 5V motor, the 12V variant barely moves on 5V.
  • It turns but lands short of the target. You are stepping too fast and losing steps. Raise step_delay_ms from 2 to 4 and retest. If the error persists at low speed, something is mechanically jamming the shaft.
  • The Pico resets or browns out when the motor starts. The motor’s inrush is more than the USB port gives. Power the ULN2003 from its own 5V supply and connect grounds, and add a 100-470 uF capacitor across the driver board’s power pins.
  • Position drifts after hours of running. That is the 63.683:1 gearbox, not a bug. Re-home at a known end stop periodically (e.g. a microswitch at position zero) and the drift resets.

What to build next

  • The Pico microSD datalogger tutorial pairs with this one: log the stepper’s commanded position and the move duration alongside sensor data.
  • The Pico servo tutorial is the faster, cheaper motor for “point at a thing” jobs; the stepper is the pick when you need full rotation and counted steps.
  • A plant-camera turntable: Pico W serves the web page (see the Pico W web server tutorial), the stepper rotates the pot 30 degrees a day.