Components · 15 min read

Rotary Encoder Arduino: KY-040 Wiring, Interrupts and Menu Code

How to use a rotary encoder with Arduino: KY-040 wiring, A/B quadrature decoding, interrupts without missed steps, debounce, LCD menu, ESP32 PCNT and Pico.

A rotary encoder is a digital control knob for Arduino projects that turns endlessly, counts one step per click and works as a push button when you press its shaft. Connected to an Arduino, a rotary encoder behaves like the menu knob on a 3D printer, the adjustment dial on an oscilloscope or the volume knob in a car: it knows how many clicks you turned and in which direction, and it never hits an end stop. The model you'll find in most hobby kits is the five-pin KY-040 module.

In this guide you'll learn how the KY-040 differs from a potentiometer, how the direction is decoded from the A and B phases (quadrature), how to filter contact bounce in hardware and software, and how to count every single click with interrupts. Then we'll build the shortcut with the Encoder library, menu navigation on an LCD and an OLED, the ESP32's hardware counter PCNT and MicroPython code for the Raspberry Pi Pico, step by step.

What is a rotary encoder and how does it work?

Inside a mechanical encoder like the KY-040 there's a toothed contact disc that turns with the shaft and two small switches that slide over it. As the shaft turns, the two switches open and close one after the other, producing square-wave pulses on the CLK (A) and DT (B) pins. The two waves are shifted by a quarter period, or 90 degrees; this is called a quadrature signal. The microcontroller finds the direction by checking which wave changes first, and the amount of rotation by counting the changes. The encoder doesn't remember its position, it only reports movement. That's why the KY-040 is an incremental encoder: the position is zero at power-up and the counting is entirely up to your code.

FeatureKY-040 value
TypeMechanical, incremental, A/B outputs
Detents20 clicks per turn (30 on some encoders)
Pulses20 full A/B cycles per turn, i.e. 80 edges
Angular resolution18 degrees per click
PinsCLK (A), DT (B), SW (button), + (supply), GND
Supply3.3–5 V (only feeds the pull-up resistors)
Built-in resistors10 kΩ pull-ups on CLK and DT; the SW resistor isn't fitted on every module
ButtonNormally open; pressing the shaft connects SW to GND (active LOW)
RotationUnlimited, no end stop

Rotary encoder vs potentiometer: what's the difference?

At first glance an encoder looks a lot like a potentiometer: both have a rotating shaft and both are used to adjust a value. But they work in completely different ways. A potentiometer is a voltage divider; it outputs an analog voltage between 0 and 5 V depending on the shaft position, and you read it with analogRead. An encoder produces pulses instead of a voltage; it tells you about change, not position.

Potentiometer for comparison with a rotary encoder
Potentiometer: its rotation is limited to about 270–300 degrees and it reports position as an analog voltage. A rotary encoder turns without stopping and produces digital pulses.
FeaturePotentiometerRotary encoder (KY-040)
RotationAbout 270–300 degrees, limitedUnlimited, continuous 360 degrees
OutputAnalog voltage (0–5 V)Digital pulses (A and B)
ReadinganalogRead (ADC)digitalRead or counting with interrupts
PositionAbsolute; the shaft position gives the value even after a power cutRelative; position is unknown at power-up
Click feelNone, turns smoothlyYes, usually 20 clicks per turn
ButtonNoYes (press the shaft)
Typical problemCrackling and drifting values over timeContact bounce (solved in software)
Typical useVolume, servo angle, analog adjustmentMenu navigation, stepped numeric settings, device panels

In short, if the value must match the physical shaft position one to one (steering a servo arm by hand or a simple volume control), a potentiometer is simpler. If you want to move through a menu, raise and lower a number in precise steps, change several settings with one knob or also change the value from software, an encoder is far more flexible. For example, if a value showing 50 is set to 80 from your phone, turning the encoder simply continues from 80; with a potentiometer the knob position and the value on screen no longer match.

A and B phases: how quadrature decoding works

When the KY-040 rests in a detent, both switches are open and, thanks to the pull-up resistors, CLK and DT both read HIGH (1). When you turn the knob by one click, the two lines drop to LOW one after the other and then return to HIGH one after the other. Clockwise, CLK drops first; counter-clockwise, DT drops first. This four-state sequence is a Gray code: only one bit changes at each step. The table below shows what the two pins read during one click of movement:

StepClockwise (CLK, DT)Counter-clockwise (CLK, DT)
Resting in a detent1, 11, 1
1st transition0, 11, 0
2nd transition0, 00, 0
3rd transition1, 00, 1
4th transition (next click)1, 11, 1

Two simple rules follow from the table. First: if DT is still HIGH when CLK falls, the knob is turning clockwise; if DT is already LOW, it's turning counter-clockwise. Second: one click has four transitions, so 20 clicks per turn means 80 transitions. Simple code only uses the first rule and counts once per click. Robust code tracks all four transitions, so it can filter out bouncing or half-finished movements. Which direction counts as clockwise can vary with the module and the wiring; if your counter runs the wrong way, just swap the CLK and DT pins in the code.

