ESP32: NVS (Non-Volatile Storage), the right way to persist settings
Use the Preferences library to save and load settings that survive power cycles on the ESP32. Wi-Fi credentials, calibration values, and the blob storage pattern for larger data.
The first thing any real IoT project needs to save is the Wi-Fi password. Then it is the MQTT topic. Then the calibration constant for the sensor. Then the device name. Then the user changes one of them, walks away, power-cycles the board, and asks why their change is gone.
If you store settings in a regular variable, every power cycle
wipes them. If you store them in RTC memory, every power cycle
wipes them. You need flash-backed storage. On the ESP32, that
storage is called NVS (Non-Volatile Storage), and the Arduino
library for it is Preferences.
This tutorial covers the open / put / get / close pattern, the namespace organization, the blob storage for larger values, wear-leveling, and the data-type gotcha that bites when you mistake a 32-bit int for a 64-bit one.
What NVS is
NVS is a key-value store that lives in a dedicated partition of
the ESP32’s flash. It survives power cycles, deep sleep, and
firmware updates. The Arduino wrapper (Preferences.h) gives
you a clean API on top of the ESP-IDF NVS layer.
Properties:
- Key-value: each entry has a string name and a typed value (int, float, blob, string).
- Namespaced: keys are grouped into namespaces (e.g. “wifi”, “mqtt”, “calibration”).
- Wear-leveled: the underlying flash management rotates writes across sectors, so no single flash sector wears out from repeated writes.
- Slow per-write, fast per-read: each write is a flash erase-and-program cycle, taking ~50 ms. Reads are instant.
- Typed: each key has a fixed type (int, float, blob). You cannot mix types under the same name.
Default partition size is about 20 KB. Plenty for settings, not enough for logs.
The open / put / get / close pattern
The basic API:
#include <Preferences.h>
Preferences prefs;
void setup() {
Serial.begin(115200);
prefs.begin("my-app", false); // namespace, read-only = false
// Put values
prefs.putInt("boot-count", 0);
prefs.putFloat("calibration", 1.023);
prefs.putString("device-name", "esp32-01");
// Get values (with default)
int bootCount = prefs.getInt("boot-count", 0);
float cal = prefs.getFloat("calibration", 1.0);
String name = prefs.getString("device-name", "esp32");
Serial.printf("boot=%d cal=%.3f name=%s\n", bootCount, cal, name.c_str());
prefs.end();
}
void loop() {}
Three rules:
- Call
begin()before anyput*/get*call. The second argument is read-only mode; set it totrueif you only need to read. - Call
end()when done. It flushes any pending writes and releases the lock. Forgetting to callend()is the most common NVS bug; the data appears to not save. - Every
get*takes a default value. If the key does not exist (first boot, never written, wiped by partition format), the default is returned. This is the right way to handle “no value yet.”
The types and their gotchas
Preferences supports four value types:
| Type | put method | get method | Size limits |
|---|---|---|---|
| Integer | putInt(key, value) | getInt(key, default) | 32-bit signed (-2^31 to 2^31-1) |
| Float | putFloat(key, value) | getFloat(key, default) | 32-bit IEEE 754 |
| String | putString(key, value) | getString(key, default) | Up to ~4 KB per key |
| Blob | putBytes(key, data, len) | getBytes(key, buf, len) | Up to ~4 KB per key, 508 KB total |
The gotcha: getInt() returns a 32-bit int. If you stored
a uint64_t (e.g. a millisecond timestamp), you cannot read it
back as an int. Either store it as two ints (high and low
words) or use the blob storage.
Same for putFloat / getFloat: they are 32-bit. If your
value is a double, the extra precision is silently truncated
on write. For sensor readings this is fine. For money or
scientific calculations where the difference matters, scale
to integer or use blob.
Namespaces for organization
Namespaces are like folders. Use them to group related keys:
prefs.begin("wifi", false);
prefs.putString("ssid", "your-network");
prefs.putString("password", "your-password");
prefs.end();
prefs.begin("mqtt", false);
prefs.putString("broker", "192.168.1.50");
prefs.putInt("port", 1883);
prefs.putString("topic", "ctrlaltbrian/sensor/temp");
prefs.end();
The practical benefit: when you have 20 settings, namespaces let
you clear() one without touching the others. Useful when the
user wants to “reset Wi-Fi settings but keep my MQTT config.”
prefs.begin("wifi", false);
prefs.clear(); // wipe everything in the "wifi" namespace
prefs.end();
You can also wipe all namespaces:
prefs.clear(); // must be inside a begin()/end() pair
The cost is a flash erase of the NVS partition, which takes ~1 second. Fine for a “factory reset” button; not fine for “every loop iteration.”
Blob storage for larger values
For anything bigger than a string, use blobs:
struct CalibrationData {
float offset;
float gain;
int sensorId;
};
CalibrationData cal = {0.5, 1.02, 42};
prefs.begin("cal", false);
prefs.putBytes("data", &cal, sizeof(cal));
prefs.end();
// Later, read it back:
CalibrationData calRead;
prefs.begin("cal", true); // read-only
size_t bytesRead = prefs.getBytes("data", &calRead, sizeof(calRead));
prefs.end();
if (bytesRead != sizeof(calRead)) {
// Either no data stored, or stored data is wrong size
// (struct shape changed across firmware versions)
}
Blob limits:
- Max 4 KB per key (firmware limit; not the partition limit).
- Max ~508 KB total across all blobs in a namespace (depends on partition size).
- Max blob size is set by
nvs_set_blobin the underlying ESP-IDF; the wrapper does not surface this directly.
For larger data, use the file system (LittleFS or FAT) instead.
Wear-leveling (the “is this safe to write often?” question)
NVS is built on top of flash, and flash wears out after ~10,000-100,000 erase cycles per sector. NVS solves this with wear-leveling: instead of writing to the same flash sector every time, the library rotates through available sectors. A single key can be written hundreds of thousands of times before flash wear becomes a problem.
Realistic numbers:
- Updating a Wi-Fi password once a month: ~100,000 writes over 800 years. Not a concern.
- Updating a sensor reading every second for logging: ~30 million writes over a year. The chip dies from something else first, but if you log this fast, you should be using a batching pattern (write every 100 readings, not every 1).
- Updating a counter every loop iteration: maybe 10 million writes per day. This is borderline; use RTC memory for boot counters and only flush to NVS periodically.
The rule: do not write to NVS in loop(). Write on event
(“Wi-Fi connected,” “settings changed,” “user pressed button”).
When to use NVS vs RTC memory vs EEPROM
For an ESP32, you have three persistence options. For an Arduino Uno, the EEPROM emulation pattern is the equivalent.
| Use case | Pick | Why |
|---|---|---|
| Wi-Fi credentials | NVS | Must survive power cycle, user changes it |
| MQTT broker address | NVS | Same |
| Calibration constant | NVS | Same, plus the value changes occasionally |
| Boot counter | RTC memory | Fast on wake, does not need to survive power cycle |
| Last sensor reading | RTC memory | Same |
| Sensor log (every minute) | NVS or LittleFS | NVS for short logs, LittleFS for long ones |
| Large calibration table | LittleFS file | Blob limit is 4 KB |
| Configuration backup (JSON) | NVS (string) | Survives OTA, easy to inspect with nvs_tool |
The boundary: if you need it before Wi-Fi is up, RTC memory. If you need it to survive a power cycle, NVS. If it is too big for NVS, LittleFS.
When something breaks
- “Put returns OK but value is gone after reboot.” You
forgot to call
prefs.end(). Withoutend(), the write is buffered and lost on power cycle. Addend()beforesetup()returns (or after the lastput*). - “Get returns the default every time even though I just
put.” The namespace name in
begin()does not match the one you used to put. Namespaces are case-sensitive. - “Type mismatch on read.” You
putInt’d a key, thengetFloat’d it. NVS tracks the type per key; reading as a different type returns the default with no error. - “NVS is full, put fails.” The NVS partition has run out
of space. Either wipe unused namespaces with
clear(), or repartition with a larger NVS partition in the partition table.
What to build next
- A Wi-Fi configuration portal: ESP32 starts in AP mode, serves a web page, accepts SSID and password, saves to NVS, then reboots into station mode and connects. The NVS write is what makes the configuration survive the reboot.
- A “reset to defaults” function that wipes specific namespaces (e.g. clear “wifi” but keep “mqtt”). Wire to a physical button held for 10 seconds.
- A calibration routine that takes 100 readings, computes the mean and standard deviation, and saves both as a blob. Read back on every boot for the sensor math.
- The RTC memory tutorial (
esp32-rtc-memory) for things that do not need to survive power cycles, like boot counters and last-known values.