Rotary encoders are one of the most useful input devices you can add to an ESP32 project. With a simple twist, they let you scroll through menus, adjust values, control speed, or change settings in a way that feels much more natural than repeatedly pressing buttons.
In this tutorial, we’ll see how to connect a rotary encoder to the ESP32 and read its position in software. Along the way, we’ll also understand how the encoder actually works, so the wiring and code make much more sense.
Let’s begin by looking at the working principle of a rotary encoder.
How Rotary Encoders Work?
Inside a rotary encoder, there’s a circular disc with contact patterns. This disc is attached to the knob you turn. The disc connects to a pin called “C,” which serves as the common ground. The encoder also has two other important pins, called “A” and “B.” These pins will help us figure out which direction the knob is turning.

When you turn the encoder’s knob, the circular disc rotates along with it. As this happens, pins A and B repeatedly make contact with the common ground (pin C).
The important thing to understand is how they make contact. Because of the way the contacts are arranged, pins A and B don’t touch the ground at the exact same time. One pin always touches just before the other. This creates two separate signals that are slightly out of sync. In technical terms, these two signals are called quadrature signals because they are 90° out of phase.

Because of this phase difference, the direction of rotation can be detected by seeing which signal leads the other. If A leads B, the shaft is rotating in one direction. And if B leads A, the shaft is rotating in the opposite direction.
A simple way to decode this in software is to monitor one channel, such as A, and check the state of B whenever A changes.
- If pin B’s state is different from pin A (B ≠ A), then the knob is rotating in one direction.

- If pin B’s state is the same as pin A (B = A), then the knob is rotating in the opposite

This is a convenient method for basic polling code. More advanced methods track every change on both A and B for more reliable decoding.
Rotary Encoder Pinout
The pinout of the rotary encoder module is as follows:

GND is the ground connection.
+ (VCC) supplies power to the encoder. You connect it to the 3.3V pin on your ESP32.
SW (Switch) is connected to a built-in push-button inside the encoder. Normally, this pin is held “HIGH” (at a positive voltage) by using ESP32’s internal pull-up resistor, or by using an external pull-up resistor. When you press the button, the SW pin is pulled down to “LOW”.
CLK (Channel A) pin provides one of the two quadrature output signals used to detect rotation. You will connect this pin to one of the GPIO pins on your ESP32.
DT (Channel B) pin provides the second quadrature output signal. This signal is similar to Channel A, but it’s 90 degrees out of phase with signal A. Like the CLK pin, you connect DT to another GPIO pin on your ESP32.
Wiring a Rotary Encoder to an ESP32
Now that we understand how a rotary encoder works, let’s connect it to ESP32 and put it to use! The wiring is simple and straightforward.
First, we need to provide power to the rotary encoder. Connect the encoder’s +V pin to the ESP32’s 3.3V and the GND pin to the ESP32’s GND.
Next, we’ll connect the signal pins. The CLK (Clock) pin on the encoder should be connected to GPIO #25 on the ESP32, while the DT (Data) pin goes to GPIO #26.
Finally, we need to connect the switch pin. Connect the SW (Switch) pin to GPIO #27 on the ESP32.
The following table lists the pin connections:
| Rotary Encoder | ESP32 | |
| + (VCC) | 3.3V | |
| GND | GND | |
| CLK | 25 | |
| DT | 26 | |
| SW | 27 |
You can also refer to the wiring diagram below for a visual guide:

Once the connections are complete, your rotary encoder will be ready to use with the ESP32!
Example 1 – Reading a Rotary Encoder Without Interrupts (Polling Method)
Below is a simple program to read a rotary encoder using the polling method. In this approach, the ESP32 continuously checks the state of the CLK pin inside the main loop. When it detects a change, it reacts only to the rising edge, then reads the DT pin at that moment to determine the direction of rotation.
This method is easy to understand and works well for basic projects, which makes it a good starting point for learning how rotary encoders are read in software.
First, upload and try the sketch below, and then we’ll go through it step by step to understand how it works.
// Pin Configuration
#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27
int counter = 0;
int currentStateCLK;
int lastStateCLK;
String currentDir = "";
unsigned long lastButtonPress = 0;
void setup() {
// Use internal pull-ups for stable inputs
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
Serial.begin(115200);
// Read the initial state of CLK
lastStateCLK = digitalRead(CLK_PIN);
}
void loop() {
// Read the current state of CLK
currentStateCLK = digitalRead(CLK_PIN);
// If the CLK state changed and is now HIGH, a pulse occurred
if (currentStateCLK != lastStateCLK && currentStateCLK == HIGH) {
// If DT is different from CLK, direction is one way
if (digitalRead(DT_PIN) != currentStateCLK) {
counter--;
currentDir = "CCW";
} else {
counter++;
currentDir = "CW";
}
Serial.print("Direction: ");
Serial.print(currentDir);
Serial.print(" | Counter: ");
Serial.println(counter);
}
// Save last CLK state
lastStateCLK = currentStateCLK;
// Read push button
int btnState = digitalRead(SW_PIN);
//If we detect LOW signal, button is pressed
if (btnState == LOW) {
//if 200ms have passed since last LOW pulse, it means that the
//button has been pressed, released and pressed again
if (millis() - lastButtonPress > 200) {
Serial.println("Button pressed!");
// Save last button press event
lastButtonPress = millis();
}
}
// Put in a slight delay to help debounce the reading
delay(1);
}After uploading the code to your ESP32, open the Serial Monitor. Turn the knob in both directions and watch the counter values change on the screen. Try pressing the button and see if “Button pressed!” appears.