KY-040 pinout and wiring

KY-040 pinFunctionArduino UnoESP32Raspberry Pi Pico
CLKPhase AD2GPIO 25GP16
DTPhase BD3GPIO 26GP17
SWButton (LOW when pressed)D4GPIO 27GP18
+Supply5V3V33V3(OUT)
GNDGroundGNDGNDGND
On the ESP32 and Raspberry Pi Pico, always connect the module's + pin to 3.3 V. The KY-040's pull-up resistors go to the + pin, so if you power the module from 5 V, CLK and DT are pulled up to 5 V and the limit of the 3.3 V inputs is exceeded. Since the encoder is nothing but switches, it works perfectly at 3.3 V.

On the Arduino Uno and Nano, hardware interrupts are only available on pins D2 and D3, so connecting CLK and DT to these two pins is best. On the Mega the interrupt pins are 2, 3, 18, 19, 20 and 21; on the Leonardo they're 0, 1, 2, 3 and 7. On the ESP32 and Pico almost every GPIO can trigger an interrupt. You can check your board's pin map in the Pinout tool. Use INPUT_PULLUP for the SW pin in your code: some modules have no resistor on this line, and a floating pin reads as if it were randomly pressed. On the ESP32, GPIO 34–39 are input-only and have no internal pull-up, so don't use them for SW.

Step by step first test: reading by polling

  1. Connect the KY-040's CLK pin to D2, DT to D3 and SW to D4.
  2. Connect the + pin to 5V and GND to the Arduino's GND.
  3. Paste the code below into the Arduino IDE and upload it to the board.
  4. Open the Serial Monitor and set the speed to 115200 baud.
  5. Turn the knob a few clicks in both directions, then press the shaft to reset the counter.
Arduino (C++)
1// KY-040: CLK -> D2, DT -> D3, SW -> D4, + -> 5V, GND -> GND
2const byte PIN_CLK = 2;
3const byte PIN_DT  = 3;
4const byte PIN_SW  = 4;
5
6int counter = 0;
7int lastClk;
8
9void setup() {
10  pinMode(PIN_CLK, INPUT_PULLUP);   // modülde 10k pull-up zaten var / the module already has 10k pull-ups
11  pinMode(PIN_DT, INPUT_PULLUP);
12  pinMode(PIN_SW, INPUT_PULLUP);    // basınca LOW okunur / reads LOW when pressed
13  Serial.begin(115200);
14  lastClk = digitalRead(PIN_CLK);
15}
16
17void loop() {
18  int clk = digitalRead(PIN_CLK);
19  if (clk != lastClk && clk == LOW) {      // CLK düşen kenar / CLK falling edge
20    if (digitalRead(PIN_DT) == HIGH) {     // DT hâlâ HIGH: saat yönü / DT still HIGH: clockwise
21      counter++;
22      Serial.print("CW  ");
23    } else {                               // DT zaten LOW: ters yön / DT already LOW: counter-clockwise
24      counter--;
25      Serial.print("CCW ");
26    }
27    Serial.println(counter);
28  }
29  lastClk = clk;
30
31  if (digitalRead(PIN_SW) == LOW) {        // mile basıldı / shaft pressed
32    delay(20);                             // buton sıçramasını bekle / wait out the button bounce
33    if (digitalRead(PIN_SW) == LOW) {
34      counter = 0;
35      Serial.println("Sifirlandi / reset");
36      while (digitalRead(PIN_SW) == LOW) {}   // bırakılmasını bekle / wait for release
37    }
38  }
39}

On every pass through loop the code reads CLK and only catches the moment CLK falls from HIGH to LOW. If DT is still HIGH at that moment, the knob has turned clockwise; if it's LOW, counter-clockwise. This is the easiest method to understand, but it has two weak points. First, pulses that arrive while loop is busy with other work (updating a display, delay, reading sensors) are missed, and the counter falls behind when you turn quickly. Second, mechanical contacts bounce: CLK can go LOW-HIGH several times while falling once, and the code may count a single click as two or three. The next two sections solve both problems.

Contact bounce (debounce): hardware and software fixes

The metal springs inside the encoder bounce a few times when they touch the contact before they settle. Manufacturer datasheets usually give this time as a few milliseconds (for example 2 ms maximum). With push buttons you fix bounce by saying "wait 20–50 ms after the last change", but that doesn't work for an encoder: when it's turned quickly there are only a few milliseconds between two edges, and a long wait swallows real steps too. These methods are used instead:

  • Hardware filter: Put a 10–100 nF capacitor between CLK and GND and another between DT and GND. Together with the module's 10 kΩ pull-ups they form an RC filter; the time constant is between 10 kΩ × 10 nF = 0.1 ms and 10 kΩ × 100 nF = 1 ms, which swallows short bounces. You can calculate it with the RC time constant tool and decode a capacitor marked 103 or 104 with the capacitor code tool.
  • State table: On every edge of both lines, the old and new AB states are compared. Bouncing means jumping back and forth between the same two states, which produces +1 and −1 in turn, and they cancel out. Transitions where both bits change at once are treated as invalid and skipped.
  • Decide only at a detent: Quarter steps are accumulated and the counter is only increased or decreased when the encoder settles into a detent (both lines HIGH). That way, a contact jittering on a click doesn't make the menu cursor jump.
  • Lockout for the button: The SW pin behaves like an ordinary push button; a 30 ms lockout with millis() is enough.

