Skip to content
Tutorial
11 min read
October 17, 2026

MicroPython PWM on ESP32: Fade an LED and Control a Servo

Understand frequency and duty cycle, dim an LED smoothly and drive an SG90 servo with correct pulse widths, including how duty ranges differ between ESP32 and Pico.

MicroPython PWM on ESP32: Fade an LED and Control a Servo

Pulse-width modulation (PWM) can control an LED's apparent brightness or generate a timing signal for a servo. MicroPython on ESP32 exposes PWM through the machine.PWM API, but valid pins, timer resources, and API details vary by chip variant and firmware.

This guide covers a safe LED fade, frequency and duty cycle, servo signal limits, wiring, and troubleshooting. GPIO numbers in examples are illustrative: verify the pinout of your exact development board before connecting hardware.

1. Understand PWM

A PWM output repeatedly switches between LOW and HIGH. Duty cycle is the fraction of each period spent HIGH. At a fixed frequency, a higher duty cycle usually means more average power delivered to a suitable load. An LED can appear brighter as duty increases because the switching is faster than the eye can resolve. PWM does not let a GPIO deliver more current than its electrical rating.

Frequency is measured in hertz. A frequency that works for an LED may not be appropriate for a servo or another peripheral. For a visible LED fade, a few hundred hertz to a few kilohertz is a reasonable starting point. Servo control uses a much lower repetition rate and defined pulse widths, so do not reuse LED PWM settings blindly.

Duty-cycle APIs differ by MicroPython version. Current builds commonly support duty_u16() with values from 0 to 65535, while older or alternate ports may expose a different interface. Check the documentation for the firmware installed on the board.

2. Wire an LED safely

Use an external LED with a series current-limiting resistor. With power disconnected, connect a verified GPIO through a resistor—often 220 Ω to 1 kΩ for a simple indicator—to the LED anode; connect the cathode to GND. The appropriate resistor depends on LED forward voltage, desired current, and GPIO voltage. The resistor is required even if the LED appears to work without one.

Do not connect an LED directly between a GPIO and ground. Not every pin printed on the board is a safe general-purpose output. Some ESP32 pins affect boot mode or connect to onboard circuitry. Select a documented output pin and verify its numbering: board labels, chip GPIO numbers, and physical header positions are not interchangeable.

Disconnect power before changing the circuit. Check LED polarity and resistor placement, ensure there are no shorts between 3.3 V and GND, and use a common ground between the board and external low-voltage circuitry. Keep load current within the board's limits.

3. Create a basic LED fade

The example uses the current MicroPython PWM interface commonly available on ESP32 builds. GPIO 18 is illustrative; replace it with a suitable pin for your board.

from machine import Pin, PWM
from time import sleep

LED_PIN = 18  # example only; verify your board
pwm = PWM(Pin(LED_PIN), freq=1000)
pwm.duty_u16(0)
try:
    while True:
        for duty in range(0, 65536, 1024):
            pwm.duty_u16(duty)
            sleep(0.02)
        for duty in range(65535, -1, -1024):
            pwm.duty_u16(duty)
            sleep(0.02)
finally:
    pwm.deinit()

The finally block releases PWM when the program exits through an exception or normal interruption. If your firmware does not provide duty_u16, consult its API documentation and adapt the duty range to the supported method. Do not assume that an API mismatch means the board is defective.

Run the code only after checking the LED circuit. If the output remains on after stopping the program, explicitly deinitialize PWM or reset the board before rewiring. Never move wires while powered.

4. Frequency, resolution, and visual smoothness

A 16-bit duty API represents duty with values from 0 to 65535. Zero requests an output that is always LOW; the maximum requests a fully or nearly fully HIGH output, depending on implementation. Intermediate values create a repeating pulse train. The actual timer resolution and supported frequency combinations may impose constraints, so not every theoretical combination is available on every ESP32 variant.

If the LED does not fade smoothly, first check the circuit and pin, then try more duty steps or a slightly slower update interval. Human brightness perception is nonlinear, so equal numerical duty increments do not necessarily look like equal brightness increments. A gamma-corrected lookup table can improve a polished visual effect, but prove a basic linear ramp first.

If multiple PWM channels are active, consult the board and firmware documentation before assuming every channel has fully independent timing resources. Changing frequency may affect other outputs on some implementations. Stop and release channels when they are no longer needed, and keep PWM configuration in one clearly documented section of the program.

5. Servo control is not the same as LED dimming

A hobby servo usually expects a repeating control pulse whose width indicates a target position. A common starting point for many analog hobby servos is a period near 20 ms, with a pulse around 1–2 ms, but the allowed range varies by model. Some servos accept a wider range; commanding beyond the mechanical limits can cause buzzing, excess current, overheating, or damage. Start conservatively and follow the servo datasheet.

Do not power a servo from an ESP32 GPIO. Even a small servo can draw a large transient current when starting, changing direction, or encountering resistance. Use a regulated supply sized for the servo's voltage and peak current. Connect the supply ground to ESP32 ground so the signal has a reference, unless a correctly designed isolation interface is used. Never connect the servo's positive supply rail to a GPIO.

For first tests, disconnect the mechanical linkage so the servo can move freely. Keep fingers clear of gears and horns. If the servo chatters, gets hot, or pulls the board into repeated resets, disconnect it and investigate the supply, wiring, pulse range, and mechanical load before trying again.

6. Generate a servo control signal

The code below demonstrates the duty_ns() method available in some current MicroPython ports. It sets a 20 ms period and starts with a 1.5 ms pulse, often near the centre position for a conventional hobby servo. Confirm that your installed firmware supports duty_ns() and that the servo accepts this pulse range. If not, use the port's documented PWM API rather than copying values from an unrelated board tutorial.

