I killed a lot of 18650 cells before I figured this out.
My first ESP32 weather station was plugged into a USB power bank. It worked great on my desk. Then I tried to deploy it in my backyard — no outlet, just a fully charged 2500 mAh battery. It was dead in 18 hours.
The ESP32 with Wi-Fi active pulls 80–240 mA. Do the math: 2500 mAh ÷ 150 mA average = about 16 hours. I was basically running a space heater in a plastic box.
The fix wasn't hardware. It was deep sleep.
After adding deep sleep properly, the same battery lasted 4 months. Same ESP32, same sensor, same battery. The only thing that changed was the firmware.
This guide is everything I wish I'd known before I wasted those cells.
What's Actually Happening Inside the Chip
Before touching any code, you need to understand why deep sleep works — otherwise you'll spend hours confused about why variables vanish between wakeups.
The ESP32 isn't one thing. It's a collection of power domains that can be turned off independently:
| Power Domain | Active | Deep Sleep |
|---|---|---|
| Main CPU (both cores) | ✅ Running | ❌ Off |
| Wi-Fi / Bluetooth radio | ✅ Running | ❌ Off |
| Main RAM (520KB SRAM) | ✅ On | ❌ Cleared |
| RTC Controller | ✅ On | ✅ On |
| RTC Memory (8KB) | ✅ On | ✅ On |
| ULP Co-processor | ✅ On | ✅ Optional |
That RTC memory row is the one that matters most. 8KB of memory that survives deep sleep. That's your data lifeline across sleep cycles — boot counts, last sensor readings, Wi-Fi credentials, calibration values, anything you need to remember.
Everything else gets wiped. Including your int count = 0 in setup(). Gone.
The other thing that catches everyone off guard: the ESP32 doesn't pause when it sleeps. It reboots. When the wakeup trigger fires, your setup() runs from the top again. There's no "resuming." It's a full cold boot, just very fast.
This is the mental model shift that makes everything else click:
Deep sleep = scheduled reboot, not a pause
Once you internalize that, the code patterns make total sense.
The 8KB That Saves You: RTC Memory
Before I show any wakeup code, let's talk about RTC_DATA_ATTR. This is the thing I missed for way too long.
Slap RTC_DATA_ATTR in front of any global variable and it lives in RTC memory. It survives every sleep cycle. It only resets on a full power-off or hard reset (the EN button).
A few gotchas I've hit:
Don't use the String class in RTC memory. It compiles, but a String only stores a pointer to its text; the text itself lives on the heap in main RAM, which deep sleep wipes. After a wake the pointer leads to garbage. Use a fixed char[] array instead.
Initializers run at power-up, not on wake. RTC_DATA_ATTR int bootCount = 0; is set to 0 when the board powers up or is reset, then keeps whatever value you give it across every deep-sleep wake. That makes a flag the easy way to run one-time setup, such as a calibration, only after a real power-up:
You've got 8KB total — some of it is used by the system, so in practice you have about 4–6KB. More than enough for most projects, but don't try to cache a JSON blob in there.
Wakeup Source 1: Timer (The One You'll Use Most)
Set a duration, go to sleep, wake up after that time. This is the foundation of 90% of battery-powered sensor projects.
That 1000000ULL trips up a lot of people. The function takes microseconds, and the ULL suffix prevents integer overflow when you multiply by large numbers. Without it, 30 seconds becomes a garbage value and your device sleeps for a random duration.
Also notice Serial.flush() before sleeping. Without it, the last few characters of your log output get cut off mid-transmission. Small thing, annoying to debug.
Wakeup Source 2: GPIO Pin (ext0)
Sometimes you don't want a fixed timer — you want to sleep until something happens. A button press, a PIR sensor triggering, a door contact opening.
That's ext0. One pin, one event.
The catch: ext0 only works with RTC-capable GPIO pins. On the ESP32 WROOM-32, those are: GPIO 0, 2, 4, 12–15, 25–27, 32–39. Pass GPIO 5 or GPIO 22 and esp_sleep_enable_ext0_wakeup() returns an error code that most sketches never check, so the board simply never wakes on that pin.
💡 Tip
ext0 is level-triggered. If the pin is still at the wake level when you call `esp_deep_sleep_start()` (say the button is still held down), the chip wakes straight back up. Wait for the pin to return to its idle level before going to sleep.
Wakeup Source 3: Multiple GPIO Pins (ext1)
Same idea as ext0, but you can monitor several pins at once. Useful for multi-button remotes, alarm systems, or anything with more than one trigger.
The bitmask syntax looks ugly but it's straightforward: shift 1 left by the GPIO number, OR them together for multiple pins.
One limit of the original ESP32: ext1 can wake when any selected pin goes HIGH, or when all of them go LOW, but not when any single one goes LOW. That's why the example wires its buttons active-HIGH, to 3.3V, with pull-downs. Newer chips such as the ESP32-S3 and ESP32-C6 add an any-LOW mode.
💡 Tip
With `ESP_EXT1_WAKEUP_ANY_HIGH`, add an external pull-down resistor (around 10 kΩ) to each wake pin so a floating input can't wake the chip at random. Don't count on the internal pull-ups and pull-downs in deep sleep unless you configure the RTC GPIO pulls yourself.
Wakeup Source 4: Touchpad
The ESP32 has 10 capacitive touch pins (T0–T9). You can use a bare wire, a copper pad, or a piece of foil as a touch sensor — and wake from deep sleep when someone touches it.
You'll need to tune the threshold (40 in the example above) for your setup. Touch the pin while monitoring touchRead(T0) in a non-sleep sketch to find your baseline, then set the threshold slightly below that.
Wakeup Source 5: ULP Coprocessor
The ULP (Ultra Low Power) coprocessor is a tiny secondary processor that keeps running while the main CPU is in deep sleep. It can read the ADC, watch a GPIO, or count pulses, and it only wakes the main CPU when something actually needs attention, for example when a sensor reading crosses a threshold.
The trade-off is complexity and power. The ULP has to be programmed separately (in ULP assembly, built with the ESP-IDF toolchain), and keeping it running draws more current than plain deep sleep. For most projects a timer wakeup is simpler and cheaper, so reach for the ULP only when you need to watch something continuously without waking the main CPU. Once a ULP program has been loaded into RTC slow memory, enabling it as a wakeup source is one line:
You can also combine wakeup sources, for example a timer as a safety net plus a GPIO for events. Whichever fires first wakes the chip, and esp_sleep_get_wakeup_cause() tells you which one it was.
A Real Project: Soil Moisture Sensor That Texts You
Here's how all of this comes together in something actually useful — a soil moisture monitor that sleeps most of the day, wakes up every hour, and sends a push notification if the plant needs water.
⚠️ Warning
18650 cells store a lot of energy. A fully charged cell sits at 4.2V, which is above the ESP32's 3.3V limit, so never connect it straight to the 3.3V pin. Power the board through a regulator that suits the cell's 3.0–4.2V range, use protected cells (or a battery-protection board), and double-check polarity before you connect.
Notice the SENSOR_POWER pin. A capacitive moisture sensor draws about 5 mA whenever it's powered. Leave it on during sleep and you've added 5 mA to a 10 µA budget, which turns months of battery life into days. Because the draw is so small, GPIO 26 can power the sensor directly; bigger loads need a MOSFET, as the diagram below shows.
💡 Tip
If you switch a bigger load with a P-channel MOSFET, fit a 100kΩ pull-up from gate to source. GPIOs aren't guaranteed to hold their output level in deep sleep, and without it the gate can float and switch the load back on while the chip sleeps. Avoid the classic 2N7000 here: its gate threshold can be close to 3V, so a 3.3V GPIO may not turn it fully on. Alternatively, latch the pin with `gpio_hold_en()` and `gpio_deep_sleep_hold_en()`.
Battery life estimate for this setup:
- Active time: ~5 seconds per hour (reading + potential Wi-Fi)
- Deep sleep: 3595 seconds at ~12 µA
- Average current: ~0.3 mA
- 2500 mAh battery: roughly 340 days
⚠️ Warning
This estimate assumes about 12 µA in deep sleep, which is what you get from a bare module or a board designed for low power. On a typical DevKit, the USB-UART chip, the power LED and the linear regulator's own quiescent current add milliamps in deep sleep. That cuts the same battery from roughly 340 days to about five weeks. See the measurements below.
Wi-Fi Reconnection Is the Real Battery Drain
If your project sends data over Wi-Fi, reconnecting after deep sleep is your biggest power drain — not the sensor, not the processing. It's the 2–5 seconds of Wi-Fi scanning and DHCP negotiation at 200 mA.
You can cut this dramatically by caching the access point details in RTC memory:
The first connection runs at normal speed. Later reconnects skip the channel scan and DHCP, which typically brings the connection time from several seconds to under one. On a device that wakes 24 times a day, that's most of the Wi-Fi energy saved.
One catch: this reuses the address DHCP handed out the first time as a static IP. Reserve that address for the ESP32 in your router's DHCP settings, or another device may be given the same IP once the lease expires.
Typical Current Numbers
These are typical figures for an ESP32-WROOM-32 module on its own, from Espressif's datasheet and common bench measurements. Your board will differ, so measure it if battery life matters: a USB power meter is enough to spot milliamp problems, and a tool such as the Nordic PPK2 or a µCurrent resolves the microamp range.
| State | Typical current |
|---|---|
| Deep sleep (RTC timer + memory, no ULP) | ~10 µA |
| Boot + setup entry | ~50 mA for ~300ms |
| Wi-Fi connecting | 100–240 mA |
| HTTP POST | 80–150 mA |
| Back to sleep | drops in ~10ms |
For comparison, here is the full range of ESP32 power modes:
| Mode | What keeps running | Typical current |
|---|---|---|
| Active, Wi-Fi transmitting | Everything | 160–260 mA |
| Modem sleep | CPU on, radio idle between beacons | 20–68 mA |
| Light sleep | CPU paused, RAM kept, resumes where it left off | ~0.8 mA |
| Deep sleep | RTC timer, RTC memory, RTC GPIOs | ~10 µA |
| Hibernation | RTC timer only, no RTC memory | ~5 µA |
Light sleep is worth knowing about: it keeps RAM and resumes your code instead of rebooting, which suits wake intervals of a few seconds.
The DevKit board itself adds several milliamps in deep sleep: the USB-UART chip (CP2102 or CH340), the power LED, and the AMS1117 regulator's own quiescent current, around 5 mA on its own. If you need sub-100 µA deep sleep current, use a bare module or a board built for low power, with a low-quiescent regulator such as the RT9080 or ME6211. Cutting the LED trace alone isn't enough.
This is one of those things that bites people when they're confused about why their measured current doesn't match the datasheet spec of 10 µA.
Common Mistakes I Made So You Don't Have To
Forgetting Serial.flush() — your last log line gets truncated. The UART is still shifting bits out when the chip powers down. One line fix.
Leaving a sensor powered during sleep — a DHT22, LCD backlight, or moisture sensor can easily add 10–50 mA to your sleep current. Use a GPIO-controlled MOSFET to cut power before sleeping.
Using a regular GPIO for ext0 wakeup — only RTC GPIOs work. GPIO 5, 18, 19, 21, 22, 23 won't work for external wakeup. Check the ESP32 pinout guide before wiring.
Using String in RTC memory — it compiles, but after a wake it points at heap memory that no longer exists. Use char[].
Reading a sensor on an ADC2 pin with Wi-Fi on — GPIO 0, 2, 4, 12–15 and 25–27 can't be read while Wi-Fi is active. Use ADC1 (GPIO 32–39) for battery-powered Wi-Fi sensors.
Not handling the first boot — esp_sleep_get_wakeup_cause() returns ESP_SLEEP_WAKEUP_UNDEFINED on a cold start. If your code branches on wakeup cause and doesn't handle the undefined case, weird things happen on first power-on.
Wrapping Up
Deep sleep isn't complicated, but it does require a different way of thinking about your program. You're not writing a loop anymore — you're writing a script that runs, does one job, and exits. The "loop" is the sleep cycle itself.
The wake → work → sleep pattern becomes second nature quickly. Once it does, you'll find yourself applying it to almost every battery project. Sensor node, door alarm, weather station, plant monitor — they all follow the same skeleton.
The two things that will save you the most time: always use RTC_DATA_ATTR for anything that needs to survive between wakes, and always call Serial.flush() before sleeping. Everything else is details.
What to Read Next
- ESP-NOW on ESP32: Send Sensor Data Wirelessly Without Wi-Fi or a Router — if Wi-Fi reconnection latency is killing your battery life, ESP-NOW is the answer
- ESP32 Wi-Fi tutorial — connecting, reconnecting and handling drop-outs
- ESP32 vs Arduino: Which Should You Choose in 2026? — if you're still picking your board
- Espressif Sleep Modes Documentation — the official reference when you need ULP or advanced power management




