Skip to content
Tutorial
14 min read
October 17, 2026

ESP32 PWM Tutorial: Control LED Brightness, Motors & Servos with LEDC

Learn ESP32 PWM with the LEDC peripheral: fade an LED, control motor speed through a MOSFET or L298N, drive a servo, play tones, choose frequency and resolution, and handle the differences between Arduino-ESP32 core 2.x and 3.x.

PWM, pulse width modulation, is how a microcontroller fakes an analog output. It switches a pin on and off thousands of times per second, and the fraction of time the pin spends on (the duty cycle) sets the average power: dim an LED, slow down a motor, move a servo, make a buzzer sing.

On an Arduino Uno you call analogWrite() on a handful of marked pins and move on. The ESP32 works differently, and better. It has a dedicated hardware PWM peripheral called LEDC (LED Control) with 16 independent channels, adjustable frequency and adjustable resolution, and any output-capable pin can carry any channel. The price of that flexibility is a slightly different API, and a recent change in the Arduino-ESP32 core that has broken a lot of older tutorials. This guide covers both, with working examples for LEDs, DC motors, servos and tones.

Image pending: ESP32 on a breadboard driving three LEDs at different PWM brightness levels from the same sketch

How ESP32 PWM Works

The LEDC peripheral generates PWM with three settings:

  • Frequency: how many on-off cycles per second, in hertz.
  • Resolution: how many bits describe the duty cycle. 8 bits means 256 steps (0 to 255), 10 bits means 1024 steps, 12 bits means 4096 steps.
  • Duty cycle: the value you write, from 0 (always off) to the maximum for the resolution (always on).

Frequency and resolution are linked. The LEDC timer is clocked from the 80 MHz APB clock, so the maximum frequency falls as resolution rises:

ResolutionMaximum frequency (approx.)
8 bit312 kHz
10 bit78 kHz
12 bit19.5 kHz
13 bit9.7 kHz
16 bit1.2 kHz

If you request a combination the hardware cannot do, the setup call fails and no PWM appears, a common cause of "my LED will not light".

Which pins? Any pin that can be an output. GPIO 34, 35, 36 and 39 are input only and cannot produce PWM. Avoid flash pins (6 to 11) and watch the strapping pins. See the ESP32 pinout guide for the safe list.

Core 3.x vs Core 2.x: Which API Do You Have?

In 2024 the Arduino-ESP32 core moved from version 2.x to 3.x, and the PWM functions changed. Check your version under Tools → Board → Boards Manager → esp32.

TaskCore 3.x (current)Core 2.x (older)
Set up a pinledcAttach(pin, freq, resolution)ledcSetup(ch, freq, res) then ledcAttachPin(pin, ch)
Write dutyledcWrite(pin, duty)ledcWrite(channel, duty)
Play a toneledcWriteTone(pin, freq)ledcWriteTone(channel, freq)
Release a pinledcDetach(pin)ledcDetachPin(pin)

The key difference: in 3.x you work with the pin number directly and the core assigns a channel for you. In 2.x you manage channel numbers yourself. If you paste a 2.x sketch into a 3.x core, it will not compile, with errors such as ledcSetup was not declared. Every example below shows the 3.x style, with the 2.x equivalent where it matters.

Image pending: Arduino IDE Boards Manager showing the esp32 package version, with the 3.x ledcAttach and 2.x ledcSetup function names compared side by side

Example 1: Fade an LED

Wire an LED with a 220 Ω resistor between GPIO 23 and GND (long leg toward the pin).

const int LED_PIN = 23;
const int FREQ    = 5000; // 5 kHz, invisible flicker
const int RES     = 8;    // 0 to 255

void setup() {
  ledcAttach(LED_PIN, FREQ, RES);
}

void loop() {
  for (int duty = 0; duty <= 255; duty++) {
    ledcWrite(LED_PIN, duty);
    delay(8);
  }
  for (int duty = 255; duty >= 0; duty--) {
    ledcWrite(LED_PIN, duty);
    delay(8);
  }
}

The same on core 2.x:

const int LED_PIN = 23;
const int CHANNEL = 0;

void setup() {
  ledcSetup(CHANNEL, 5000, 8);
  ledcAttachPin(LED_PIN, CHANNEL);
}

