The ESP32 has built-in flash memory that retains data even when the power is off. By using a file system, you can store configuration files, sensor logs, calibration data, and even web pages directly on the chip -- no SD card needed. This article explains how to read, write, and manage files on the ESP32 using LittleFS.
🔗Why Use a File System?
Variables in your sketch live in RAM, which is erased every time the ESP32 restarts. If you need data to survive reboots -- WiFi credentials, sensor calibration values, logged readings -- you need to write it to flash memory.
Common use cases for a file system on the ESP32:
- Configuration files -- Store WiFi SSID/password, MQTT broker address, or device settings so they persist across power cycles.
- Sensor logs -- Record temperature, humidity, or other readings over time, even when the device is offline.
- Calibration data -- Save sensor offsets or scaling factors so you do not have to recalibrate after every reboot.
- Web server files -- Host HTML, CSS, and JavaScript files for a built-in web interface.
Note: For simple key-value storage (like saving a single WiFi password or a threshold value), the Preferences library is often easier. It works like a dictionary:
preferences.putString("ssid", "MyNetwork"). A file system is the better choice when you need multiple files, structured data, or larger storage.
🔗SPIFFS vs LittleFS
The ESP32 Arduino framework supports two file systems. Here is how they compare:
| Feature | SPIFFS | LittleFS |
|---|---|---|
| Directory support | No (flat file list) | Yes |
| Wear leveling | Basic | Better (log-structured) |
| Power-loss resilience | Poor (can corrupt) | Good (atomic writes) |
| Speed | Slower | Faster |
| Status | Deprecated | Recommended |
| API | FS.h | FS.h (same interface) |
SPIFFS was the original file system for ESP32 Arduino projects, but it has been deprecated in favor of LittleFS. LittleFS supports directories, handles power loss more gracefully, and is faster.
Tip: Use LittleFS for all new projects. SPIFFS still works but is no longer actively developed. Both use the same
FS.hAPI, so switching from SPIFFS to LittleFS in existing code usually just means changing the include and mount call.
🔗Flash Partition Layout
The ESP32's flash memory is divided into partitions -- regions reserved for different purposes. A typical layout looks like this:
| Partition | Purpose | Typical size |
|---|---|---|
| Bootloader | Starts the ESP32 | 16 KB |
| Partition table | Describes the layout | 4 KB |
| app0 | Your sketch (primary) | ~1.3 MB |
| app1 | OTA update slot | ~1.3 MB |
| spiffs / littlefs | File system data | ~1.5 MB |
| NVS | Preferences / key-value store | 20 KB |
The file system partition is where LittleFS stores your files. With the default partition scheme on a 4 MB flash ESP32, you get roughly $1.5 \, \text{MB}$ for file storage -- more than enough for configuration files, logs, and small web pages.
Tip: You can select different partition schemes in the Arduino IDE under Tools > Partition Scheme. For example, "No OTA (2MB APP / 2MB SPIFFS)" gives you more file system space by removing the second app slot. Only change this if you know you need more storage and do not need OTA updates.
🔗Code Example: Basic File Operations
This sketch demonstrates the core file operations: mounting the file system, writing, reading, appending, checking existence, listing files, and deleting.View complete sketch
#include <LittleFS.h>
void setup() {
Serial.begin(115200);
delay(1000);
// Mount LittleFS (true = format if mount fails, e.g. first use)
if (!LittleFS.begin(true)) {
Serial.println("Failed to mount LittleFS");
return;
}
Serial.println("LittleFS mounted successfully");
// --- Write a file ---
Serial.println("\n--- Writing file ---");
File fileW = LittleFS.open("/config.txt", "w"); // "w" = write (overwrites)
if (!fileW) {
Serial.println("Failed to open file for writing");
return;
}
fileW.println("ssid=MyNetwork");
fileW.println("password=Secret123");
fileW.close();
Serial.println("File written");
// --- Read a file ---
Serial.println("\n--- Reading file ---");
File fileR = LittleFS.open("/config.txt", "r"); // "r" = read
if (!fileR) {
Serial.println("Failed to open file for reading");
return;
}
while (fileR.available()) {
String line = fileR.readStringUntil('\n');
Serial.println(line);
}
fileR.close();
// --- Append to a file ---
Serial.println("\n--- Appending to file ---");
File fileA = LittleFS.open("/config.txt", "a"); // "a" = append
if (!fileA) {
Serial.println("Failed to open file for appending");
return;
}
fileA.println("mqtt_broker=192.168.1.100");
fileA.close();
Serial.println("Line appended");
// --- Check if a file exists ---
if (LittleFS.exists("/config.txt")) {
Serial.println("\n/config.txt exists");
}
// --- List directory contents ---
Serial.println("\n--- Listing root directory ---");
File root = LittleFS.open("/");
File entry = root.openNextFile();
while (entry) {
Serial.printf(" %s (%d bytes)\n", entry.name(), entry.size());
entry = root.openNextFile();
}
root.close();
// --- Delete a file ---
Serial.println("\n--- Deleting file ---");
if (LittleFS.remove("/config.txt")) {
Serial.println("/config.txt deleted");
} else {
Serial.println("Delete failed");
}
// Verify it is gone
if (!LittleFS.exists("/config.txt")) {
Serial.println("/config.txt no longer exists");
}
}
void loop() {
// Nothing here
}
🔗How It Works
| Code | Purpose |
|---|---|
#include <LittleFS.h> | Include the LittleFS library (built into ESP32 Arduino core) |
LittleFS.begin(true) | Mount the file system. The true parameter tells it to format the partition if mounting fails (for example, on first use) |
LittleFS.open(path, mode) | Open a file. Modes: "r" (read), "w" (write/overwrite), "a" (append) |
file.println() / file.print() | Write text to the file, just like Serial |
file.readStringUntil('\n') | Read one line at a time |
file.close() | Close the file and flush data to flash. Always close files when done |
LittleFS.exists(path) | Check whether a file exists |
LittleFS.remove(path) | Delete a file |
root.openNextFile() | Iterate through files in a directory |
Tip: Always check the return value of
LittleFS.open(). If it returns a falsyFileobject, the open failed -- usually because the path is wrong or the file system is not mounted.
🔗Storing Configuration as JSON
Plain text files work, but structured data is easier to manage with JSON. The ArduinoJson library makes this straightforward.
Install ArduinoJson from the Arduino Library Manager (search for "ArduinoJson" by Benoit Blanchon) or add it to your PlatformIO lib_deps.View complete sketch
#include <LittleFS.h>
#include <ArduinoJson.h>
const char* CONFIG_FILE = "/config.json";
// Load configuration from file. Returns true if successful.
bool loadConfig(char* ssid, char* password, char* mqttBroker) {
File file = LittleFS.open(CONFIG_FILE, "r");
if (!file) {
Serial.println("Config file not found");
return false;
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) {
Serial.printf("Failed to parse config: %s\n", error.c_str());
return false;
}
strlcpy(ssid, doc["ssid"] | "default_ssid", 32);
strlcpy(password, doc["password"] | "", 64);
strlcpy(mqttBroker, doc["mqtt_broker"] | "192.168.1.1", 64);
return true;
}
// Save configuration to file. Returns true if successful.
bool saveConfig(const char* ssid, const char* password,
const char* mqttBroker) {
JsonDocument doc;
doc["ssid"] = ssid;
doc["password"] = password;
doc["mqtt_broker"] = mqttBroker;
File file = LittleFS.open(CONFIG_FILE, "w");
if (!file) {
Serial.println("Failed to open config file for writing");
return false;
}
serializeJsonPretty(doc, file);
file.close();
return true;
}
void setup() {
Serial.begin(115200);
delay(1000);
if (!LittleFS.begin(true)) {
Serial.println("Failed to mount LittleFS");
return;
}
char ssid[32];
char password[64];
char mqttBroker[64];
// Try to load existing config
if (loadConfig(ssid, password, mqttBroker)) {
Serial.println("Configuration loaded:");
Serial.printf(" SSID: %s\n", ssid);
Serial.printf(" Password: %s\n", password);
Serial.printf(" MQTT Broker: %s\n", mqttBroker);
} else {
// No config found -- save defaults
Serial.println("No config found, saving defaults...");
saveConfig("MyNetwork", "MyPassword", "192.168.1.100");
Serial.println("Default config saved. Restart to load it.");
}
}
void loop() {
// Your main application code here
}
🔗How It Works
The loadConfig() function opens the JSON file, parses it with ArduinoJson, and copies each value into the provided character arrays. The | "default_value" syntax provides a fallback if a key is missing from the file.
The saveConfig() function creates a JSON document, populates it, and writes it to the file using serializeJsonPretty() for human-readable formatting.
On the first boot, no config file exists, so the sketch saves defaults. On subsequent boots, it reads the saved configuration. The file on flash looks like this:
{
"ssid": "MyNetwork",
"password": "MyPassword",
"mqtt_broker": "192.168.1.100"
}Tip: This pattern works well for devices that need a setup mode. If no config file exists on boot, you could start in AP mode and serve a web page where the user enters their WiFi credentials. Once saved, the device reboots and connects to the configured network.
🔗Data Logging
A common use case is logging sensor readings to a file for later retrieval. This example appends readings in CSV format and uses a buffering strategy to reduce flash writes.View complete sketch
#include <LittleFS.h>
const char* LOG_FILE = "/sensor_log.csv";
const int BUFFER_SIZE = 10; // Flush after this many readings
const unsigned long READ_INTERVAL = 5000; // Read every 5 seconds
float readingBuffer[BUFFER_SIZE];
unsigned long timestampBuffer[BUFFER_SIZE];
int bufferIndex = 0;
unsigned long lastReadTime = 0;
// Simulate a sensor reading (replace with real sensor code)
float readSensor() {
return 20.0 + random(-50, 50) / 10.0; // 15.0 to 25.0
}
// Flush buffered readings to flash
void flushBuffer() {
File file = LittleFS.open(LOG_FILE, "a");
if (!file) {
Serial.println("Failed to open log file");
return;
}
for (int i = 0; i < bufferIndex; i++) {
file.printf("%lu,%.1f\n", timestampBuffer[i], readingBuffer[i]);
}
file.close();
Serial.printf("Flushed %d readings to flash\n", bufferIndex);
bufferIndex = 0;
}
// Print the entire log file to Serial
void printLog() {
File file = LittleFS.open(LOG_FILE, "r");
if (!file) {
Serial.println("No log file found");
return;
}
Serial.println("\n--- Sensor Log (timestamp_s, temperature_C) ---");
while (file.available()) {
Serial.println(file.readStringUntil('\n'));
}
Serial.printf("--- End of log (%d bytes) ---\n", file.size());
file.close();
}
void setup() {
Serial.begin(115200);
delay(1000);
if (!LittleFS.begin(true)) {
Serial.println("Failed to mount LittleFS");
return;
}
Serial.println("LittleFS mounted");
// Write CSV header if file does not exist
if (!LittleFS.exists(LOG_FILE)) {
File file = LittleFS.open(LOG_FILE, "w");
file.println("timestamp_s,temperature_C");
file.close();
Serial.println("Created new log file with header");
}
// Print any existing log data
printLog();
Serial.println("\nLogging started. Send 'p' via Serial to print log.");
bootTime = millis();
}
void loop() {
unsigned long now = millis();
// Take a reading at the configured interval
if (now - lastReadTime >= READ_INTERVAL) {
lastReadTime = now;
readingBuffer[bufferIndex] = readSensor();
timestampBuffer[bufferIndex] = millis() / 1000; // Seconds since boot
Serial.printf("Reading %d: %.1f C\n", bufferIndex, readingBuffer[bufferIndex]);
bufferIndex++;
// Flush buffer when full
if (bufferIndex >= BUFFER_SIZE) {
flushBuffer();
}
}
// Print log on serial command
if (Serial.available()) {
char c = Serial.read();
if (c == 'p' || c == 'P') {
if (bufferIndex > 0) {
flushBuffer(); // Write any buffered data first
}
printLog();
}
}
}
🔗How It Works
Instead of writing every reading directly to flash, this sketch buffers readings in RAM and writes them in batches. This is important because flash memory has a limited number of write cycles.
The flow looks like this:
graph LR
A[Read sensor] --> B[Store in RAM buffer]
B --> C{Buffer full?}
C -->|No| A
C -->|Yes| D[Write batch to flash]
D --> AWith a 5-second read interval and a buffer size of 10, the sketch writes to flash once every 50 seconds instead of every 5 seconds -- a 10x reduction in flash writes.
Warning: ESP32 flash memory has a limited lifespan of roughly $100{,}000$ write cycles per sector. Writing every second would wear out a sector in about a day. Always buffer writes in RAM and flush periodically. For high-frequency logging, consider using an SD card instead.
The log file uses CSV format, which is easy to parse and can be opened directly in spreadsheet software:
timestamp_s,temperature_C
50,22.3
55,21.8
60,22.1🔗Serving Web Pages from Flash
LittleFS is not just for data files -- you can store complete HTML, CSS, and JavaScript files on the ESP32's flash and serve them with a web server. This is how many ESP32 projects provide a configuration interface or dashboard.
The basic idea:
- Create your web files (HTML, CSS, JS)
- Upload them to the ESP32's LittleFS partition using the LittleFS upload tool (available as a plugin for both Arduino IDE and PlatformIO)
- Use the
ESPAsyncWebServeror built-inWebServerlibrary to serve the files
In PlatformIO, place your web files in a data/ folder in your project root. Then run the "Upload Filesystem Image" command to flash them to the ESP32's LittleFS partition.
This is a topic that deserves its own article. For now, just know that LittleFS is the standard way to store web assets on the ESP32.
🔗Troubleshooting
| Problem | Likely cause | Solution |
|---|---|---|
LittleFS.begin() returns false | Partition not formatted or wrong partition scheme | Pass true to LittleFS.begin(true) to auto-format on first use |
| File not found after reboot | File path does not start with / | All paths must start with /, e.g. /config.txt, not config.txt |
| Data lost after re-uploading sketch | Arduino IDE erased the flash partition | In Arduino IDE, set Tools > Erase All Flash Before Sketch Upload to "Disabled" (default). In PlatformIO, this is not an issue by default |
| Flash appears full | Log file grew too large | Check file size with file.size() and delete or rotate logs when they exceed a limit |
| Slow writes | Writing small amounts frequently | Buffer data in RAM and write in larger batches |
| Data corruption after power loss | SPIFFS does not handle power loss well | Switch to LittleFS, which is resilient to unexpected power loss |
Compilation error: LittleFS not found | Older ESP32 Arduino core | Update to ESP32 Arduino core 2.0 or later. LittleFS is included by default |
🔗What's Next?
Now that you can store data persistently, you are ready to combine file systems with networking. Store WiFi credentials in a JSON config file, then use them to connect automatically on boot -- see WiFi Basics for the networking side.
For sending stored sensor data to a remote server or dashboard, check out Introduction to MQTT -- the lightweight messaging protocol used in most IoT systems.
Project idea: Build an offline data logger that records sensor readings to LittleFS while disconnected, then uploads the backlog to an MQTT broker when WiFi becomes available.