Preview build — page text is a draft awaiting human review. Code badges come from real compiles. The flasher is real but not yet hardware-tested; the Playground is still a preview.

KY-040 rotary encoder with Arduino Nano — interrupts on D2/D3

Read a KY-040 rotary encoder on the Arduino Nano’s two interrupt pins with a bounce-proof state table — no library — plus the push switch on D4.

Arduino C++Verified — core avr 1.8.8, 3 Nov 2026Compile-only

Community draft — report a mistake

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 NanoOnly the pins used are shown · 5 V logicKY-040 rotary encoder5VGNDD4D3D2CLKDTSW+GND
Every connection
Arduino NanoKY-040 rotary encoderWireNotes
D2CLKyellowINT0
D3DTgreenINT1
D4SWblueInternal pull-up; most KY-040 boards have no resistor on SW.
5V+red
GNDGNDblack

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 / 4 to steps / 2.
  • If you need D2/D3 for something else, the PinChangeInterrupt approach works on any pin — but that is more code.

Code

rotary-encoder-nano.inoArduino C++Open in PlaygroundOpen in PlaygroundFlash
// 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

Serial Monitor · 115200Output
Turn 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.

↑↓ to move · Enter to open