void loop() {
  for (int duty = 0; duty <= 255; duty++) {
    ledcWrite(CHANNEL, duty);
    delay(8);
  }
}

Why 5 kHz? Anything above roughly 1 kHz is invisible flicker. Lower frequencies, below about 200 Hz, produce visible flicker on LEDs and on camera footage.

Smoother fading. Human eyes perceive brightness logarithmically, so a linear duty ramp looks like it rushes through the dim part and barely changes at the top. Apply gamma correction for a natural fade:

int gamma(int v) {            // v: 0 to 255
  return (int)(pow(v / 255.0, 2.2) * 255.0 + 0.5);
}
// ledcWrite(LED_PIN, gamma(duty));

The core also offers a built-in hardware fade: ledcFade(pin, startDuty, targetDuty, durationMs) on 3.x, which runs without blocking your loop.

Image pending: Breadboard wiring of an LED with a 220 ohm resistor from GPIO 23 to GND and an oscilloscope trace showing different PWM duty cycles

Example 2: Potentiometer Dimmer

Combine the ADC with PWM to build a classic dimmer. Use an ADC1 pin (GPIO 34) so it keeps working if you add WiFi later.

const int POT_PIN = 34;
const int LED_PIN = 23;

void setup() {
  Serial.begin(115200);
  ledcAttach(LED_PIN, 5000, 12);   // 12-bit: 0 to 4095, matches the ADC range
}

void loop() {
  int pot = analogRead(POT_PIN);   // 0 to 4095
  ledcWrite(LED_PIN, pot);
  Serial.println(pot);
  delay(20);
}

Choosing 12-bit resolution makes the ADC value and the PWM duty the same range, so no scaling is needed. Details of the ADC are in the ESP32 ADC guide.

Example 3: DC Motor Speed Control

Never connect a motor directly to an ESP32 pin. A GPIO supplies about 20 mA and a motor needs hundreds. Use a driver.

Option A: a logic-level MOSFET (one direction). An IRLZ44N or similar logic-level MOSFET switches the motor's ground. Wire the ESP32 pin to the gate through a 100 Ω resistor, a 10 kΩ pull-down from gate to GND, the motor between the supply and drain, and a flyback diode across the motor. Share the ground between the ESP32 and the motor supply.

Option B: an L298N or similar H-bridge (both directions).

L298N pinESP32 pin
ENA (speed)GPIO 25 (PWM)
IN1GPIO 26
IN2GPIO 27
GNDGND (shared with motor supply)
const int ENA = 25;
const int IN1 = 26;
const int IN2 = 27;

void setup() {
  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  ledcAttach(ENA, 20000, 8); // 20 kHz is above hearing range
}

void setSpeed(int speed) {          // -255 to 255
  if (speed >= 0) {
    digitalWrite(IN1, HIGH);
    digitalWrite(IN2, LOW);
  } else {
    digitalWrite(IN1, LOW);
    digitalWrite(IN2, HIGH);
  }
  ledcWrite(ENA, abs(speed));
}

void loop() {
  setSpeed(120);  delay(2000);   // gentle forward
  setSpeed(255);  delay(2000);   // full speed
  setSpeed(0);    delay(1000);   // stop
  setSpeed(-150); delay(2000);   // reverse
}

Choose a high frequency for motors. At 1 to 5 kHz many motors whine audibly. 20 kHz or higher is silent to human ears and often gives smoother low-speed control. Note that many small motors will not start turning below roughly 30 to 40 percent duty, so expect a dead zone at the bottom of the range.

Power the motor from its own supply, not from the ESP32 3.3V or 5V pin, and connect the grounds together.

Image pending: Wiring diagram of an ESP32 controlling a small DC motor through an L298N driver with a separate battery supply and shared ground

Example 4: Servo Control With PWM

A hobby servo expects a 50 Hz signal with a pulse width of roughly 500 to 2500 microseconds (1000 to 2000 µs for the common range) that sets the angle. The easiest route is the ESP32Servo library (install it from the Library Manager):

#include <ESP32Servo.h>

Servo myServo;

void setup() {
  myServo.setPeriodHertz(50);
  myServo.attach(18, 500, 2400); // pin, min and max pulse width in microseconds
}