Reading with interrupts so you never miss a step

An interrupt makes the processor drop what it's doing the moment a pin changes and run a short function (ISR). Once the encoder lines are attached to interrupts, no edge is lost even if loop contains a 50 ms delay or the display updates slowly. The code below runs on both edges of CLK and DT, uses the state table described above and only counts when the knob settles into a detent. For the basics, see the Arduino interrupt article as well.

Arduino (C++)
1// KY-040: CLK -> D2 (INT0), DT -> D3 (INT1), SW -> D4, + -> 5V
2const byte PIN_CLK = 2;
3const byte PIN_DT  = 3;
4const byte PIN_SW  = 4;
5
6// İndeks: (eski AB << 2) | yeni AB / Index: (old AB << 2) | new AB
7const int8_t QEM[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
8
9volatile long position = 0;   // tık sayısı, ISR ile loop paylaşır / detent count shared by ISR and loop
10uint8_t lastAB = 3;           // son durum, 3 = ikisi de HIGH / last state, 3 = both HIGH
11int8_t quarter = 0;           // iki tık arası çeyrek adımlar / quarter steps between detents
12
13void readEncoder() {
14  uint8_t ab = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
15  quarter += QEM[(lastAB << 2) | ab];
16  lastAB = ab;
17  if (ab == 3) {                          // tıka oturdu / settled in a detent
18    if (quarter > 1) position++;          // saat yönü / clockwise
19    else if (quarter < -1) position--;    // ters yön / counter-clockwise
20    quarter = 0;
21  }
22}
23
24bool buttonPressed() {
25  static bool lastState = HIGH;
26  static unsigned long lastChange = 0;
27  bool state = digitalRead(PIN_SW);
28  if (state != lastState && millis() - lastChange > 30) {   // 30 ms kilit / 30 ms lockout
29    lastChange = millis();
30    lastState = state;
31    return state == LOW;                  // yalnızca basış anı / only the moment of pressing
32  }
33  return false;
34}
35
36void setup() {
37  pinMode(PIN_CLK, INPUT_PULLUP);
38  pinMode(PIN_DT, INPUT_PULLUP);
39  pinMode(PIN_SW, INPUT_PULLUP);
40  Serial.begin(115200);
41  lastAB = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
42  attachInterrupt(digitalPinToInterrupt(PIN_CLK), readEncoder, CHANGE);   // her iki kenar / both edges
43  attachInterrupt(digitalPinToInterrupt(PIN_DT), readEncoder, CHANGE);
44}
45
46long lastPosition = 0;
47
48void loop() {
49  noInterrupts();                         // 4 baytı güvenle kopyala / copy the 4 bytes safely
50  long pos = position;
51  interrupts();
52
53  if (pos != lastPosition) {
54    Serial.print(pos > lastPosition ? "CW  " : "CCW ");
55    Serial.println(pos);
56    lastPosition = pos;
57  }
58
59  if (buttonPressed()) {
60    noInterrupts();
61    position = 0;
62    interrupts();
63    Serial.println("Sifirlandi / reset");
64  }
65
66  delay(50);   // loop yavaş olsa da tık kaçmaz / no detent is lost even with a slow loop
67}
Code partWhat does it do?
QEM[16] tableTurns the old and new AB state into +1, −1 or 0; invalid transitions where both lines change at once count as 0
attachInterrupt(..., CHANGE)Runs readEncoder() on both the rising and falling edges of CLK and DT
quarterAccumulates the quarter steps between two clicks; +1 and −1 pairs caused by bounce cancel out
if (ab == 3)The decision is made only when the encoder settles into a detent (both lines HIGH); an accumulated value of +2 or more counts as one click
volatile long positionThe variable shared by the ISR and loop; volatile stops the compiler from using a stale copy
noInterrupts() / interrupts()Prevents the ISR from cutting in while the 4-byte long is being copied
buttonPressed()Debounces SW with a 30 ms lockout and returns true only once, at the moment of pressing
Keep the ISR as short as possible: don't use Serial.print, delay or display commands inside it; just update the counter and leave the real work to loop. Declare every variable shared by the ISR and loop as volatile. Because the Uno has an 8-bit processor, briefly disabling interrupts while reading a 2- or 4-byte variable stops you from reading a half-updated value.

The shortcut: the Encoder library

If you don't want to write your own ISR, Paul Stoffregen's Encoder library does the same job with optimized code. Open the Library Manager in the Arduino IDE, search for "Encoder" and install Paul Stoffregen's library; for the installation steps, have a look at the how to add an Arduino library guide. The library gives the best performance when both pins are interrupt pins, good performance when only the first pin passed to the constructor is, and low performance when neither is. Because it counts every edge, the value changes by 4 per click; divide by 4 to get the number of clicks.

Arduino (C++)
1#include <Encoder.h>
2
3// Önce DT (3), sonra CLK (2): saat yönü artı sayılır / DT (3) first, then CLK (2): clockwise counts up
4Encoder knob(3, 2);
5const byte PIN_SW = 4;
6long lastDetent = 0;
7
8void setup() {
9  pinMode(PIN_SW, INPUT_PULLUP);
10  Serial.begin(115200);
11}
12
13void loop() {
14  long detent = knob.read() / 4;          // 4 kenar = 1 tık / 4 edges = 1 detent
15  if (detent != lastDetent) {
16    lastDetent = detent;
17    Serial.println(detent);
18  }
19  if (digitalRead(PIN_SW) == LOW) {       // basınca sıfırla / reset on press
20    knob.write(0);
21  }
22}

read() returns the total number of edges so far, write(0) resets the counter, and readAndReset() reads and resets in one go. We pass DT first and CLK second to the constructor because of the sign of the direction: in this order clockwise counts as positive. If it's reversed for you, swap 2 and 3. The library also enables the pins' internal pull-up resistors by itself.

I2C 16x2 LCD display
I2C 16x2 LCD display: it connects with just the SDA and SCL wires; the settings of the menu example are shown on this display.

Menus are where encoders are used most: you turn the knob to move between lines, press it to put the selected setting into edit mode, turn it to change the value and press once more to return to the menu. The example below adjusts the red, green and blue channels of an RGB LED between 0 and 255 and shows the values on an I2C LCD display. Connect the LCD to A4 (SDA) and A5 (SCL), and the colour legs of a common-cathode RGB LED to D9, D10 and D11 through 220 Ω resistors.

Arduino (C++)
1#include <Wire.h>
2#include <LiquidCrystal_I2C.h>
3
4LiquidCrystal_I2C lcd(0x27, 16, 2);           // adres 0x27 ya da 0x3F / address 0x27 or 0x3F
5
6const byte PIN_CLK = 2, PIN_DT = 3, PIN_SW = 4;
7const byte COUNT = 3;
8const byte LED_PINS[COUNT] = {9, 10, 11};     // RGB LED, PWM pinleri / PWM pins
9const char* NAMES[COUNT] = {"Kirmizi/Red", "Yesil/Green", "Mavi/Blue"};
10int values[COUNT] = {0, 0, 0};                // 0-255 parlaklık / brightness
11
12const int8_t QEM[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
13volatile int steps = 0;                       // loop'un işlemediği tıklar / detents not yet handled by loop
14uint8_t lastAB = 3;
15int8_t quarter = 0;
16
17int selected = 0;                             // seçili satır / selected item
18bool editing = false;                         // değer düzenleme modu / value edit mode
19
20void readEncoder() {
21  uint8_t ab = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
22  quarter += QEM[(lastAB << 2) | ab];
23  lastAB = ab;
24  if (ab == 3) {
25    if (quarter > 1) steps++;
26    else if (quarter < -1) steps--;
27    quarter = 0;
28  }
29}
30
31bool buttonPressed() {
32  static bool lastState = HIGH;
33  static unsigned long lastChange = 0;
34  bool state = digitalRead(PIN_SW);
35  if (state != lastState && millis() - lastChange > 30) {
36    lastChange = millis();
37    lastState = state;
38    return state == LOW;
39  }
40  return false;
41}
42
43void drawMenu() {
44  int top = selected > COUNT - 2 ? COUNT - 2 : selected;   // görünen ilk satır / first visible row
45  for (byte row = 0; row < 2; row++) {
46    int i = top + row;
47    char mark = ' ';
48    if (i == selected) mark = editing ? '*' : '>';
49    char line[17];
50    snprintf(line, sizeof(line), "%c%-11s%4d", mark, NAMES[i], values[i]);
51    lcd.setCursor(0, row);
52    lcd.print(line);
53  }
54}
55
56void setup() {
57  pinMode(PIN_CLK, INPUT_PULLUP);
58  pinMode(PIN_DT, INPUT_PULLUP);
59  pinMode(PIN_SW, INPUT_PULLUP);
60  for (byte i = 0; i < COUNT; i++) pinMode(LED_PINS[i], OUTPUT);
61  lcd.init();
62  lcd.backlight();
63  lastAB = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
64  attachInterrupt(digitalPinToInterrupt(PIN_CLK), readEncoder, CHANGE);
65  attachInterrupt(digitalPinToInterrupt(PIN_DT), readEncoder, CHANGE);
66  drawMenu();
67}
68
69void loop() {
70  noInterrupts();
71  int s = steps;                              // biriken tıkları al / take the pending detents
72  steps = 0;
73  interrupts();
74
75  if (s != 0) {
76    if (editing) {
77      values[selected] = constrain(values[selected] + s * 5, 0, 255);   // tık başına 5 / 5 per detent
78      analogWrite(LED_PINS[selected], values[selected]);
79    } else {
80      selected = constrain(selected + s, 0, COUNT - 1);
81    }
82    drawMenu();
83  }
84
85  if (buttonPressed()) {                      // bas: düzenle ya da bitir / press: edit or finish
86    editing = !editing;
87    drawMenu();
88  }
89}

A > sign appears at the start of the selected line, and a * sign in edit mode. The encoder code is the same as in the previous example; the only difference is that the ISR accumulates the number of unprocessed clicks (steps) instead of a position, and loop reads and clears it. So even if loop stays busy for a while, no click is lost; the accumulated ones are handled together on the next pass. In edit mode each click changes the value by 5; for finer control you can reduce the step to 1 or add acceleration that increases the step when the knob is turned quickly.

To show the same menu on an OLED display, you only need to change the drawMenu() function. The version below draws the selected line in inverted colours on a white bar. Add the Adafruit SSD1306 and Adafruit GFX libraries instead of the LCD library, delete the lcd object and call display.begin() instead of lcd.init() in setup().

Arduino (C++)
1// LCD menü kodunda değişecek kısımlar / parts to change in the LCD menu sketch
2#include <Adafruit_GFX.h>
3#include <Adafruit_SSD1306.h>
4
5Adafruit_SSD1306 display(128, 64, &Wire, -1);
6// setup() içinde lcd.init() ve lcd.backlight() yerine / in setup() instead of lcd.init() and lcd.backlight():
7//   display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
8
9void drawMenu() {
10  display.clearDisplay();
11  display.setTextSize(1);
12  for (byte i = 0; i < COUNT; i++) {
13    int y = i * 16;
14    if (i == selected) {
15      display.fillRect(0, y, 128, 14, SSD1306_WHITE);   // seçili satır çubuğu / highlight bar
16      display.setTextColor(SSD1306_BLACK);
17    } else {
18      display.setTextColor(SSD1306_WHITE);
19    }
20    display.setCursor(4, y + 3);
21    display.print(NAMES[i]);
22    display.setCursor(98, y + 3);
23    display.print(values[i]);
24    if (i == selected && editing) display.print('*');   // düzenleme işareti / edit marker
25  }
26  display.display();
27}
If you don't want the settings to be lost when the power goes off, write the value to EEPROM when leaving edit mode and read it back at start-up. EEPROM has a limited number of write cycles, so save only when the button is pressed, not on every click. Using a long press as a "back" command is also an easy way to build deeper menus.

Rotary encoder with the ESP32: the PCNT hardware counter

The ESP32 has a hardware block called PCNT (Pulse Counter) that counts pulses without keeping the processor busy at all. Each unit has two channels, so it can decode the A/B signal directly; the classic ESP32 has 8 PCNT units, and the ESP32-S2 and S3 have 4. The counter is 16 bits wide and includes a glitch filter that removes short noise. This filter can be set to at most about 12.8 microseconds, so it only swallows very short spikes, not the whole contact bounce. Four-edge counting balances out the rest of the bounce by itself: back-and-forth bounces cancel each other as +1 and −1. The code below uses the ESP-IDF driver that comes with version 3.x of the Arduino ESP32 core.

Arduino (C++)
1#include "driver/pulse_cnt.h"
2#include "driver/gpio.h"
3
4// KY-040: CLK -> GPIO 25, DT -> GPIO 26, SW -> GPIO 27, + -> 3V3
5const int PIN_CLK = 25;
6const int PIN_DT  = 26;
7const int PIN_SW  = 27;
8
9pcnt_unit_handle_t unit = NULL;
10
11void setup() {
12  Serial.begin(115200);
13  pinMode(PIN_SW, INPUT_PULLUP);
14
15  pcnt_unit_config_t unitCfg = {};
16  unitCfg.low_limit = -10000;                 // sınıra varınca sayaç 0'a döner / the counter returns to 0 at a limit
17  unitCfg.high_limit = 10000;
18  ESP_ERROR_CHECK(pcnt_new_unit(&unitCfg, &unit));
19
20  pcnt_glitch_filter_config_t filterCfg = {};
21  filterCfg.max_glitch_ns = 1000;             // 1 µs'den kısa iğneleri yok say / ignore spikes shorter than 1 µs
22  ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(unit, &filterCfg));
23
24  pcnt_chan_config_t aCfg = {};
25  aCfg.edge_gpio_num = PIN_CLK;               // A kanalı: CLK kenarları, DT seviyesi / channel A: CLK edges, DT level
26  aCfg.level_gpio_num = PIN_DT;
27  pcnt_channel_handle_t chA = NULL;
28  ESP_ERROR_CHECK(pcnt_new_channel(unit, &aCfg, &chA));
29
30  pcnt_chan_config_t bCfg = {};
31  bCfg.edge_gpio_num = PIN_DT;                // B kanalı: DT kenarları, CLK seviyesi / channel B: DT edges, CLK level
32  bCfg.level_gpio_num = PIN_CLK;
33  pcnt_channel_handle_t chB = NULL;
34  ESP_ERROR_CHECK(pcnt_new_channel(unit, &bCfg, &chB));
35
36  // 4x çözme: iki hattın tüm kenarları sayılır / 4x decoding: every edge of both lines is counted
37  ESP_ERROR_CHECK(pcnt_channel_set_edge_action(chA, PCNT_CHANNEL_EDGE_ACTION_DECREASE, PCNT_CHANNEL_EDGE_ACTION_INCREASE));
38  ESP_ERROR_CHECK(pcnt_channel_set_level_action(chA, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_INVERSE));
39  ESP_ERROR_CHECK(pcnt_channel_set_edge_action(chB, PCNT_CHANNEL_EDGE_ACTION_INCREASE, PCNT_CHANNEL_EDGE_ACTION_DECREASE));
40  ESP_ERROR_CHECK(pcnt_channel_set_level_action(chB, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_INVERSE));
41
42  gpio_pullup_en((gpio_num_t)PIN_CLK);        // çıplak encoder için iç pull-up / internal pull-up for a bare encoder
43  gpio_pullup_en((gpio_num_t)PIN_DT);
44
45  ESP_ERROR_CHECK(pcnt_unit_enable(unit));
46  ESP_ERROR_CHECK(pcnt_unit_clear_count(unit));
47  ESP_ERROR_CHECK(pcnt_unit_start(unit));
48}
49
50int lastDetent = 0;
51
52void loop() {
53  int count = 0;
54  pcnt_unit_get_count(unit, &count);
55  int detent = count / 4;                     // 4 kenar = 1 tık / 4 edges = 1 detent
56  if (detent != lastDetent) {
57    lastDetent = detent;
58    Serial.printf("Tik / detent: %d  (ham / raw: %d)\n", detent, count);
59  }
60  if (digitalRead(PIN_SW) == LOW) {           // basınca sıfırla / reset on press
61    pcnt_unit_clear_count(unit);
62  }
63  delay(20);
64}

The two channels are set up as mirror images of each other: channel A counts the edges of CLK and flips the direction according to the level of DT, while channel B counts the edges of DT relative to CLK. As a result the counter changes by 4 per click. When it reaches the ±10000 limit it returns to zero; if you need a wider range, you can accumulate overflows with the flags.accum_count option and watch points. The ESP32-C3 has no PCNT unit; on that board use the interrupt-based Arduino code. If you're on version 2.x of the Arduino ESP32 core or want shorter code, the ESP32Encoder library uses the same hardware and does the job with its attachFullQuad() and getCount() functions. If you use MicroPython on the ESP32, the machine.Encoder class in recent versions (e.g. 1.27) also uses PCNT behind the scenes. If you're new to the ESP32, read What is the ESP32 first.

Rotary encoder with the Raspberry Pi Pico and MicroPython

Every GPIO pin on the Pico can trigger an interrupt, so reading an encoder in MicroPython with Pin.irq() is easy. The code below uses exactly the same state table as the Arduino example and turns the encoder into an LED dimmer: each click changes the brightness by 5 percent and pressing the shaft switches the LED on and off. Connect CLK to GP16, DT to GP17, SW to GP18 and an LED through a 220 Ω resistor to GP15. For setting up MicroPython, see the What is the Raspberry Pi Pico article.

MicroPython
1from machine import Pin, PWM
2import time
3
4clk = Pin(16, Pin.IN, Pin.PULL_UP)     # A fazı / phase A
5dt = Pin(17, Pin.IN, Pin.PULL_UP)      # B fazı / phase B
6sw = Pin(18, Pin.IN, Pin.PULL_UP)      # buton, basınca 0 / button, 0 when pressed
7led = PWM(Pin(15))                     # LED + 220 ohm
8led.freq(1000)
9
10# İndeks: (eski AB << 2) | yeni AB / Index: (old AB << 2) | new AB
11QEM = (0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0)
12last_ab = (clk.value() << 1) | dt.value()
13quarter = 0
14position = 10                          # 0-20 tık, başlangıç %50 / 0-20 detents, start at 50 %
15
16def on_edge(pin):
17    global last_ab, quarter, position
18    ab = (clk.value() << 1) | dt.value()
19    quarter += QEM[(last_ab << 2) | ab]
20    last_ab = ab
21    if ab == 3:                        # tıka oturdu / settled in a detent
22        if quarter > 1 and position < 20:
23            position += 1
24        elif quarter < -1 and position > 0:
25            position -= 1
26        quarter = 0
27
28clk.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=on_edge)
29dt.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=on_edge)
30
31on = True
32shown = -1
33while True:
34    if sw.value() == 0:                # mile basıldı / shaft pressed
35        time.sleep_ms(20)              # sıçramayı bekle / wait out the bounce
36        if sw.value() == 0:
37            on = not on
38            shown = -1                 # yeniden yaz / force an update
39            while sw.value() == 0:     # bırakılmasını bekle / wait for release
40                time.sleep_ms(10)
41    if position != shown:
42        shown = position
43        led.duty_u16(shown * 65535 // 20 if on else 0)
44        print("Parlaklik / brightness: %d%%" % (shown * 5), "acik / on" if on else "kapali / off")
45    time.sleep_ms(10)

We check the limits of the value (0–20 clicks) inside the interrupt function, so the main loop only reads the variable and there's no race condition where both sides write at the same time. You'll find how LED brightness is controlled in the What is PWM article. If you prefer a ready-made solution, the rotary_irq_rp2 module from the micropython-rotary project comes with limits, wrap-around and direction reversal. For very fast motor encoders, the Pico's PIO block can count without loading the processor at all. If you're new to MicroPython, the MicroPython basics article is a good place to start.

Types of rotary encoder: mechanical, optical and magnetic

TypeHow does it work?Typical resolutionWhere is it used?
Mechanical (KY-040, EC11)Contact disc and two switches12–30 clicks per turnMenu knobs, volume controls, device front panels
OpticalSlotted disc, LED and phototransistorHundreds to thousands of pulses per turnCNC machines, robot joints, industrial motors
Magnetic (Hall effect)Magnetic disc and Hall sensorsA few pulses per motor shaft turn (e.g. 7 or 11)N20 and geared DC motors with encoders
AbsoluteOutputs a unique code for every positionFor example 12 bits (4096 positions) on the AS5600Robot arms, angle measurement; knows its position at power-up

The code in this article also works with other incremental encoders that have A/B outputs. For example, on a geared DC motor with an encoder, a Hall encoder giving 11 pulses per motor shaft turn, four-edge counting and a 1:30 gear ratio produce 11 × 4 × 30 = 1320 counts per turn of the output shaft. When the pulse rate climbs into the thousands per second, interrupt-based code takes a noticeable share of the processor's time, whereas the ESP32's PCNT unit does this counting effortlessly. Industrial optical encoders mostly run on 5–24 V and have open-collector or differential outputs; always check the output type and voltage in the datasheet before connecting one.

Rotary encoder project ideas

  • Digital volume knob: Set the volume of music playing on a DFPlayer Mini with the encoder and press the shaft to skip to the next track.
  • Colour picker lamp: Rotate the hue of a WS2812B LED ring with the encoder and press to switch to brightness mode.
  • Precise positioning: Make each click move a stepper motor by a set number of steps; build a camera slider or a turntable.
  • Kitchen timer: Set the time with the encoder, press to start the countdown and sound a buzzer when time is up.
  • Game controller: Use the encoder as the paddle in Pong; in Arduino game projects it gives finer control than a joystick.
  • Clock and alarm setting: Set the time and alarm of an RTC clock module with a single knob.
  • Test instrument panel: Change a PWM frequency, a signal generator value or the target temperature of a soldering station in fine steps.

Common mistakes and how to fix them

ProblemLikely causeSolution
Counts 2 or 4 per clickEvery edge is counted separately or the contacts bounceDivide the edge count by 4, use a state table, add 10–100 nF capacitors to CLK and DT
Counts only every second click30-click / 15-pulse encoder; each click is half a cycleDivide the library value by 2 instead of 4; in the table code change if (ab == 3) to if (ab == 3 || ab == 0)
Direction is reversedCLK and DT are swapped or the module is differentSwap the CLK and DT pin numbers in the code
Steps are missed when turning fastPolling is used, there's a delay in loopUse interrupts or the ESP32 PCNT, keep the ISR short
The button presses itselfSW pin is floating, no pull-upUse pinMode(SW, INPUT_PULLUP)
The counter jumps randomlyNo common GND, long or loose wiresShare GND, shorten the wires, check the breadboard contacts
fatal error: driver/pulse_cnt.h: No such file or directory / undefined reference to 'pcnt_new_unit'Arduino ESP32 core 2.x (first error) or an ESP32-C3, which has no PCNT unit (second error)Update the core to 3.x in the Boards Manager or use the ESP32Encoder library; on the C3 use the interrupt code
The LCD only shows blocksWrong contrast setting or I2C addressTurn the trimmer on the back, try address 0x3F instead of 0x27
If you want to try the code before building the circuit, the Wokwi simulator has a ready-made KY-040 part: after clicking the encoder you can turn it click by click with the arrow keys and press the button with the space bar. For setup, see the Wokwi simulation guide.

A rotary encoder is one of the cheapest ways to give a project a professional user interface: a single knob and a display can handle dozens of settings. Start with the polling code to understand the logic, then move to the interrupt or PCNT version and adapt the menu example to your own project. In the BlueGrays app you'll find potentiometer servo control, I2C LCD, push button and 7-segment counter projects with circuit diagrams and step-by-step code, and you can easily upgrade them by replacing the potentiometer or button with an encoder.

Frequently asked questions

What is the difference between a rotary encoder and a potentiometer?

A potentiometer outputs an analog voltage that depends on the shaft position, its rotation is limited to about 270–300 degrees, and you read it with analogRead. A rotary encoder turns endlessly and produces digital pulses on every click; it reports the direction and amount of movement, not the position. An encoder is better for menu navigation and stepped settings, a potentiometer for simple position-based adjustments.

Why does my rotary encoder count two steps per click or skip steps?

Counting too much is usually caused by contact bounce or by counting every edge separately; dividing the 4 edges per click by 4 and using a state table fixes it. Skipped steps happen when pulses arrive while loop is busy. Connect CLK and DT to interrupt pins and read them with interrupts, and add 10–100 nF capacitors if needed, and the problem goes away.

Does the KY-040 rotary encoder work at 3.3 V?

Yes. The KY-040 consists only of mechanical switches and pull-up resistors, so it works perfectly at 3.3 V too. On the ESP32 and Raspberry Pi Pico, always connect the + pin to 3V3; if you power it from 5 V, the pull-up resistors pull CLK and DT up to 5 V and the limit of the 3.3 V inputs is exceeded.

How many pulses per revolution does the KY-040 give?

Common KY-040 modules have 20 clicks per turn, and the A and B lines complete one full cycle per click. Each cycle has four edges, so you count 80 edges per turn, and each click is 18 degrees. Some encoders have 30 clicks and 15 pulses; on those, each click corresponds to half a cycle.

Can a rotary encoder be read without interrupts?

Yes, it can be read by polling, checking the CLK pin constantly inside loop, and that's often enough for a menu knob turned slowly. But if loop contains delay, display updates or long calculations, quickly turned clicks will be missed. For reliable results, interrupts are recommended, and on the ESP32 the PCNT hardware counter.

Can I connect more than one rotary encoder to an Arduino?

Yes. Since only D2 and D3 provide hardware interrupts on the Uno, you can connect the first pin of each encoder (the one passed first to the constructor) to an interrupt pin and the other to a normal pin using the Encoder library, or use pin change interrupts. The Mega has six interrupt pins, the classic ESP32 can count 8 encoders in hardware with its 8 PCNT units, and on the Pico every GPIO can trigger an interrupt.

See all projects
Toggle an LED with a Button (On/Off)
#10Beginner

Toggle an LED with a Button (On/Off)

Press the button once and the light comes on, press it again and it goes off, just like the light switch in your room. The Arduino notices every press and flips the LED to the opposite state, waiting

4.8(100)
162
ESP32 LED Control with Pushbutton (Toggle)
#105Beginner

ESP32 LED Control with Pushbutton (Toggle)

Each time the button is pressed the state of the LED changes: if it is on it turns off, if it is off it turns on. Classic toggle pattern. With debounce we clean up the mechanical noise of the pushbutton to ensure a single toggle with a single press.

4.6(39)
57
Raspberry Pi Pico Button Debounce: IRQ vs Software Polling (GP5 + GP4)
#159Intermediate
Premium

Raspberry Pi Pico Button Debounce: IRQ vs Software Polling (GP5 + GP4)

Press a button once, code counts 3-4? That is **contact bounce** — metal contacts oscillate for microseconds! 🔁 Compare 2 counting methods side-by-side in Serial: **RAW polling** (catches bounce, over-counts) vs **IRQ + software debounce** (accurate). Learn contact bounce physics, software debounce (`ticks_diff() > 50ms`), **Pin.irq()** interrupt registration, **IRQ_FALLING** trigger, ISR handlers, `global` + ISR safety. LED toggles via IRQ (visual feedback). Serial shows both counters per press

0.0(0)
12
Servo Motor Operation with Potentiometer
#36Beginner
Premium

Servo Motor Operation with Potentiometer

As you turn the potentiometer knob, the servo motor's arm turns right along with it: one end of the knob means zero degrees, the other end means half a turn. The Arduino keeps reading the knob's posit

4.6(37)
52
ESP32 LED Brightness Control with a Potentiometer (Analog → PWM)
#115Intermediate
Premium

ESP32 LED Brightness Control with a Potentiometer (Analog → PWM)

In this project, we control the brightness of an LED analogically using a potentiometer. The ESP32's ADC pin reads the 0-3.3V voltage from the potentiometer, and this value is then converted into a PWM (Pulse Width Modulation) signal and sent to the LED. As a result, as you turn the potentiometer, the LED gradually brightens and dims. With Arduino Core 3.0+, `analogWrite()` can be used directly; on the ESP32-S2/S3, the ADC resolution is fixed at 12-bit.

4.5(19)
32
Counter with 7-Segment Display and Buttons
#43Advanced
Premium

Counter with 7-Segment Display and Buttons

A simple counter with three buttons: press one and the digit on the display goes up by one, press another and it goes down by one, and the third sends it back to zero. The single-digit 7-segment displ

4.6(35)
47
Using LCD Screen with I2C
#32Beginner
Premium

Using LCD Screen with I2C

Give your Arduino its own screen and make it write its first message. The two-line LCD screen hooks up with just a few wires; a greeting appears on the top line, then a second message on the bottom li

4.8(53)
70
Paddle Ball Game - SSD1306
#86Intermediate
Premium

Paddle Ball Game - SSD1306

A ball, two paddles and you: you play table tennis, one of the very first video games, on a small OLED screen. Two buttons move your paddle on the right up and down, while the computer controls the le

4.5(47)
65