If you are porting an Arduino Uno sketch to an ESP32 and hit a wall with EEPROM.h, here is the direct answer: The ESP32 does not have hardware EEPROM. In modern ESP32 Arduino Core (v3.x and later), the legacy EEPROM library is deprecated and functionally unreliable. To store persistent data on the ESP32, you must use the Preferences.h library (which wraps Espressif’s Non-Volatile Storage, or NVS) for internal flash, or wire up an external I2C EEPROM chip like the AT24C256 for raw byte-level control.

Migrating from memory-addressed EEPROM.put() to key-value Preferences requires a fundamental shift in how you structure your data. Below is the exact hardware, code, and debugging framework you need to make the switch without bricking your flash partitions.

Internal NVS vs. Deprecated EEPROM vs. External I2C

Before writing a single line of code, you need to choose your storage backend. The table below breaks down the physical and architectural differences between the three storage methods you will encounter when searching for ESP32 EEPROM preferences Arduino solutions.

Feature Preferences.h (Internal NVS) Legacy EEPROM.h (Deprecated) External I2C (AT24C256)
Underlying Hardware Internal SPI Flash (NVS Partition) Emulated in SPI Flash (RAM cached) Dedicated I2C Silicon Chip
API Paradigm Key-Value pairs (Namespaces) Byte-addressed array (0 to 4095) Byte-addressed memory pointers
Wear Leveling Yes (handled by NVS driver) No (causes premature flash death) Hardware page-level (chip dependent)
Max Write Cycles ~100,000 (per flash sector) ~100,000 (highly concentrated risk) 1,000,000+ (AT24C256 spec)
Data Limit per Key 15 chars (Namespace & Key names) 1 byte per address 32,768 bytes (32KB total chip)
Core v3.x Support Fully Supported (Recommended) Removed / Broken Supported via Wire.h
⚠️ The 15-Character Trap: The most common failure point when migrating to Preferences.h is the strict 15-character limit on both Namespace and Key names. If you attempt preferences.putInt("motor_speed_calibration", 50), the function will silently fail or truncate. Keep keys short: "m_spd_cal".

Hardware Parts & External Fallback Pin Mapping

While Preferences.h uses internal flash and requires no external wiring, a robust industrial or high-reliability hobbyist build should include an external I2C EEPROM as a fallback. If the internal NVS partition corrupts (a known issue if power is lost exactly during a flash write), the external chip retains your calibration data.

Bill of Materials (BOM)

  • MCU: ESP32-WROOM-32 DevKit V1 (30-pin or 38-pin variant)
  • External Storage: AT24C256 I2C EEPROM Module (3.3V compatible, 32KB capacity)
  • Wiring: 22 AWG silicone stranded wire for I2C bus
  • Pull-ups: 4.7kΩ resistors on SDA/SCL (if not pre-populated on the module)

Pin Mapping Table (External I2C Fallback)

If you are implementing the hybrid failover code provided below, wire your external AT24C256 to the ESP32 using this mapping:

AT24C256 Pin ESP32 DevKit V1 Pin Function / Notes
VCC 3V3 Do NOT use 5V/VIN unless module has onboard regulator
GND GND Common ground reference
SDA GPIO 21 I2C Data (Requires 4.7kΩ pull-up to 3V3)
SCL GPIO 22 I2C Clock (Requires 4.7kΩ pull-up to 3V3)
A0, A1, A2 GND Sets I2C address to 0x50
WP GND Write Protect disabled (tied to GND allows writing)

Complete Compilable Code: Preferences with I2C Failover

This code targets the ESP32 DevKit V1 (ESP32-WROOM-32) running Arduino Core v3.x. It attempts to read and write to the internal NVS using Preferences.h. If the NVS partition is corrupted or fails to initialize, it catches the error and falls back to writing the payload to the external AT24C256 via the Wire library.

#include <Preferences.h>
#include <Wire.h>

// --- PIN DEFINITIONS (Required for External I2C Fallback) ---
#define I2C_SDA_PIN 21
#define I2C_SCL_PIN 22
#define EEPROM_I2C_ADDR 0x50 // AT24C256 base address (A0,A1,A2 tied to GND)

// --- NVS CONFIGURATION ---
// Namespace and Keys MUST be 15 characters or less!
const char* NVS_NAMESPACE = "calibration";
const char* KEY_OFFSET = "pwm_offset";

Preferences preferences;
bool nvsHealthy = false;

// Function to write a byte to external I2C EEPROM
void writeExternalEEPROM(uint16_t eeaddress, byte data) {
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((int)(eeaddress >> 8));   // MSB
  Wire.write((int)(eeaddress & 0xFF)); // LSB
  Wire.write(data);
  Wire.endTransmission();
  delay(5); // AT24C256 requires ~5ms write cycle time
}

