pico intermediate 30 min

Pico: PWM in depth with MicroPython, frequencies and duty cycles

Use the Pico's 8 PWM slices from MicroPython to drive servos, dim LEDs, generate tones, and synthesize analog output with a low-pass filter.

Code available for: MicroPython
Published Aug 26, 2026

PWM is the trick that turns a digital pin into something that acts analog. You flash the pin high and low so fast that the load (an LED, a motor, a servo) only ever sees the average. The fraction of the time the pin is high is the duty cycle. How often the pin goes through a full cycle is the frequency.

I use PWM for almost every output that is not a simple on/off. LED dimming, servo angles, motor speed, audio tones, even crude DAC output with a capacitor. The Pico is good at this because it has dedicated PWM hardware called slices, and MicroPython exposes them through machine.PWM.

What you need

  • Raspberry Pi Pico (or Pico W, same code)
  • An LED + 220 ohm resistor (for the fading example)
  • A hobby servo, e.g. SG90 (for the servo example)
  • A passive buzzer, e.g. the 3-pin “KY-006” type (for the tone example)
  • A 10k ohm resistor and a 1uF capacitor (for the low-pass filter example)

What PWM actually is

Two knobs, that’s it:

  • Frequency: how many full on/off cycles per second, in Hz.
  • Duty cycle: what fraction of each cycle the pin is high, in percent.

So a 50 Hz signal with 10% duty is high 2 ms, low 18 ms, repeating 50 times a second. A 1 kHz signal with 50% duty is high 0.5 ms, low 0.5 ms, repeating 1000 times a second.

The Pico’s PWM slices run independently of the CPU. You set them up once and they keep toggling the pin until you change the duty cycle. Your code is not in the loop. That is why you can drive 8 outputs with no flicker and almost no CPU.

The machine.PWM API

from machine import Pin, PWM

pwm = PWM(Pin(0))
pwm.freq(50)                   # 50 Hz, the standard for servos
pwm.duty_u16(32768)            # 50% duty, 16-bit range 0-65535

The 16-bit range matters. A hobby servo wants 1-2 ms pulses out of a 20 ms cycle, so the resolution at 50 Hz is about 0.3 microseconds per step. That is plenty for any servo I have ever used.

Three methods you will use constantly:

  • pwm.freq(hz) sets the frequency.
  • pwm.duty_u16(value) sets the duty as 0-65535.
  • pwm.duty_ns(ns) sets the duty as a number of nanoseconds. This is the one to use for servos if you want the math to be obvious (e.g. pwm.duty_ns(1_500_000) is a 1.5 ms pulse).

There is also pwm.duty_u8(0-255) for when you want the 8-bit Arduino mental model. I almost never use it.

Choosing a frequency

Different loads want different frequencies. The wrong frequency is the most common PWM bug I see.

Use caseFrequencyWhy
Hobby servo50 HzThe servo protocol. Anything else makes the servo unhappy.
LED dimming1 kHz+Below 100 Hz the eye sees flicker. 1 kHz looks smooth.
Buzzer tone100-4000 HzThe frequency is the pitch.
DC motor speed25 kHz+Above audible range. Otherwise the motor whines.
Audio output (with LPF)44.1 kHz+Matches audio sample rates.

Pick the frequency for the load, not the load for the frequency. The 25 kHz motor rule exists because a motor coil is a speaker. PWM it at 1 kHz and you have a 1 kHz speaker.

Servo: the 1-2 ms pulse rule

A hobby servo wants a pulse every 20 ms (50 Hz). The pulse width tells it where to go:

  • 1.0 ms = one extreme (0 degrees on most servos)
  • 1.5 ms = center (90 degrees)
  • 2.0 ms = other extreme (180 degrees)
from machine import Pin, PWM
import time

servo = PWM(Pin(0))
servo.freq(50)

def angle(a):
    # Map 0-180 degrees to 1.0-2.0 ms pulse widths
    pulse_us = 1000 + (a / 180) * 1000
    servo.duty_ns(pulse_us * 1000)

angle(0)
time.sleep(1)
angle(90)
time.sleep(1)
angle(180)

I use duty_ns for servos because the math is honest. 1.5 ms is 1,500,000 ns. The PWM slice does the rest.

Fading an LED

from machine import Pin, PWM
import time

led = PWM(Pin(15))
led.freq(1000)

while True:
    for duty in range(0, 65536, 256):
        led.duty_u16(duty)
        time.sleep_ms(5)
    for duty in range(65535, -1, -256):
        led.duty_u16(duty)
        time.sleep_ms(5)

