07 · Files & Persistence¶
Everything in RAM vanishes at reset — and embedded devices reset a lot
(power loss, watchdogs, deploys). The ESP32's flash chip holds a small
filesystem that survives, and MicroPython exposes it through the same
open()/read()/write() API you know from desktop Python. This module
covers the filesystem, a JSON config pattern every device should use, CSV
sensor logging, and the space limits you must respect. Wokwi's simulated
ESP32 has a working filesystem too — files persist across soft resets
within a session, so everything here is runnable in the browser.
The onboard filesystem¶
MicroPython formats part of the ESP32's flash (typically ~2 MB of a 4 MB
chip) as a LittleFS filesystem mounted at /. boot.py and main.py from
module 2 live there, and os works as expected:
import os
print(os.listdir()) # ['boot.py', 'main.py', 'ssd1306.py', ...]
os.mkdir("data") # directories work
os.rename("log.csv", "old.csv")
os.remove("old.csv")
print(os.getcwd()) # '/'
How much room is left? os.statvfs returns block counts — multiply out:
def fs_free():
st = os.statvfs("/")
block_size, total_blocks, free_blocks = st[0], st[2], st[3]
return free_blocks * block_size, total_blocks * block_size
free, total = fs_free()
print("{} of {} bytes free".format(free, total)) # e.g. 1978368 of 2097152
Reading and writing is standard Python — context managers and all:
with open("note.txt", "w") as f:
f.write("written at boot\n")
with open("note.txt") as f:
print(f.read())
Flash wears out
Flash memory endures a limited number of erase cycles (~10k–100k per sector). Writing a file every 50 ms will genuinely destroy a board in weeks. Log at sensible intervals (seconds/minutes), batch writes, and keep constantly-changing state in RAM. This is a real-world failure mode the desktop never taught you.
Pattern: JSON config file¶
Hard-coding WiFi names, pin numbers, and thresholds means re-editing code
per device. The standard fix — a config.json on the filesystem and the
json module:
import json
DEFAULTS = {
"wifi_ssid": "Wokwi-GUEST",
"wifi_password": "",
"read_interval_s": 5,
"temp_alert_c": 30.0,
}
def load_config(path="config.json"):
try:
with open(path) as f:
cfg = json.load(f)
except (OSError, ValueError): # missing file or corrupt JSON
cfg = {}
out = dict(DEFAULTS)
out.update(cfg) # file values override defaults
return out
def save_config(cfg, path="config.json"):
with open(path, "w") as f:
json.dump(cfg, f)
config = load_config()
print("reading every", config["read_interval_s"], "s")
Note the shape of the error handling: a fresh board with no config file
must run anyway with defaults, and a corrupt file must not crash the
boot. except (OSError, ValueError) covers both. This exact pattern
returns in the capstone.
Pattern: CSV sensor log¶
Append-mode ("a") writing plus one line per reading gives you a log any
spreadsheet can open:
from machine import Pin
import dht, time
sensor = dht.DHT22(Pin(15))
LOG = "temps.csv"
def log_reading():
sensor.measure()
line = "{},{:.1f},{:.1f}\n".format(
time.ticks_ms(), sensor.temperature(), sensor.humidity())
with open(LOG, "a") as f: # "a" = append, creates if missing
f.write(line)
for _ in range(10):
log_reading()
time.sleep(2)
with open(LOG) as f: # read it back
for line in f:
print(line.strip())
(Real timestamps need a clock — the capstone uses uptime, Level 2 adds NTP.)
Bounding the log: a poor man's ring buffer¶
An append-forever log eventually fills the flash and every write starts
raising OSError: 28 (ENOSPC). Bound it. Simplest robust scheme — two
files: when the current log gets big, rename it to .old (deleting the
previous .old) and start fresh. You always retain between one and two
files' worth of recent history, and it's crash-safe:
import os
MAX_BYTES = 4096
def rotate_if_needed(path="temps.csv"):
try:
if os.stat(path)[6] > MAX_BYTES: # index 6 = file size
old = path + ".old"
try:
os.remove(old)
except OSError:
pass # no previous .old — fine
os.rename(path, old)
except OSError:
pass # log doesn't exist yet — fine
rotate_if_needed()
log_reading()
The capstone upgrades this idea to a fixed-size in-RAM ring buffer flushed to flash — but rotation is the workhorse you'll reuse everywhere.
How It Actually Works¶
open() looks identical to desktop Python, but underneath it MicroPython is
talking to LittleFS, a filesystem specifically engineered for raw NOR flash
and its two defining constraints: erase-before-write, and a limited number
of erases per cell.
- Flash can't be overwritten in place the way a hard drive can. A NOR
flash sector must be erased (every bit set to 1) as a whole block —
typically 4 KB — before any bit in it can be written back to 0. LittleFS
is a copy-on-write, log-structured filesystem: instead of editing a file's
data block directly, it writes the new version into a fresh location and
updates metadata to point at it, only reclaiming (erasing) the old block
later. This is fundamentally different from ext4 or NTFS, and it's the
real reason
os.stat()/os.statvfs()exist as thin wrappers around LittleFS's own accounting rather than a generic VFS layer pretending flash behaves like a disk. - Wear-leveling is why "write every 50 ms will destroy a board" is literal, not hyperbole. Each flash sector tolerates on the order of 10,000–100,000 erase cycles before it stops reliably holding a charge. LittleFS spreads writes across free blocks rather than always erasing the same physical sector (that's "wear leveling"), which delays the failure but cannot eliminate it if you write constantly — the finite number of erase cycles is a property of the silicon's floating-gate transistors, not something any filesystem algorithm can work around.
json.load/json.dumpallocate a full parse tree on the heap. Unlike streaming parsers, MicroPython'sjsonmodule builds the complete dict/list structure in RAM before your code sees any of it — fine for a smallconfig.json, but a reason to never treat it as a database: parsing a multi-KB JSON file on a device with ~100 KB of heap competes directly with everything else your program has allocated, and is a common, hard-to-diagnose source ofMemoryErroron boot when a log file accidentally gets loaded as if it were config.- The rotate-to-
.oldpattern is a manual, file-level version of what LittleFS's own journal does internally at the block level — never let an in-progress write be the only copy of anything you can't afford to lose. Because a power loss mid-write can leave a file truncated or (rarely, depending on wear and corruption) a directory entry inconsistent, LittleFS is designed to always recover to some consistent prior state after an unclean power-down — but "consistent" doesn't mean "your log wasn't truncated," which is exactly the gap the two-file rotation scheme closes at the application level.
Cheat sheet¶
| Function / idiom | Purpose |
|---|---|
os.listdir() / mkdir / remove / rename |
Filesystem housekeeping |
os.stat(path)[6] |
File size in bytes |
os.statvfs("/") |
Blocks free/total → space arithmetic |
open(p, "w"/"a"/"r") |
Write / append / read — standard Python |
json.load(f) / json.dump(cfg, f) |
Config in, config out |
except (OSError, ValueError) |
Missing file / corrupt JSON — boot anyway |
| CSV logging | One f.write(line) per reading, append mode |
| Rotation | os.rename current → .old when size exceeds a cap |
| Flash wear | Limited erase cycles — write seconds apart, not milliseconds |
| Typical size | ~2 MB filesystem on a 4 MB-flash ESP32 |
Exercise¶
Build a boot counter with settings in Wokwi: on startup, load
config.json (defaults: {"blink_ms": 250, "boots": 0}), increment
boots, save it back, and print "Boot #N". Then blink the pin-5 LED at
blink_ms. Press Ctrl-D in the console a few times and watch the count
climb — persistence across resets, live. Add a button (pin 4) that cycles
blink_ms through 100/250/1000 and saves the config on each change. Then
log every button press to events.csv (uptime ms + new rate) with rotation
at 1 KB, and print the log plus fs_free() at each boot. Finally,
hand-edit config.json in the Wokwi file tab to something invalid and
confirm the program still boots with defaults.