// Function to read a byte from external I2C EEPROM
byte readExternalEEPROM(uint16_t eeaddress) {
  byte rdata = 0xFF;
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((int)(eeaddress >> 8));   // MSB
  Wire.write((int)(eeaddress & 0xFF)); // LSB
  Wire.endTransmission();
  Wire.requestFrom(EEPROM_I2C_ADDR, 1);
  if (Wire.available()) rdata = Wire.read();
  return rdata;
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("\n--- ESP32 Storage Initialization ---");

  // 1. Initialize I2C for external fallback
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);

  // 2. Initialize Internal NVS (Preferences)
  // false = Read/Write mode, true = Read-Only
  nvsHealthy = preferences.begin(NVS_NAMESPACE, false);
  
  if (!nvsHealthy) {
    Serial.println("[FATAL] NVS Partition corrupted or locked. Falling back to I2C EEPROM.");
  } else {
    Serial.println("[OK] Internal NVS initialized successfully.");
  }

  // 3. Read or Write Data
  int targetOffset = 42; // The value we want to persist
  int retrievedOffset = 0;

  if (nvsHealthy) {
    // Check if key exists, if not, write it
    if (!preferences.isKey(KEY_OFFSET)) {
      Serial.println("Key missing in NVS. Writing default...");
      preferences.putInt(KEY_OFFSET, targetOffset);
    }
    retrievedOffset = preferences.getInt(KEY_OFFSET, 0);
    Serial.printf("Read from NVS: %d\n", retrievedOffset);
  } else {
    // Fallback to External I2C EEPROM (Address 0x0000)
    retrievedOffset = readExternalEEPROM(0x0000);
    if (retrievedOffset == 0xFF) { // 0xFF means blank flash
      writeExternalEEPROM(0x0000, targetOffset);
      retrievedOffset = targetOffset;
    }
    Serial.printf("Read from External I2C: %d\n", retrievedOffset);
  }

  // Clean up NVS to free RAM resources if we are done writing
  if (nvsHealthy) preferences.end();
}

void loop() {
  // Main application logic runs here
  delay(1000);
}

Debugging: Exact Error Strings and Ranked Causes

When working with ESP32 flash partitions, the Arduino IDE Serial Monitor will sometimes spit out raw ESP-IDF C-level errors before the setup() function even finishes. If your preferences.begin() returns false, check the serial output for these exact strings.

The First Three Things to Check When It Fails

  1. Partition Table Mismatch: Did you recently change the Partition Scheme in the Arduino IDE Tools menu (e.g., from "Default 4MB" to "Huge APP")? Changing the scheme wipes the NVS partition boundaries, causing immediate initialization failure.
  2. Namespace Typos: Verify your namespace string is strictly ≤ 15 characters. The compiler will not warn you if you pass a 20-character string; it will simply fail at runtime.
  3. Flash Voltage Setting: In the IDE Tools menu, ensure "Flash Mode" is set to QIO and "Flash Frequency" to 80MHz. Incorrect SPI flash timing parameters can cause NVS read timeouts.

Ranked Error Causes & Fixes

Exact Error String (Serial Monitor) Root Cause The Fix
ESP_ERR_NVS_NO_FREE_PAGES The NVS flash sector is full of invalidated (deleted) keys and hasn't been garbage-collected. Call nvs_flash_erase() via ESP-IDF, or use the Arduino IDE "Erase All Flash Before Sketch Upload" tool.
ESP_ERR_NVS_NEW_VERSION_FOUND You downgraded Arduino Core (e.g., from v3.x back to v2.x). The newer NVS binary format is unreadable by the older driver. Erase the flash completely and re-upload. NVS formats are not strictly backward-compatible across major core versions.
E (123) nvs: nvs_flash_init failed Hardware brownout during a previous write cycle corrupted the NVS page header. Implement the I2C external fallback code provided above. Hardware corruption requires external redundancy.
💡 Pro-Tip for Bench Debugging: If you are stuck in an ESP_ERR_NVS_NO_FREE_PAGES loop and don't want to wipe your entire sketch, you can programmatically format the NVS partition from within your code by calling #include <nvs_flash.h> and executing nvs_flash_erase(); followed by nvs_flash_init(); before calling preferences.begin(). Use this only as a one-time recovery script.

Extending and Simplifying Your Storage Build

Once you have the baseline Preferences implementation running, you will quickly realize that managing dozens of 15-character keys becomes a nightmare for complex projects like PID controllers or multi-sensor arrays. Here is how to scale your architecture.

How to Extend: JSON Serialization to LittleFS

If your data structure exceeds the simple key-value paradigm (e.g., storing arrays of calibration points or Wi-Fi credentials), abandon Preferences.h and move to the ESP32’s LittleFS file system. LittleFS allows you to save standard .json files directly to the flash. You can use the ArduinoJson library to serialize a C++ struct into a JSON document, write it to /config.json, and parse it on boot. This completely bypasses the 15-character limit and makes your configuration human-readable via a simple web server.

How to Simplify: The Wrapper Class Pattern

To prevent magic strings from littering your loop(), wrap the Preferences calls in a dedicated C++ class. Create a ConfigManager.h file that exposes clean getter/setter methods like ConfigManager::getMotorSpeed(). This centralizes your namespace definitions, ensures you never accidentally misspell a key, and allows you to swap the underlying storage engine (from NVS to external I2C) without rewriting your main application logic.

For deeper architectural details on ESP32 flash management, always refer to the official Espressif NVS Flash Documentation and the Arduino Storage Reference. Understanding the boundary between the Arduino wrapper and the underlying ESP-IDF is what separates a frustrated hobbyist from a reliable embedded engineer.