from machine import Pin, PWM
from time import sleep

SERVO_PIN = 18  # example only; verify the pinout
servo = PWM(Pin(SERVO_PIN), freq=50)
try:
    for pulse_ns in (1_000_000, 1_250_000, 1_500_000, 1_750_000, 1_500_000):
        servo.duty_ns(pulse_ns)
        sleep(1)
finally:
    servo.deinit()

A 50 Hz signal has a period of about 20 ms. The pulse-width values above are expressed in nanoseconds, not duty percentages. They are illustrative test points, not a guarantee that every servo supports the entire range. Begin with a narrow range near the manufacturer's recommended neutral pulse, and stop if the mechanism strains or the servo buzzes continuously.

Some older MicroPython builds lack duty_ns() or use different APIs. Check the official documentation for the installed firmware and verify timing with a suitable instrument if precise pulse width matters. Do not connect an oscilloscope ground clip to a point that could create a short; use ordinary low-voltage bench measurement practices.

7. Test the servo separately before combining projects

First run a minimal program that moves the servo through a few conservative positions. Do not add Wi-Fi, sensor polling, or a complex control loop until the servo and its supply behave consistently. A servo can draw brief current peaks that a USB port or small regulator cannot provide. Symptoms include ESP32 resets, serial disconnects, Wi-Fi drops, jitter, and a hot regulator.

Use a regulated supply that matches the servo's specified voltage and current. Connect the supply ground and ESP32 ground together for a non-isolated signal path. Check the signal wire and connector orientation from the servo documentation; wire colours are common conventions, not guarantees. Secure the servo mechanically so it cannot twist wires or pull itself off the work surface.

If the servo works unloaded but fails under load, do not immediately increase the PWM range. Check for mechanical binding, inadequate power, poor connections, or a load beyond the servo's rating. PWM controls the requested position; it does not measure actual position or provide torque feedback unless the device has additional feedback electronics.

8. Troubleshooting common PWM problems

The LED stays off: check the selected GPIO, board pinout, LED polarity, resistor wiring, and ground. Test a simple digital HIGH/LOW output before debugging PWM. The LED stays fully bright: confirm the code is changing the duty value and that the correct pin is connected. An API mismatch or another part of the circuit driving the pin can produce misleading results.

The fade looks stepped: increase the number of increments or reduce the delay between updates while keeping the program responsive. The LED flickers: try a higher PWM frequency within the supported range and inspect wiring. An attribute error appears: verify whether your firmware supports duty_u16, duty_ns, and the constructor arguments used by the example.

The ESP32 resets when the servo moves: disconnect the servo, then check supply voltage, peak current capability, ground connections, and wiring. Do not use the ESP32 3.3 V output to power a servo. The servo buzzes at an endpoint: reduce the pulse range and check for mechanical binding. The servo does not move: verify signal pin, ground reference, voltage, connector pinout, and the servo's control specifications.

Change one variable at a time and record what changed. Avoid changing pins, firmware, wiring, and code simultaneously because that makes the actual cause difficult to identify.

9. Make the fade smoother and easier to maintain

Once the basic ramp works, put pin number, frequency, step size, and delay near the top of the script. This makes it clear which values are hardware-specific and simplifies controlled experiments. A reusable function can set the brightness from a normalized value between 0.0 and 1.0, clamp the input to that range, convert it to the API's duty scale, and update the output. Clamping avoids unexpected values when the brightness comes from a sensor or user interface.

For example, with a 16-bit API, a normalized brightness can be converted using int(level * 65535). Validate the level before converting it and remember that perceived brightness is not linear. A lookup table or gamma curve can improve the visual result later. Keep the conversion separate from hardware setup so the same application logic can be tested without a physical LED.

If you want a breathing effect rather than a triangle-shaped ramp, use a smooth curve such as a sine function to calculate the duty value. Ensure the loop has a sensible update interval and does not monopolize the interpreter when other tasks must run. Do not create multiple PWM objects for the same pin unnecessarily; update the existing object and deinitialize it when finished.

10. Combine PWM with buttons or sensors

A useful next step is to control LED brightness from a potentiometer or button. Read the input, validate the returned range, map it to the PWM duty scale, and update the existing PWM object. Avoid creating a new PWM object on every iteration. If you use an ADC, check the board's ADC pin restrictions, attenuation options, reference range, and calibration behaviour; those details differ between ESP32 families.

When a button changes brightness modes, remember that mechanical switches bounce. Use a debounce strategy and keep the event logic separate from the PWM update. If a sensor reading fails, decide whether to hold the last valid output, set a safe default, or signal an error. Do not let a malformed network command or invalid sensor value create an uncontrolled output.

For a servo, use a separate control function that converts a validated target position into a pulse width within a deliberately limited safe range. Keep LED duty percentages and servo pulse widths as different concepts and variables. They use PWM hardware but represent different physical control methods. If a project has multiple outputs, document which pin and PWM object belongs to each device and test them individually before combining them.

Final pre-power checklist

Before publishing or reusing this project, confirm the exact board and firmware version, verify every GPIO against the board pinout, and run the LED circuit separately from the servo. Check the LED resistor, servo voltage rating, supply current capability, and shared ground for a non-isolated signal. Begin with conservative pulse widths and an unloaded servo, then expand only within the manufacturer's documented limits. Keep real credentials out of shared code and document any API differences you observed. If the device resets, the servo overheats, or movement is mechanically restricted, stop testing and correct the cause rather than increasing duty or pulse range.

Signal In

Built from what makers search for

The lookups piling up in SolderHub's search bar every day shape what we publish next — new component pages, board guides, and firmware notes, sent out as they go live.

By subscribing you agree to our Privacy Policy. Unsubscribe anytime.