void loop() {
  for (int angle = 0; angle <= 180; angle += 2) {
    myServo.write(angle);
    delay(15);
  }
  for (int angle = 180; angle >= 0; angle -= 2) {
    myServo.write(angle);
    delay(15);
  }
}

If you prefer to use LEDC directly, set 50 Hz at 16-bit resolution and calculate the duty from the pulse width:

const int SERVO_PIN = 18;

void setup() {
  ledcAttach(SERVO_PIN, 50, 16);   // 50 Hz, 16-bit
}

void writeServoUs(int us) {
  // period is 20,000 us; max duty is 65535
  uint32_t duty = (uint32_t)((us / 20000.0) * 65535.0);
  ledcWrite(SERVO_PIN, duty);
}

void loop() {
  writeServoUs(1000); delay(1000);  // one end
  writeServoUs(1500); delay(1000);  // center
  writeServoUs(2000); delay(1000);  // other end
}

Power servos from an external 5V supply. A moving servo can pull hundreds of milliamps, and powering it from the ESP32 board is a common reason for random resets. Connect the supply ground to the ESP32 ground.

Image pending: Servo motor connected to GPIO 18 with an external 5V supply and a shared ground, sweeping between 0 and 180 degrees

Example 5: Play Tones on a Buzzer

A passive buzzer needs a frequency to make sound, which PWM provides. (An active buzzer has its own oscillator and just needs HIGH.)

const int BUZZER_PIN = 25;

void setup() {
  ledcAttach(BUZZER_PIN, 2000, 8);
}

void loop() {
  ledcWriteTone(BUZZER_PIN, 262); delay(300); // C4
  ledcWriteTone(BUZZER_PIN, 330); delay(300); // E4
  ledcWriteTone(BUZZER_PIN, 392); delay(300); // G4
  ledcWriteTone(BUZZER_PIN, 0);   delay(1000); // silence
}

ledcWriteTone() adjusts the frequency at a fixed 50 percent duty cycle, which is the loudest setting for a passive buzzer.

Choosing the Right Frequency

Use caseTypical frequencyResolution
LED dimming1 to 5 kHz8 to 12 bit
Hobby servo50 Hz16 bit
DC motor15 to 25 kHz8 bit
4-pin PC fan25 kHz8 bit
Buzzer tonesThe note frequency8 bit
LED strips with a MOSFET1 to 20 kHz8 to 10 bit

Common Problems

The LED never lights. The frequency and resolution combination is invalid (for example 16-bit at 5 kHz). Lower one of them. Also confirm the LED's polarity and resistor.

ledcSetup or ledcAttachPin not declared. You are on core 3.x with 2.x code. Switch to ledcAttach.

PWM changes brightness but the output flickers on camera. Raise the frequency above 1 kHz, higher for filming.

The servo twitches or jitters. The supply is weak or the ground is not shared. Use an external 5V supply.

Two outputs interfere with each other. On core 2.x two pins attached to the same channel share the same signal. Give each pin its own channel number.

PWM does not work on GPIO 34 to 39. These pins are input only.

Image pending: Oscilloscope capture showing PWM signals at 25 percent, 50 percent and 75 percent duty at 5 kHz from an ESP32 pin

Frequently Asked Questions

How many PWM channels does the ESP32 have? Sixteen LEDC channels on the original ESP32. Other chips have fewer: the ESP32-S3 has eight, and the ESP32-C3 has six.

Can I use PWM and WiFi together? Yes. LEDC is independent of the radio. Only the ADC2 analog inputs conflict with WiFi.

What is the best resolution? Use 8 bits for simple LED and motor control, and 10 to 12 bits when you need very smooth dimming at low brightness. Use 16 bits for servos.

Can I generate PWM with analogWrite() on the ESP32? Newer cores (3.x) include a working analogWrite() that wraps LEDC with a fixed 1 kHz frequency. It is fine for quick tests, but ledcAttach() gives you control over frequency and resolution.

Can PWM give a true analog voltage? Not by itself. To get a smooth voltage, add an RC filter (for example 10 kΩ and 1 µF), or use the ESP32's real DAC on GPIO 25 and 26.

Next: pair PWM with the GPIO input and output tutorial to build buttons that change brightness, or return to the ESP32 pinout guide to pick safe pins.

Image pending: Finished project with a potentiometer on GPIO 34 controlling LED brightness on GPIO 23 and a servo on GPIO 18

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.