The range(0, 65536, 256) step size of 256 is a trade-off: smoother fades use a smaller step but more CPU. For a status LED I use 1024. For a mood lamp I use 64.

The LED can flicker at low duty cycles even with a 1 kHz PWM. The eye sees the discrete steps. If the flicker bothers you, drop to 8-bit resolution (duty_u8 instead of duty_u16).

Generating tones

A passive buzzer is a small speaker. Apply a square wave at the right frequency and you get a tone. Change the frequency and you get a different tone.

from machine import Pin, PWM
import time

buzzer = PWM(Pin(14))

def tone(hz, duration_ms):
    buzzer.freq(hz)
    buzzer.duty_u16(32768)   # 50% duty, the loudest
    time.sleep_ms(duration_ms)
    buzzer.duty_u16(0)       # silence

# Middle C, E, G, C
for note in [262, 330, 392, 523]:
    tone(note, 400)
    time.sleep_ms(50)

You can also play melodies. The time.sleep_ms between notes is what makes a melody sound like a melody and not a chord.

Active buzzers (the kind with a built-in oscillator) ignore the frequency and just beep. Make sure your buzzer is the passive kind if you want tones.

The 8-slices trick

The Pico has 8 PWM slices, each with two channels (A and B) for a total of 16 PWM outputs. Every GPIO from 0 to 28 is wired to one of these slices, and each slice drives two specific pins. Both pins on a slice share the same frequency, but you can set different duty cycles on A and B.

The trick: you can run PWM on 8 different frequencies, but if you want PWM on more than 8 pins at different frequencies, you run out of slices. The solution is to either accept that pins on the same slice share a frequency, or use the PIO peripheral (a different beast) for the odd ones out.

from machine import Pin, PWM

# Two pins on the same slice share a frequency
# GP0 and GP16 are both on slice 0
pwm_a = PWM(Pin(0), freq=50)
pwm_b = PWM(Pin(16), freq=50)

# GP1 and GP17 are on slice 1, independent of slice 0
pwm_c = PWM(Pin(1), freq=1000)
pwm_d = PWM(Pin(17), freq=1000)

For most projects you never hit the limit. I mention it because it surprises people the first time two pins “fight” over the frequency.

PWM as a cheap DAC

A capacitor and a resistor turn PWM into a DC voltage. The capacitor averages the high/low transitions. The resistor sets how fast the capacitor charges (this is the “low-pass filter”).

Pico GPIO ----[1k]----+---- LED or ADC input
                      |
                    [1uF]
                      |
                     GND

With 1 kHz PWM and a 1k/1uF low-pass filter, you get a stable DC voltage from about 0 V to 3.3 V. The output is not perfect (it has a small ripple), but for “set a brightness level” or “control a slow analog input” it is plenty.

from machine import Pin, PWM
import time

dac = PWM(Pin(0))
dac.freq(1000)

# Set "voltage" to 50% of 3.3V = 1.65V
dac.duty_u16(32768)

The output is not a real DAC. It has ripple, and it cannot change instantaneously. If you need a real analog output, use the Pico’s ADC input range or an external DAC like the MCP4725.

What you learned

  • PWM is duty cycle + frequency. Pick the frequency for the load.
  • The Pico has 8 PWM slices driving up to 16 pins. Pins on the same slice share a frequency.
  • duty_ns is the honest way to set servo pulse widths.
  • A 1k/1uF RC low-pass filter turns PWM into a slow analog signal.

When something breaks

The servo jitters. Three usual causes: insufficient power (the SG90 draws 200 mA, use a separate 5V supply for multiple servos), shared ground missing, or electrical noise (add a 100uF cap across the servo power pins).

The LED is dim even at 100% duty. The PWM pin is 3.3V. Your LED is rated for a higher forward voltage. Check the LED’s Vf.

The buzzer is silent. Check whether it is active or passive. Active buzzers ignore the frequency.

Two pins on the same slice ignore independent frequencies. They are on the same slice. Move one of them to a different slice’s pins, or accept the shared frequency.

ValueError: bad PWM freq. MicroPython’s PWM has a minimum frequency (usually around 1 Hz) and the math needs to work out. If you ask for 1 Hz at 16-bit, the slice can take a long time to count up; MicroPython may reject it. Lower the resolution or use a higher frequency.

What to build next

  • A pan/tilt camera mount with two servos.
  • A music box with the buzzer, with a list of note frequencies in a tuple.
  • A “fake DAC” that drives a voltage-controlled oscillator (e.g. a PWM input on a motor controller).

The pan/tilt mount is one of the next tutorials on this site. The music box is in the Pico Audio Projects book.