If you notice that the rotation direction is opposite of what you expect, you can easily fix this by swapping the connections of the CLK and DT pins.
Code Explanation:
In the beginning of our code, we define which ESP32 GPIO pins connect to the encoder’s CLK, DT, and SW pins.
#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27We also create several important variables.
- The
countervariable keeps track of how many steps the encoder has rotated. - The
currentStateCLKandlastStateCLKvariables store the state of the CLK pin at different times, which helps us detect movement. - The
currentDirvariable tells us whether the encoder is turning clockwise (CW) or counterclockwise (CCW). - Lastly, the
lastButtonPressvariable helps prevent false button presses by making sure we don’t count multiple presses when you only pushed the button once.
int counter = 0;
int currentStateCLK;
int lastStateCLK;
String currentDir = "";
unsigned long lastButtonPress = 0;In the setup() function, we first set the rotary encoder’s pins as inputs so we can read their signals. On the ESP32, it is a good idea to enable the internal pull-up resistors on the encoder pins as well as the switch pin. This keeps the input signals stable and helps prevent floating readings when the contacts are open.
We also start the Serial Monitor so we can see what is happening.
Finally, we read the initial state of the CLK pin and store it in lastStateCLK. This is important because it gives us a reference point so we can detect when the knob moves.
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
Serial.begin(115200);
lastStateCLK = digitalRead(CLK_PIN);In the loop() section, we read the current state of the CLK pin and compare it with the previous state stored in lastStateCLK. If the state has changed, we know the knob has moved.
To avoid counting the same step twice, we react only when the CLK signal becomes HIGH. This means we are using just one edge of the signal for counting.
To figure out which direction the encoder is moving, we check the DT pin at that moment.
- If DT is different from CLK, the encoder is rotating counterclockwise (CCW), so we decrease the counter and set
currentDirto “CCW”. - If DT is the same as CLK, the encoder is turning clockwise (CW), so we increase the counter and set
currentDirto “CW”.
if (digitalRead(DT_PIN) != currentStateCLK) {
counter--;
currentDir = "CCW";
} else {
counter++;
currentDir = "CW";
}We then display the direction and counter value on the Serial Monitor.
Serial.print("Direction: ");
Serial.print(currentDir);
Serial.print(" | Counter: ");
Serial.println(counter);At this point, we update lastStateCLK to match the current state of CLK so that the next time the loop runs, we can detect a new change correctly.
lastStateCLK = currentStateCLK;For the button, we check if it is pressed, which happens when the SW pin reads LOW. This is because the pin is using an internal pull-up resistor, so it normally stays HIGH and goes LOW only when the button is pressed.
To avoid false readings caused by button bounce, we check whether at least 50 milliseconds have passed since the last valid press. If enough time has passed, we print “Button pressed!” on the Serial Monitor and store the current time so that future presses can be detected properly.
int btnState = digitalRead(SW_PIN);
if (btnState == LOW) {
if (millis() - lastButtonPress > 200) {
Serial.println("Button pressed!");
lastButtonPress = millis();
}
}We end the loop with a very small 1 millisecond delay to help stabilize the readings before starting the next cycle.
delay(1);Example 2 – Reading a Rotary Encoder With Interrupts
In the previous example, we read the rotary encoder using a polling method. The ESP32 repeatedly checked the state of the CLK and DT pins inside the loop() function to see whether the knob had moved. This method is simple and works well for basic projects, but it has a few limitations.
- First, the ESP32 has to keep reading the encoder pins all the time, even when the knob is not moving.
- Second, polling does not always respond instantly. If the ESP32 is busy doing other tasks, there can be a slight delay before it notices that the encoder has moved.
- Finally, if the knob is turned very quickly, some changes may happen between two reads of the pins. In that case, the ESP32 may miss a few steps.
To solve this, we can use interrupts. An interrupt allows the ESP32 to react immediately whenever the encoder signal changes. Instead of constantly checking the pins, the processor only responds when needed. This makes the system more efficient and improves reliability.
There is one more difference between this example and the previous one. In the polling example, we used a 1-edge decoding method, where we watched one signal and checked the other to determine direction. In this example, we use a full state-transition method. We’ll monitor changes on both A and B, store the previous and current states, and decode movement from the sequence of those states.
This is a more complete way to read a quadrature encoder because a rotary encoder does not simply produce a single “A changed, so check B” event. Instead, it generates a sequence of states. The four possible states are 00, 01, 11, and 10, and the order of these states tells us the direction of rotation. If the states appear in one order, the encoder is turning clockwise (CW). If they appear in the reverse order, it is turning counterclockwise (CCW).
Now let’s try the interrupt-based sketch below, and then we’ll break it down step by step.
// Pin Configuration
#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27
// Variables modified inside an interrupt MUST be declared 'volatile'
volatile int32_t encoderCount = 0; // full decoded position
volatile uint8_t encoderState = 0; // last 2-bit AB state
volatile bool buttonPressed = false;
volatile uint32_t lastButtonInterruptMs = 0;
// Quadrature Decode Table
// Index = previous_AB << 2 | current_AB
// Value = movement delta
//
// Valid transitions:
// 00->01, 01->11, 11->10, 10->00 = +1
// 00->10, 10->11, 11->01, 01->00 = -1
//
// Invalid/bounce transitions = 0
static const int8_t QUAD_TABLE[16] = {
0, -1, +1, 0,
+1, 0, 0, -1,
-1, 0, 0, +1,
0, +1, -1, 0
};
// ISR for Encoder
// IRAM_ATTR loads this function into RAM for faster execution on ESP32
void IRAM_ATTR handleEncoderISR() {
// Read both pins as quickly as possible
uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
uint8_t currentState = (a << 1) | b;
uint8_t index = (encoderState << 2) | currentState;
encoderCount += QUAD_TABLE[index];
encoderState = currentState;
}
// ISR for Button
void IRAM_ATTR handleButtonISR() {
uint32_t now = millis(); // acceptable in Arduino-ESP32 ISR for simple debounce usage
if (now - lastButtonInterruptMs > 200) {
buttonPressed = true;
lastButtonInterruptMs = now;
}
}
// Atomic Read Helper
int32_t readEncoderCount() {
noInterrupts();
int32_t value = encoderCount;
interrupts();
return value;
}
// If your encoder produces 4 counts per detent, divide by 4 to get "clicks".
int32_t readEncoderDetents() {
return readEncoderCount() / 4;
}
void setup() {
Serial.begin(115200);
// Set encoder pins as inputs
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
// Initialize starting AB state before enabling interrupts
uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
encoderState = (a << 1) | b;
// Attach interrupt on both channels
attachInterrupt(digitalPinToInterrupt(CLK_PIN), handleEncoderISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(DT_PIN), handleEncoderISR, CHANGE);
// button interrupt
attachInterrupt(digitalPinToInterrupt(SW_PIN), handleButtonISR, FALLING);
Serial.println("ESP32 Rotary Encoder Ready");
}
void loop() {
static int32_t lastReportedDetent = 0;
int32_t detents = readEncoderDetents();
if (detents != lastReportedDetent) {
int32_t diff = detents - lastReportedDetent;
lastReportedDetent = detents;
if (diff > 0) {
Serial.printf("Direction: CW | Counter: %ld\n", (long)detents);
} else {
Serial.printf("Direction: CCW | Counter: %ld\n", (long)detents);
}
}
if (buttonPressed) {
noInterrupts();
buttonPressed = false;
interrupts();
Serial.println("Button pressed");
}
delay(1);
}After uploading the code to your ESP32, open the Serial Monitor. Turn the knob in both directions and watch the counter values change on the screen. Try pressing the button and see if “Button pressed!” appears.

