How to Use ESP-IDF NVS for Persistent Configuration and Calibration Data

You spent 20 minutes calibrating a sensor offset. You tuned a threshold until the readings looked perfect. You unplugged the board to move it, plugged it back in, and everything was gone. Back to defaults. Again.
This happens because RAM doesn’t survive a power cycle, and if your configuration lives in RAM (or worse, hardcoded constants you keep editing), you’re stuck recalibrating every time the ESP32 reboots. The fix is NVS: a key-value store built into ESP-IDF that writes to flash and persists across reboots, power cycles, even OTA updates. No external EEPROM, no SD card, no filesystem.
This article covers simple scalars: integers, floats, and short strings like Wi-Fi SSIDs or sensor labels. By the end, you’ll have a copy-and-adapt pattern for any project that needs esp32 persistent config.
What NVS Actually Is
NVS stands for Non-Volatile Storage. It’s a flash-based key-value store that ships as part of esp-idf non-volatile storage components. The default partition table already carves out space for it, so there’s zero setup on that front.
Picture a partition on your ESP32’s internal flash, divided into namespaces, each holding key-value pairs:
+---------------------+
| NVS Partition |
| +---------------+ |
| | Namespace: "wifi" |
| | key: "ssid" |
| | key: "pass" |
| +---------------+ |
| +---------------+ |
| | Namespace: "cal" |
| | key: "offset" |
| | key: "gain" |
| +---------------+ |
+---------------------+Here are the three pieces you need to understand:
- Namespace: a grouping label, max 15 characters. Think of it like a folder.
"wifi","cal","prefs". - Key: a name within that namespace, also max 15 characters.
"ssid","offset","threshold". - Value: typed data.
int8_t,int32_t,uint32_t,const char*, and others.
For typical config writes (a handful per boot cycle, not thousands per second), flash wear isn’t a concern. The NVS library handles wear leveling for you.
NVS is not a filesystem. If you need to store large structured data or files, SPIFFS or LittleFS are better tools. But for the 90% case of “remember this number across reboots,” NVS is exactly right.
Project Setup and Initialization
You need two headers:
#include "nvs_flash.h"
#include "nvs.h"No special CMakeLists.txt changes required. nvs_flash is a default ESP-IDF component.
Here’s the initialization boilerplate every project needs:
void init_nvs(void) {
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES ||
err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
// Partition was corrupted or format changed; erase and retry
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
}That erase-and-retry block looks odd the first time you see it. It exists because changing your partition table, doing certain OTA updates, or flashing a brand new chip can leave the NVS partition in an unexpected state. The check catches that, wipes the slate clean, and reinitializes. On a normal reboot, it doesn’t trigger.
Call init_nvs() once at the top of app_main(), before any NVS reads or writes. Skip this step and you’ll get a hard crash.
Writing Values to NVS
Every NVS write follows a three-step pattern: open, write, close. With a commit squeezed in before the close.
Here’s a function that saves two calibration values:
esp_err_t save_calibration(int32_t offset, int32_t gain) {
nvs_handle_t handle;
esp_err_t err;
err = nvs_open("cal", NVS_READWRITE, &handle);
if (err != ESP_OK) return err;
err = nvs_set_i32(handle, "offset", offset);
if (err != ESP_OK) { nvs_close(handle); return err; }
err = nvs_set_i32(handle, "gain", gain);
if (err != ESP_OK) { nvs_close(handle); return err; }
err = nvs_commit(handle);
nvs_close(handle);
return err;
}Walking through each call:
nvs_open("cal", NVS_READWRITE, &handle)opens the"cal"namespace for writing. If the namespace doesn’t exist yet, it gets created.nvs_set_i32(handle, "offset", offset)stages a 32-bit signed integer in RAM.nvs_commit(handle)pushes staged changes to flash.nvs_close(handle)releases the handle.
The set family has variants for different types:
| Function | C Type | Typical Use |
|---|---|---|
nvs_set_i8 | int8_t | Small flags, modes |
nvs_set_u8 | uint8_t | Small flags, modes |
nvs_set_i32 | int32_t | Calibration offsets |
nvs_set_u32 | uint32_t | Counters, thresholds |
nvs_set_str | const char* | SSID, short labels |
Storing Floats (the Sneaky Part)
NVS has no native float type. The cleanest workaround is a memcpy to uint32_t:
float temp_offset = 1.25f;
uint32_t raw;
memcpy(&raw, &temp_offset, sizeof(raw));
nvs_set_u32(handle, "t_off", raw);Read it back the same way, reversing the memcpy. This preserves the exact bit pattern with no precision loss. The alternative is storing milli-units as an integer (1250 instead of 1.25), which works fine for many sensor applications.
One important rule: don’t write in a tight loop. Flash has a finite number of write cycles. NVS is designed for infrequent configuration changes. If you’re logging data every second, use a different storage mechanism entirely.
Reading Values with Default Fallback
Here’s where real projects trip up. The first time your firmware boots on a fresh chip, the keys don’t exist yet. nvs_get_i32 returns ESP_ERR_NVS_NOT_FOUND. That’s normal, not an error to panic about.
The pattern: try to read, fall back to a default if the key is missing.
#define DEFAULT_OFFSET 0
#define DEFAULT_GAIN 1000
esp_err_t load_calibration(int32_t *offset, int32_t *gain) {
nvs_handle_t handle;
esp_err_t err;
err = nvs_open("cal", NVS_READONLY, &handle);
if (err != ESP_OK) {
// First boot: namespace doesn't exist yet
*offset = DEFAULT_OFFSET;
*gain = DEFAULT_GAIN;
return ESP_OK;
}
if (nvs_get_i32(handle, "offset", offset) != ESP_OK) {
*offset = DEFAULT_OFFSET;
}
if (nvs_get_i32(handle, "gain", gain) != ESP_OK) {
*gain = DEFAULT_GAIN;
}
nvs_close(handle);
return ESP_OK;
}We open with NVS_READONLY since we’re only reading. This means the namespace won’t be created if it doesn’t exist (which is fine; we handle that case).
Defining defaults as #define constants at the top of the file makes them easy to find and change later.
For strings, nvs_get_str requires two calls. The first gets the length, the second gets the data:
size_t len = 0;
nvs_get_str(handle, "ssid", NULL, &len); // get length
char *ssid = malloc(len);
nvs_get_str(handle, "ssid", ssid, &len); // get value
Don’t forget to free() that buffer when you’re done with it.
The Complete Working Example
Here’s a self-contained app_main you can flash and test. It loads calibration values (defaulting on first boot), prints them, then saves new values. Reboot the board and you’ll see the saved values persist.
#include <stdio.h>
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_log.h"
#define TAG "nvs_demo"
#define DEFAULT_OFFSET 0
#define DEFAULT_GAIN 1000
static void init_nvs(void) {
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES ||
err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
}
static esp_err_t load_calibration(int32_t *offset, int32_t *gain) {
nvs_handle_t handle;
esp_err_t err = nvs_open("cal", NVS_READONLY, &handle);
if (err != ESP_OK) {
*offset = DEFAULT_OFFSET;
*gain = DEFAULT_GAIN;
return ESP_OK;
}
if (nvs_get_i32(handle, "offset", offset) != ESP_OK)
*offset = DEFAULT_OFFSET;
if (nvs_get_i32(handle, "gain", gain) != ESP_OK)
*gain = DEFAULT_GAIN;
nvs_close(handle);
return ESP_OK;
}
static esp_err_t save_calibration(int32_t offset, int32_t gain) {
nvs_handle_t handle;
esp_err_t err = nvs_open("cal", NVS_READWRITE, &handle);
if (err != ESP_OK) return err;
nvs_set_i32(handle, "offset", offset);
nvs_set_i32(handle, "gain", gain);
err = nvs_commit(handle);
nvs_close(handle);
return err;
}
void app_main(void) {
init_nvs();
int32_t offset, gain;
load_calibration(&offset, &gain);
ESP_LOGI(TAG, "Loaded: offset=%ld, gain=%ld", offset, gain);
// Simulate new calibration
offset += 5;
gain += 10;
save_calibration(offset, gain);
ESP_LOGI(TAG, "Saved: offset=%ld, gain=%ld", offset, gain);
}Flash it, open the serial monitor, and watch the output. First boot: offset=0, gain=1000. Reboot: offset=5, gain=1010. Reboot again: offset=10, gain=1020.
Here’s the operation flow for reference:
app_main()
│
▼
nvs_flash_init()
│
▼
nvs_open("namespace", mode, &handle)
│
├──▶ nvs_get_xxx(handle, "key", &value) [READ]
│
├──▶ nvs_set_xxx(handle, "key", value) [WRITE]
│ │
│ ▼
│ nvs_commit(handle)
│
▼
nvs_close(handle)Common Mistakes and How to Avoid Them
Forgetting nvs_commit(). Your set calls stage data in RAM. Without commit, nothing reaches flash. The board reboots, the data’s gone, and you’ll spend an hour staring at code that “should work.”
Key or namespace longer than 15 characters. "calibration_data" is 16 characters. It’ll fail or truncate silently depending on the version. Keep names short: "cal", "wifi", "prefs".
Calling NVS functions before nvs_flash_init(). This guarantees a crash. Put init_nvs() at the very top of app_main(), before anything else touches storage.
Writing in a tight loop. Calling save_calibration() every 100ms will chew through flash write cycles. Save when the user changes a setting, not on every sensor read.
On a fresh chip, every key returns ESP_ERR_NVS_NOT_FOUND. That’s expected behavior, not a bug. It’s the entire reason for the default-fallback pattern. If you see that error code during first boot, everything is working correctly.
If you’re building a BLE-connected device that needs to persist configuration, this NVS pattern works alongside the Hubble Device SDK’s advertising packet structure. Store device-specific settings that survive reboots while your BLE stack handles communication.
Copy These 5 Calls Into Your Next Project
The whole pattern boils down to nvs_flash_init, nvs_open, nvs_get_xxx / nvs_set_xxx, nvs_commit, nvs_close. The “read with default fallback” pattern handles first boot gracefully, and the init boilerplate catches corrupted partitions automatically.
For most embedded projects, this covers 90% of what you need from persistent storage. When you outgrow simple scalars, NVS also supports blob storage for raw byte arrays (useful for storing entire structs), encrypted NVS partitions for sensitive credentials, and custom NVS partitions for larger storage needs.
Copy the init_nvs() function into your app_main boilerplate and leave it there. It costs almost nothing, and the first time you need to save a setting across reboots, you’ll be glad it’s already wired up.
Hubble Network enables persistent device configuration to reach the cloud over satellite—no local gateways or infrastructure required. See how it works →