A rotary encoder is the knob on menus, volume controls and 3D printers. Unlike a potentiometer it turns forever and reports steps, so the Nano has to catch every pulse.
That is why both signal pins go on D2 and D3 — the Nano’s only two interrupt pins. The push switch goes on D4 with the internal pull-up and resets the count.
Wiring
| Arduino Nano | KY-040 rotary encoder | Wire | Notes |
|---|---|---|---|
| D2 | CLK | yellow | INT0 |
| D3 | DT | green | INT1 |
| D4 | SW | blue | Internal pull-up; most KY-040 boards have no resistor on SW. |
| 5V | + | red | |
| GND | GND | black |
3.3–5 V part, 5 V board. Power the module from your board’s logic voltage (3.3 V on ESP32/Pico) — its pull-ups go to the + pin, so CLK and DT will never exceed that voltage.
Board quirks
- The Nano has exactly two external-interrupt pins, D2 and D3. Using both for CLK and DT means no missed steps, even when the loop is busy.
- Encoders bounce. Decoding all four transitions with a lookup table means a bounce adds a step and then removes it, so the count stays right without delays or capacitors.
- Most KY-040s give 4 transitions per click (20 clicks per turn). If yours moves 2 per click, change
steps / 4tosteps / 2. - If you need D2/D3 for something else, the PinChangeInterrupt approach works on any pin — but that is more code.
Code
// KY-040 rotary encoder on an Arduino Nano using both external interrupts (D2 = INT0, D3 = INT1).
// CLK → D2, DT → D3, SW → D4, + → 5V, GND → GND. No library: a small state table decodes the
// Gray code, so contact bounce cancels itself out.
const uint8_t PIN_CLK = 2;
const uint8_t PIN_DT = 3;
const uint8_t PIN_SW = 4;
volatile int16_t steps = 0; // quarter-steps
volatile uint8_t lastState = 0;
void onTurn() {
static const int8_t TABLE[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
uint8_t s = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
steps += TABLE[(lastState << 2) | s];
lastState = s;
}
void setup() {
Serial.begin(115200);
pinMode(PIN_CLK, INPUT); // the KY-040 has 10 kΩ pull-ups on CLK and DT
pinMode(PIN_DT, INPUT);
pinMode(PIN_SW, INPUT_PULLUP); // ...but usually none on SW
lastState = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
attachInterrupt(digitalPinToInterrupt(PIN_CLK), onTurn, CHANGE);
attachInterrupt(digitalPinToInterrupt(PIN_DT), onTurn, CHANGE);
Serial.println(F("Turn the knob, press to reset"));
}
void loop() {
static int16_t shown = 0;
noInterrupts();
int16_t detents = steps / 4; // KY-040: 4 quarter-steps per click
interrupts();
if (detents != shown) {
shown = detents;
Serial.print(F("Position: "));
Serial.println(shown);
}
if (digitalRead(PIN_SW) == LOW) {
noInterrupts();
steps = 0;
interrupts();
shown = 0;
Serial.println(F("Reset to 0"));
while (digitalRead(PIN_SW) == LOW) delay(10);
}
}// KY-040 rotary encoder on an Arduino Nano using both external interrupts (D2 = INT0, D3 = INT1).
// CLK → D2, DT → D3, SW → D4, + → 5V, GND → GND. No library: a small state table decodes the
// Gray code, so contact bounce cancels itself out.
const uint8_t PIN_CLK = 2;
const uint8_t PIN_DT = 3;
const uint8_t PIN_SW = 4;
volatile int16_t steps = 0; // quarter-steps
volatile uint8_t lastState = 0;
void onTurn() {
static const int8_t TABLE[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
uint8_t s = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
steps += TABLE[(lastState << 2) | s];
lastState = s;
}
void setup() {
Serial.begin(115200);
pinMode(PIN_CLK, INPUT); // the KY-040 has 10 kΩ pull-ups on CLK and DT
pinMode(PIN_DT, INPUT);
pinMode(PIN_SW, INPUT_PULLUP); // ...but usually none on SW
lastState = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
attachInterrupt(digitalPinToInterrupt(PIN_CLK), onTurn, CHANGE);
attachInterrupt(digitalPinToInterrupt(PIN_DT), onTurn, CHANGE);
Serial.println(F("Turn the knob, press to reset"));
}
void loop() {
static int16_t shown = 0;
noInterrupts();
int16_t detents = steps / 4; // KY-040: 4 quarter-steps per click
interrupts();
if (detents != shown) {
shown = detents;
Serial.print(F("Position: "));
Serial.println(shown);
}
if (digitalRead(PIN_SW) == LOW) {
noInterrupts();
steps = 0;
interrupts();
shown = 0;
Serial.println(F("Reset to 0"));
while (digitalRead(PIN_SW) == LOW) delay(10);
}
}Library: Encoder (Paul Stoffregen).
Expected serial output
Turn the knob, press to reset
Position: 1
Position: 2
Position: 1
Reset to 0Turn the knob, press to reset Position: 1 Position: 2 Position: 1 Reset to 0
Common errors
Counts in the wrong direction
Swap CLK and DT, either in wiring or in the pin constants.
Count jumps or skips
CLK/DT not on D2/D3, or Serial.print inside the interrupt. Keep the ISR tiny, as in the sketch.