Code Explanation:
At the beginning of the code, we define which ESP32 GPIO pins are connected to the rotary encoder’s CLK, DT, and SW pins.
#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27Next, we create the variables needed to track the encoder and button states.
- The
encoderCountvariable stores the encoder’s position. Since this value is changed inside an interrupt service routine, it is declared asvolatile. This tells the compiler that the value can change at any time, so it must always be read directly from memory. - The
encoderStatevariable stores the last known 2-bit state of the encoder signals. It is also declaredvolatilebecause it is updated inside the interrupt. - The
buttonPressedvariable is used to tell the main program that the button was pressed. - The
lastButtonInterruptMsvariable helps with button debouncing by storing the time of the last valid button press.
volatile int32_t encoderCount = 0;
volatile uint8_t encoderState = 0;
volatile bool buttonPressed = false;
volatile uint32_t lastButtonInterruptMs = 0;After that, we define a quadrature decode table.
A rotary encoder produces two signals, and together they form four possible states: 00, 01, 11, and 10. As the knob turns, the encoder moves through these states in sequence. One sequence represents clockwise rotation, while the reverse sequence represents counterclockwise rotation.
This table helps us quickly determine whether a state change means +1, -1, or no valid movement at all.
static const int8_t QUAD_TABLE[16] = {
0, -1, +1, 0,
+1, 0, 0, -1,
-1, 0, 0, +1,
0, +1, -1, 0
};The table index is calculated from the previous encoder state and the current encoder state. Valid transitions produce either +1 or -1, while invalid transitions, which can happen because of switch bounce or noise, produce 0.
Next comes the interrupt service routine for the encoder. This function runs automatically whenever the CLK or DT pin changes. The IRAM_ATTR attribute tells the ESP32 to place this function in RAM so it can execute quickly during an interrupt.
void IRAM_ATTR handleEncoderISR() {
uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
uint8_t currentState = (a << 1) | b;
uint8_t index = (encoderState << 2) | currentState;
encoderCount += QUAD_TABLE[index];
encoderState = currentState;
}Inside the function, we first read both encoder pins as quickly as possible. Then we combine the two pin readings into a single 2-bit number called currentState. For example:
- if CLK = 0 and DT = 0, the state is 00
- if CLK = 0 and DT = 1, the state is 01
- if CLK = 1 and DT = 0, the state is 10
- if CLK = 1 and DT = 1, the state is 11
After that, we combine the previous state and the current state to create an index for the lookup table.
uint8_t index = (encoderState << 2) | currentState;Using that index, we read the movement value from QUAD_TABLE and add it to encoderCount.
encoderCount += QUAD_TABLE[index];Finally, we save the current state into encoderState so it becomes the previous state for the next interrupt.
encoderState = currentState;Next is the interrupt service routine for the push button. This function runs whenever the button pin detects a falling edge, which happens when the button is pressed.
void IRAM_ATTR handleButtonISR() {
uint32_t now = millis();
if (now - lastButtonInterruptMs > 200) {
buttonPressed = true;
lastButtonInterruptMs = now;
}
}The code uses a simple debounce method. It checks how much time has passed since the last valid press. If more than 200 milliseconds have passed, it treats the button press as valid.
Instead of printing directly inside the interrupt, the code simply sets buttonPressed to true. The main loop will handle the actual message later. This is a safer design because interrupt functions should be kept as short as possible.
After that, we have a helper function called readEncoderCount(). This function reads the encoder count safely.
int32_t readEncoderCount() {
noInterrupts();
int32_t value = encoderCount;
interrupts();
return value;
}Since encoderCount is being updated inside an interrupt, the value could change while the main program is trying to read it. To avoid that, we temporarily disable interrupts using noInterrupts(), copy the value into a local variable, and then enable interrupts again with interrupts(). This makes the read atomic, meaning the value is read safely in one uninterrupted step.
Next is another helper function:
int32_t readEncoderDetents() {
return readEncoderCount() / 4;
}Many rotary encoders generate four state changes per physical click, also called a detent. Since encoderCount increases or decreases on every valid transition, dividing by 4 converts the raw count into the number of actual knob clicks.
Then we move to the setup() function. First, we start serial communication so we can print messages to the Serial Monitor. Then we configure the encoder and button pins as INPUT_PULLUP. This enables the ESP32’s internal pull-up resistors, which helps keep the signals stable.
Serial.begin(115200);
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);Before enabling interrupts, we read the current states of CLK and DT and store them in encoderState. This gives the program the correct starting state, so the first interrupt can be decoded properly.
uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
encoderState = (a << 1) | b;Next, we attach interrupts to both encoder pins using the CHANGE mode. That means the interrupt will trigger whenever either signal changes from LOW to HIGH or from HIGH to LOW. This is important because the full state-transition method needs to observe every change on both pins.
attachInterrupt(digitalPinToInterrupt(CLK_PIN), handleEncoderISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(DT_PIN), handleEncoderISR, CHANGE);We also attach a button interrupt to the SW pin using FALLING, because the button is considered pressed when the signal goes from HIGH to LOW.
attachInterrupt(digitalPinToInterrupt(SW_PIN), handleButtonISR, FALLING);Now let’s look at the loop() function. Inside the loop, we first create a static variable called lastReportedDetent. This remembers the last encoder position that was printed to the Serial Monitor.
static int32_t lastReportedDetent = 0;Then we read the current encoder position in detents by calling readEncoderDetents().
int32_t detents = readEncoderDetents();If the value has changed, we know the encoder has moved. We calculate the difference between the new position and the old one.
- If the difference is positive, the encoder moved clockwise, so we print CW along with the updated counter value.
- If the difference is negative, the encoder moved counterclockwise, so we print CCW along with the updated counter value.
if (detents != lastReportedDetent) {
int32_t diff = detents - lastReportedDetent;
lastReportedDetent = detents;
if (diff > 0) {
Serial.printf("Direction: CW | Counter: %ld\n", (long)detents);
} else {
Serial.printf("Direction: CCW | Counter: %ld\n", (long)detents);
}
}After that, we check whether the button interrupt has set buttonPressed to true. If it has, we safely clear the flag while interrupts are temporarily disabled, and then print “Button pressed” to the Serial Monitor.
if (buttonPressed) {
noInterrupts();
buttonPressed = false;
interrupts();
Serial.println("Button pressed");
}This design keeps the interrupt routine very short and lets the main loop handle the slower task of printing.
Finally, the loop ends with a very small delay of 1ms. This is not required for interrupt handling, but it keeps the loop from running unnecessarily fast and is perfectly fine in this example.
delay(1);
