- Published on
Pull-Up, Pull-Down, and Floating Pins: Why a Single Button Is So Hard to Get Right
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — A Pin With Nothing Connected Keeps Changing Value
- What a Floating Input Actually Is — The Pin Is a Tiny Antenna
- What Pull-Up and Pull-Down Resistors Actually Do
- Why Internal Pull-Ups Exist, and What Value They Actually Are
- Why Active-Low Is the Convention
- Choosing an External Resistor Value — Noise Immunity Versus Current Draw
- Open-Drain Output
- Switch Bounce and Debouncing
- Conclusion — Deciding an Input Pin's Default Value Is Half the Circuit
Introduction — A Pin With Nothing Connected Keeps Changing Value
Set D2 to an input on an Arduino, connect nothing to it, and read its value ten times a second over serial.
1 1 1 0 1 1 0 0 0 1 0 1 1 1 0 1 0 0 1 1
The value keeps changing. Bring your hand near the pin and the pattern shifts; flip on a fluorescent light and it shifts again. There's nothing wrong with the code.
From a software point of view, this is hard to make sense of. An unread variable holds an initial value, or at least a stable piece of garbage. But this pin gives a different value every time you read it.
The answer is simple: that pin's voltage isn't fixed. A digital input is a circuit that reads a voltage and compares it against a threshold — and if there's no voltage to compare in the first place, there's no result either. And the moment you wire up a button, this issue rises straight to the center of your circuit design.
What a Floating Input Actually Is — The Pin Is a Tiny Antenna
The inside of a digital input pin is an extremely high-impedance comparator. How high? The input leakage current listed on a datasheet tops out around 1 microamp. Convert that to a resistance at 3.3V:
3.3V / 0.000001A = 3300000 ohm = 3.3 megohm
Over 3 megohms. So this pin is, for all practical purposes, a bystander through which almost no current flows.
That's exactly where the problem starts. The voltage at a node where almost no current flows gets shaken badly by even a tiny amount of charge. The capacitance formed by the pin, the wiring, and the breadboard is roughly 20 to 30 picofarads. The relationship between voltage and charge is:
Voltage change = Charge / Capacitance
Deposit just 25 picocoulombs of charge on 25 picofarads and the voltage shifts by 1V. 25 picocoulombs is easily produced by nothing more than the capacitive coupling from bringing a finger nearby.
So a pin connected to nothing becomes an antenna. Induction from a 60Hz power line, signal transitions on a nearby wire, fluorescent-light ballast noise, switching-adapter noise — all of it shakes this pin's voltage. And when that voltage wanders around the threshold, every read gives you a different value.
It's worth knowing exactly where that threshold sits. A CMOS input has two of them.
| Board | Logic voltage | Minimum voltage recognized as HIGH | Maximum voltage recognized as LOW | Undefined band |
|---|---|---|---|---|
| Arduino Uno (ATmega328P) | 5V | 3.0V | 1.5V | 1.5–3.0V |
| Raspberry Pi (BCM) | 3.3V | 2.31V | 0.99V | 0.99–2.31V |
| ESP32 | 3.3V | 2.475V | 0.825V | 0.825–2.475V |
The middle band is the problem. If the pin voltage sits between 1.5V and 3.0V, the Arduino doesn't decide whether that's HIGH or LOW — it's a region the datasheet makes no guarantee about. A floating pin spends most of its time right in that band.
There's one more invisible cost to this state. The first stage inside an input pin is a pair of complementary transistors, and when the input sits at a middle voltage, the upper and lower transistors both turn on a little at the same time. A shoot-through current flows from the supply straight to GND. It's only a few hundred microamps per pin, so you'd never notice it — but it's exactly why battery-powered devices tie every unused pin to a pull-up.
What Pull-Up and Pull-Down Resistors Actually Do
The fix is straightforward: decide what voltage the pin should sit at when nothing is happening. The component that does that job is a pull-up or pull-down resistor.
A pull-up puts a resistor between the pin and the supply. When nothing else is touching the pin, current flows from the supply through the resistor to the pin, pulling the pin up to the supply voltage. More precisely: since the pin's leakage current is nearly zero, the voltage across the resistor is also nearly zero, so the pin voltage ends up essentially equal to the supply. With a 10k pull-up and 1 microamp of leakage, the voltage dropped across the resistor is:
0.000001A × 10000 ohm = 0.00001V = 0.01mV
Negligible. So the pin reads a stable HIGH.
A pull-down does the reverse: a resistor between the pin and GND. The default becomes LOW.
Here's a question worth taking seriously: why use a resistor at all — why not just wire straight to the supply with plain wire? Wire it directly, and that pin is HIGH forever, and the button can't do anything. Actually, worse: the instant you press the button and connect that pin to GND, you've directly shorted the supply to GND through zero resistance. A dead short.
What the resistor does is "pull weakly." Nothing opposing it wins by default; anything strongly opposing it wins instead. Take a pin with a 10k pull-up, and have the button connect it to GND — the button's own resistance is nearly zero, so the pin gets dragged down toward GND. The current flowing at that moment is:
3.3V / 10000 ohm = 0.00033A = 0.33mA
Only 0.33mA flows from supply to GND. This current is the cost of the pull-up, and it's exactly what you calculate when choosing a resistor value.
Pull-up and pull-down are logically symmetric, but in practice, pull-up is used overwhelmingly more often. The reasons unfold over the next two sections.
Why Internal Pull-Ups Exist, and What Value They Actually Are
Almost every microcontroller has a pull-up resistor built right into the chip, switchable in software. It's convenient because it saves you a component.
Knowing the value matters. An internal pull-up isn't a precision resistor — it's made by the semiconductor process, so it varies a lot.
| Board / chip | Internal pull-up resistance | Internal pull-down | How to enable |
|---|---|---|---|
| Arduino Uno (ATmega328P) | 20–50 kΩ | None | pinMode(pin, INPUT_PULLUP) |
| Raspberry Pi (BCM2835 family) | ~50–65 kΩ | ~50–65 kΩ | gpiozero's pull_up argument |
| ESP32 | ~45 kΩ | ~45 kΩ | pinMode(pin, INPUT_PULLUP) |
| Raspberry Pi GPIO2 / GPIO3 | 1.8 kΩ (fixed on-board) | Not available | Cannot be removed |
Three things worth flagging.
First, the Arduino's ATmega328P has no internal pull-down. If you need a pull-down, you must add an external resistor. This asymmetry is one of the technical roots of the active-low convention.
Second, the spread is large. Between 20 and 50 kilohms is a factor of two and a half. Any circuit that needs a precise current or time constant can't rely on an internal pull-up.
Third — and this is the one that trips people up most often in practice — the Raspberry Pi's GPIO2 and GPIO3 have a physical 1.8k pull-up soldered right onto the board. It's there for the I2C bus, and it cannot be removed. Use these pins as ordinary inputs and they're always pulled strongly HIGH, so even wiring on a pull-down can't overpower it. Add a 10k pull-down, for instance, and the result is a voltage divider:
Pin voltage = 3.3V × 10000 / (1800 + 10000) = 3.3V × 0.847 = 2.80V
2.80V — nowhere near the LOW ceiling of 0.99V. It still reads HIGH. GPIO2 and GPIO3 are best left dedicated to I2C.
Why Active-Low Is the Convention
There are two ways to wire a button.
Active-high uses a pull-down resistor, with the button between the pin and the supply. Pressing it produces HIGH. In code, digitalRead(pin) == HIGH means "pressed," which is intuitive.
Active-low uses a pull-up resistor, with the button between the pin and GND. Pressing it produces LOW. In code, digitalRead(pin) == LOW means "pressed," which feels backward at first.
And yet nearly every real schematic and module out there is active-low. There are several layers of reasons.
First, as we just saw, internal pull-ups are common and internal pull-downs are rare or nonexistent. Go active-low and you don't need an external resistor at all.
Second, the wiring is safer. One leg of the button is GND. On a breadboard, the GND rail runs the full length and is easy to grab from anywhere, and accidentally touching it elsewhere doesn't cause an accident, because it's already GND. A supply rail running here and there, on the other hand, is a dead short the instant it accidentally touches GND.
Third, and this was historically decisive: a transistor's ability to sink current was stronger than its ability to source it. In early TTL logic, the sink-to-LOW capability was 16mA while the source-to-HIGH capability was only 0.4mA — a 40x difference. So it was natural to assign the meaningful signal to LOW. Modern CMOS is close to symmetric in both directions, but the convention stuck around.
Fourth, the failure mode is safer. Think about what happens if a wire comes loose. In active-low, if the wire falls off, the pull-up wins and you get HIGH — "not pressed." In active-high, if the wire falls off, the pin floats and can read as randomly pressed. A design that fails toward the safe state when something breaks is a good design.
So the correct way to wire a single button on an Arduino is this short:
const int BUTTON_PIN = 2;
void setup() {
Serial.begin(115200);
// Turn on the internal pull-up. No external resistor needed.
// The button only needs to connect D2 to GND.
pinMode(BUTTON_PIN, INPUT_PULLUP);
}
void loop() {
// With the pull-up, it's normally HIGH, and LOW when pressed.
bool pressed = (digitalRead(BUTTON_PIN) == LOW);
Serial.println(pressed ? "pressed" : "released");
delay(100);
}
Use gpiozero on a Raspberry Pi and this convention is already the default.
from gpiozero import Button
from signal import pause
# pull_up=True is the default. The button connects between GPIO17 and GND.
# The library turns on the internal pull-up, and is_pressed becomes True when LOW.
# In other words, the library handles the active-low inversion for you.
button = Button(17, pull_up=True, bounce_time=0.05)
button.when_pressed = lambda: print("pressed")
button.when_released = lambda: print("released")
pause()
bounce_time is an option that handles debouncing — covered later — at the library level.
Choosing an External Resistor Value — Noise Immunity Versus Current Draw
Sometimes the internal pull-up isn't enough: long wire runs, a noisy environment, needing a precise value, or several devices sharing one line. In these cases you need to choose the external resistor value yourself.
This choice pits two demands head to head.
A smaller resistor is better for noise immunity, because it recovers faster. For noise to move the pin voltage, it has to charge the pin's capacitance, and a smaller pull-up resistor undoes that charging faster. Work it out as a time constant. Taking the pin-plus-wiring capacitance as 25 picofarads:
1 kΩ → 1000 × 0.000000000025 = 25 nanoseconds
10 kΩ → 250 nanoseconds
100 kΩ → 2.5 microseconds
A pin with a 100k pull-up takes 2.5 microseconds to recover a voltage that noise has knocked askew. During that stretch, a read can come back wrong.
A larger resistor is better for current draw. While the button is held down, current keeps flowing from the supply to GND through the pull-up resistor.
1 kΩ → 3.3V / 1000 = 3.3mA
10 kΩ → 0.33mA
100 kΩ → 0.033mA = 33 microamps
3.3mA is no big deal for a single button. But with a bank of sixteen switches and half of them held down, that's 26mA — and on a battery-powered device, this number decides how long the battery lasts.
Put together, here's the guideline:
| Resistor value | Current draw while pressed (3.3V) | Noise-recovery time | Good for |
|---|---|---|---|
| 1 kΩ | 3.3mA | 25ns | Long wiring, near motors, industrial environments |
| 4.7 kΩ | 0.70mA | 118ns | Standard I2C value |
| 10 kΩ | 0.33mA | 250ns | Common default choice |
| 47 kΩ | 0.07mA | 1.2μs | Low-power devices, short wiring |
| 100 kΩ and up | 0.03mA | 2.5μs | Not recommended |
With no particular reason to do otherwise, 10 kilohms is a good starting point. And if you see the symptom "the button occasionally presses itself," lowering the value is the first thing to try. Once wiring runs longer than 30 centimeters, dropping to 4.7k or 1k is the safer move.
If you've wired up a button or switch and want to double-check you haven't left out the pull resistor, drop the part into this site's circuit wiring validator. It flags any part that would float if left alone — like a plain push button — as needing a pull-up or pull-down, and it also warns you if you've picked a pin like the Raspberry Pi's GPIO2 or GPIO3 that already has a fixed on-board pull-up.
Open-Drain Output
Once you understand pull-ups, an output style called open-drain follows naturally, and you're guaranteed to run into it in practice when dealing with I2C or sharing an interrupt line.
A normal digital output is push-pull. Write HIGH and the upper transistor turns on, connecting the pin to the supply; write LOW and the lower transistor turns on, connecting it to GND. Both directions push actively.
That comes with a constraint. If two push-pull devices sit on the same line and one writes HIGH while the other writes LOW, the supply and GND get shorted together straight through both transistors. At 5V, if each transistor's on-resistance is 25 ohms:
5V / (25 + 25) = 0.1A = 100mA
100mA rips through both chips. One or both get damaged.
Open-drain does away with the upper transistor entirely. The output either pulls LOW, or does nothing and lets go. Making HIGH happen is the job of an external pull-up resistor.
This lets multiple devices safely share one line. If nobody's pulling it down, the pull-up wins and it's HIGH; if even one device pulls it down, it's LOW. Logically, an AND gate emerges just from the wiring. Because of this property, an open-drain bus follows the rule "if even one asserts LOW, the line is LOW," which is what makes techniques like clock stretching — where an I2C slave holds the clock line down until it's ready — possible.
The cost is speed. Going to LOW is fast because a transistor actively pulls it down, but going to HIGH depends entirely on how fast the pull-up resistor can charge the capacitance. With 4.7 kΩ and 100 picofarads:
Time constant = 4700 × 0.0000000001 = 0.00000047 second = 470 nanoseconds
The rise time is a bit more than twice the time constant, so roughly 1 microsecond. I2C standard mode's maximum rise time spec is 1 microsecond — right at that edge. Add more devices and the capacitance grows, and you blow past spec. This is the physical reason you can't just keep piling devices onto an I2C bus indefinitely.
It's also worth knowing how to fake an open-drain output on an Arduino: by switching the pin mode.
const int SHARED_LINE = 4;
void setup() {
// Released. The external pull-up brings it HIGH.
pinMode(SHARED_LINE, INPUT);
}
void assertLow() {
// Actively pull it down.
pinMode(SHARED_LINE, OUTPUT);
digitalWrite(SHARED_LINE, LOW);
}
void release() {
// Go back to high impedance. The key is never using HIGH.
pinMode(SHARED_LINE, INPUT);
}
The point is to never call digitalWrite(pin, HIGH). Making HIGH happen is left to the pull-up; this side only has two states — pulling down, or letting go.
Switch Bounce and Debouncing
You've wired everything correctly and turned on the pull-up, and yet pressing the button once bumps the counter by 3. This is the last problem.
The Waveform an Oscilloscope Actually Shows
A mechanical switch's contact is a piece of metal. Press it, and two pieces of metal collide — and metal has elasticity, so it bounces. It touches, separates for a very brief instant, touches again, repeats a few times, and only then settles. Look at a tactile switch on an oscilloscope and you get roughly this waveform.
┌──────────────────
─────┐ ┌──┐ ┌─┐ ┌┐ │
│ │ │ │ │ ││ │
└───┘ └──┘ └─┘└──────────────┘
|<------ about 1–5ms ------>|
moment of press settling point
Roughly the range per contact type:
| Switch type | Typical bounce time | Worst case |
|---|---|---|
| Small tactile switch | 1–5ms | 10ms |
| Toggle switch | 5–10ms | 20ms |
| Micro switch (limit) | 1–3ms | 8ms |
| Relay contact | 5–20ms | 50ms |
| Rotary encoder | 1–5ms (both channels) | 10ms |
Now it's clear why the counter jumps by 3. With simple code, an Arduino's loop cycles on a microsecond timescale, so it observes a contact bouncing for 5 milliseconds hundreds of times over. Every single transition in there looks like a genuine input.
The important thing to recognize is that this isn't a defect — it's the normal behavior of a mechanical contact. Buying a better switch doesn't make it go away. You have to handle it in the circuit or in code.
Smearing Out the Waveform With an RC Circuit
The most intuitive hardware fix is a low-pass filter built from a resistor and a capacitor. Put a capacitor between the pin and GND, and while the contact is bouncing, the capacitor absorbs the brief voltage swings.
Say you put a 1 microfarad capacitor on a 10k pull-up.
Time constant = 10000 ohm × 0.000001F = 0.01 second = 10ms
After you release the button, the time it takes the pin voltage to climb up to the HIGH threshold of 2.31V is about 1.2 times the time constant.
10ms × 1.2 = 12ms
Since the bounce finishes within 5 milliseconds, every bit of that bouncing gets absorbed by the capacitor, and the pin voltage never crosses the threshold. The bounce disappears.
There's a catch here. An RC filter slows the signal down. That means the pin voltage crawls through the undefined band we saw earlier over 12 milliseconds. During that crossing, even tiny amounts of noise can flip the input comparator back and forth several times. Trying to eliminate bounce, you end up creating a different kind of multi-trigger.
So RC debouncing only really works properly paired with a Schmitt-trigger input. A Schmitt trigger has different thresholds going up versus going down, so it doesn't chatter in the middle. The 74HC14 is a classic example of such a part. Fortunately, the ESP32's GPIO inputs are Schmitt triggers, and the ATmega328P's inputs have some hysteresis too. Still, if you want to be certain, a dedicated part is the better choice.
There's one more practical tip. Since the capacitor sits between the pin and GND, and the button also sits between the pin and GND, the moment you press the button, the capacitor's charge discharges straight through the button's contacts. Discharging 1 microfarad in an instant sends a big current through the contact and wears it out. So it's standard practice to add one more resistor, in the 100 to 220 ohm range, between the button and the capacitor.
Software Debouncing — And Why delay Is Wrong
Handling it in code without adding a component is far more common. The principle is simple: only count a value as a real change once it's stayed stable for some length of time after changing.
First, let's look at code that's widely copied but wrong.
// Do not write it like this.
void loop() {
if (digitalRead(BUTTON_PIN) == LOW) {
counter++;
delay(50); // intended to wait out the bounce
}
}
There are three reasons this code is broken.
First, delay stops the CPU completely. For 50 milliseconds, no other button, no sensor reading, no serial receive, no motor control — everything stops. The moment you add a second button, this approach falls apart.
Second, it doesn't handle bounce on release. The code above only waits 50 milliseconds when pressing. The contact bounces the same way when you let go of the button, and at that moment there's no protection at all.
Third, holding the button down keeps incrementing the counter every 50 milliseconds. It's watching the state itself instead of the change in state.
The correct approach is non-blocking. Record the moment the value last changed, and only confirm it once enough time has passed since then.
const int BUTTON_PIN = 2;
const unsigned long DEBOUNCE_MS = 50;
// The last raw value read from the pin. The bouncing value lands here as-is.
int lastRawReading = HIGH;
// The confirmed value that has passed debouncing.
int stableState = HIGH;
// The moment the raw value last changed.
unsigned long lastChangeMs = 0;
unsigned long pressCount = 0;
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
}
void loop() {
int raw = digitalRead(BUTTON_PIN);
// Every time the raw value wobbles, restart the timer from scratch.
// As long as the bouncing keeps going, we never reach the confirmation block below.
if (raw != lastRawReading) {
lastChangeMs = millis();
lastRawReading = raw;
}
// If it's been quiet for DEBOUNCE_MS since the last change, it's a real value.
if (millis() - lastChangeMs >= DEBOUNCE_MS) {
if (raw != stableState) {
stableState = raw;
// Here we look at the transition, not the state itself.
// With a pull-up, going from HIGH to LOW is a press.
if (stableState == LOW) {
pressCount++;
Serial.print("press #");
Serial.println(pressCount);
}
}
}
// loop keeps running freely. You can put other work in here too.
}
Let's go through how this differs from the broken code above.
The subtraction millis() - lastChangeMs doesn't stop the CPU. loop keeps running and can do other things, and if you add ten buttons, giving each its own timer lets them all get handled at the same time.
And comparing via subtraction matters. millis() wraps back to zero roughly every 49.7 days, but subtracting unsigned integers gives the correct difference even across that wraparound. Write it as something like millis() > lastChangeMs + DEBOUNCE_MS instead, and a bug appears right at that rollover point.
The part comparing stableState and raw is what catches the state transition. Even holding the button down continuously, stableState is already LOW, so the counter doesn't keep climbing.
The value of 50 milliseconds sits comfortably above the longest bounce time in the table above, while staying short enough that a person can't perceive it. When handling something fast like a rotary encoder, this value actually starts causing missed inputs, so you'd shrink it to around 5 milliseconds and combine it with other techniques. Conversely, reading relay contacts might call for stretching it to 100 milliseconds. This number isn't a constant to memorize — it's a value that comes out of the contact's own characteristics.
Conclusion — Deciding an Input Pin's Default Value Is Half the Circuit
The content of this post boils down to one line: a circuit must decide what value a digital input pin should be when nothing is happening.
This idea is familiar to programmers, because it's the same rule as never reading an uninitialized variable. The difference is that in hardware, initialization isn't an assignment statement — it's a single resistor. And unlike an uninitialized read, whose result is merely undefined, the result here is a value that changes every time depending on the surrounding environment.
There's one more thing to add to make this complete. Even once that default value is settled, a mechanical contact isn't honest for a few milliseconds the instant its value changes. The code that postpones judgment during that stretch is debouncing, and postponing it via a recorded timestamp rather than delay is what keeps the rest of the program alive.
The next post covers cases where the voltage arriving at an input pin is itself dangerous — what happens inside the pin when you connect a 5V sensor to a 3.3V Raspberry Pi.