MicroPython for Microcontrollers Module 8 – Files, storage and data formats
Files, safe writes and power cuts
Save settings and logs on a MicroPython board so that a power cut never leaves them half-written: temporary files and rename, checked records and fewer erases.
What you will learn
- Write a configuration file so that a power cut leaves either the old or the new version
- Recover at boot from every state that an interrupted save can leave behind
- Detect torn records in an append-only log with a length and a CRC
- Prove a storage format safe by replaying every point where a cut can stop a save
- Limit flash wear by batching writes, with the erases counted
Before you start
What you’ll learn · Before you start
What you will learn
- Write a configuration file so that a power cut leaves either the old or the new version
- Recover at boot from every state that an interrupted save can leave behind
- Detect torn records in an append-only log with a length and a CRC
- Prove a storage format safe by replaying every point where a cut can stop a save
- Limit flash wear by batching writes, with the erases counted
Before you start
On this page
A Pico saves its settings with open('config.json', 'w'), and one day the sensor it reads during the save stops
answering: the file is left with the first 32 bytes of the new settings, and at the next boot json.loads() raises
ValueError and the board starts from its defaults. On FAT, a power cut during the save can break the file the same
way.
Safe writes come down to two patterns: write a new version of a file beside the old one and rename it into place, and give every record of a log its length and a checksum. Both can be proved by trying every point where power cuts could stop a save, and checking what is left.
A save in place can leave a file half-written
open(path, 'w') empties the file before the first byte of the new settings is written, so files saved this way
pass through states that hold no valid settings. If an error stops the save before its last write, the file keeps
only what was written so far. This program saves twice; during the second save, reading the sensor’s calibration
fails:
"""A configuration saved in place, and what an error halfway through the save leaves behind.
open(path, "w") empties the file at once. When the save stops before its last write,
leaving the with block closes the file, and the part already written is what remains.
"""
import json
DEFAULTS = {"ssid": "", "interval": 60, "offset": 0}
def read_offset(sensor_ok):
"""Stands in for reading the sensor's calibration over I2C."""
if not sensor_ok:
raise OSError(5, "EIO") # the sensor stopped answering
return -12
def save_in_place(path, ssid, interval, sensor_ok=True):
with open(path, "w") as f:
f.write('{"ssid": %s, ' % json.dumps(ssid))
f.write('"interval": %d, ' % interval)
f.write('"offset": %d}' % read_offset(sensor_ok))
def load(path):
try:
with open(path) as f:
return json.loads(f.read())
except OSError:
print(" no file -> defaults")
except ValueError:
print(" not valid JSON -> defaults")
return dict(DEFAULTS)
save_in_place("config.json", "lab", 10)
print("save 1:", load("config.json"))
try:
save_in_place("config.json", "lab", 30, sensor_ok=False)
except OSError as e:
print("save 2 stopped: OSError", e.args[0])
with open("config.json") as f:
print("config.json now holds %r" % f.read())
print("next boot:", load("config.json")) Output
save 1: {'ssid': 'lab', 'interval': 10, 'offset': -12}
save 2 stopped: OSError 5
config.json now holds '{"ssid": "lab", "interval": 30, '
not valid JSON -> defaults
next boot: {'ssid': '', 'interval': 60, 'offset': 0}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 save_in_place.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The with block did what it should and closed the file when the exception left it, and closing is what made those
32 bytes the file. On LittleFS: A small filesystem designed for flash: it writes changes to free blocks, commits them in small checked logs and spreads erases over the blocks. The rp2 and esp32 ports of MicroPython format their flash with LittleFS v2. More in “Filesystems: LittleFS, FAT and vfs.mount”, whose documentation says a file’s changes are committed
only when it is closed or synced, a power cut in the middle of the writes keeps the old version. An error inside the
with block, or a flush() halfway through, commits the half-written file all the same. FAT has no such commit, so
a power cut during the save can leave the file without its settings, or damage the filesystem itself, as
MicroPython’s documentation warns.
Calling flush() to make a save safer
On LittleFS, f.flush() commits the file as it is at that moment, so a flush halfway through a save makes half a
file permanent. Leave the commit to close(), once every byte has been written.
Checkpoint
Check: What a save in place can leave
Write a temporary file, then rename it over the old one
The safe pattern never touches config.json until the new version is complete. Write the new settings to
config.json.tmp, close it, and only then rename it over config.json with os.rename(). LittleFS’s documentation
states that rename is atomic: Done in one step that cannot be seen half-done: after a power cut, an atomic rename has either happened completely or not at all., even if power is lost, so after a cut config.json is the old file or the new one. MicroPython’s FAT driver renames
in two steps when the target exists: it removes config.json, then renames the temporary file, so a cut between them
leaves only config.json.tmp, complete. Step through a save and the boot that follows it:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Write beside it. save() writes the whole new settings to config.json.tmp, in one open() and one close(). config.json is not touched yet, so it still holds the old settings.
- A cut while writing. If power fails now, config.json is whole and old. The temporary file may be empty or half-written, and the next boot deletes it.
- Close the new file. Closing commits config.json.tmp on LittleFS. From here the new settings exist in full, in a file of their own.
- Switch in one step. os.rename() puts the new file in place of the old. LittleFS does it atomically, even if power is lost, so config.json is the old file or the new one. MicroPython's FAT driver first removes config.json, then renames.
- Boot. At the next boot, load() first reads config.json. If it is there and is valid JSON, it is the old version or the new one, never a mix.
- A leftover .tmp. A config.json.tmp beside a good config.json belongs to a save that never reached its rename. load() deletes it and keeps the version that is there.
- The rename was cut. On FAT, a cut between the removal and the rename leaves no config.json, but a whole config.json.tmp. load() reads it and renames it into place, finishing the save.
- Nothing to load. With neither file valid, as on the very first boot, load() returns the defaults, and the app should log it, because on a board in service it means something else went wrong.
The program below is that pattern, with a replay of one save on a model of a filesystem without a commit, which keeps every byte as soon as it is written. It stops the save at every point a cut could, then runs the boot code on what is left:
"""A configuration saved safely: write config.tmp, close it, rename it over config.json.
save() and load() are what a board runs. The replay models a filesystem without a commit,
which applies each operation in order and keeps every byte as soon as it is written: it stops
one save at every point a power cut could, then boots, that is, runs load() on what is left.
"""
import json
import os
DEFAULTS = {"ssid": "", "interval": 60}
def save(cfg, path="config.json"):
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(json.dumps(cfg))
os.rename(tmp, path) # one atomic step on LittleFS
def read(name):
try:
with open(name) as f:
return json.loads(f.read())
except (OSError, ValueError):
return None
def remove(name):
try:
os.remove(name)
except OSError:
pass
def load(path="config.json"):
tmp = path + ".tmp"
cfg = read(path)
if cfg is not None:
remove(tmp) # a save that did not finish: keep the old version
return cfg
cfg = read(tmp)
if cfg is not None:
os.rename(tmp, path) # the save stopped after its write: finish it
return cfg
remove(tmp)
return dict(DEFAULTS)
# ---- the replay ------------------------------------------------------------------
OLD = {"ssid": "lab", "interval": 10}
NEW = {"ssid": "lab", "interval": 30}
J, T = "config.json", "config.json.tmp"
data = json.dumps(NEW)
WAYS = {
"in place": [("open_w", J), ("write", J)],
"tmp, then rename": [("open_w", T), ("write", T), ("rename",)],
"tmp, rename on FAT": [("open_w", T), ("write", T), ("remove", J), ("rename",)],
}
def states(steps):
"""Every state a cut can leave: after each step, and after each byte of a write."""
fs = {J: json.dumps(OLD)}
yield dict(fs)
for step in steps:
if step[0] == "write":
for k in range(len(data)):
yield dict(fs, **{step[1]: data[:k]})
fs[step[1]] = data
elif step[0] == "open_w":
fs[step[1]] = ""
elif step[0] == "remove":
del fs[step[1]]
else: # rename T over J
fs[J] = fs.pop(T)
yield dict(fs)
print("one save cut at every point, then load() at boot")
print("%-20s %6s %4s %4s %7s" % ("", "cuts", "old", "new", "broken"))
for way, steps in WAYS.items():
count = {"old": 0, "new": 0, "broken": 0}
for fs in states(steps):
for name in (J, T):
remove(name)
for name, text in fs.items():
with open(name, "w") as f:
f.write(text)
cfg = load()
count["old" if cfg == OLD else "new" if cfg == NEW else "broken"] += 1
print("%-20s %6d %4d %4d %7d" % (way, sum(count.values()), count["old"], count["new"], count["broken"])) Output
one save cut at every point, then load() at boot
cuts old new broken
in place 34 1 1 32
tmp, then rename 35 34 1 0
tmp, rename on FAT 36 34 2 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 config_store.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every state the pattern can leave, 35 with LittleFS’s rename and 36 with FAT’s, loads the old settings or the new ones, while the save in place breaks 32 of its 34. The new settings win once the rename has run, and on FAT also when only the temporary file is left, which the boot code renames into place.
Key idea
Never write over the only good copy: write the new version beside it, complete it, switch to it in one atomic step, and let the boot code finish or discard a switch that was cut.
Other files need more than this pattern. A log only grows, so each record carries its length and a CRC-32, and the reader stops at a torn record: A record that a power cut or a reset stopped partway through its write, so that only its first bytes reached the storage. A reader recognises it by a length that runs past the end of the data or a checksum that does not match.. A setting kept in raw flash, without any filesystem, needs two blocks that take turns, so that one always holds a whole copy. Each erase wears a flash block a little, so settings saved every second wear the flash out. Batching the writes in RAM cuts the erases. A replay of every cut point, like the one above, proves such designs safe.
Checkpoint
Check: The temporary file and the rename
Check yourself
The questions below cover saves in place and the temporary-file pattern, and explain every choice.
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Logs that stop cleanly at a torn record Pro: opens with a Pro, Premium or Ultimate pass
A log is appended to, never replaced, so the rename pattern does not fit it. Each record below carries its length and a CRC-32, which lets a reader tell a whole record from one a power cut tore, and lets the boot repair the log before it appends again.
In this part:
- A record format with a length and a CRC-32, cut at every byte
- Repair before the next append, or new records are lost
1 diagram · 1 runnable example (Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Proving a save safe by replaying every cut Pro: opens with a Pro, Premium or Ultimate pass
A save is safe when every state a power cut can leave behind loads the old version or the new one. This part builds that check for a setting kept in raw flash, without any filesystem, and uses it to prove an A/B design that never loses a setting.
In this part:
- Where a cut can stop a save in raw flash
- The invariant that makes the A/B store safe
1 diagram · 1 runnable example (Python) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Flash wear, counted: batching writes Pro: opens with a Pro, Premium or Ultimate pass
Every erase wears a block a little, and a board that saves every second erases blocks tens of thousands of times a day. This part counts the erases of four designs, turns them into days of life, and ends with an exercise that builds the two-block log of the count.
In this part:
- Erases per day, four designs counted
- Batching, and what it costs
- Exercise: a two-block log that passes the replay
- A two-block log in flash that survives every power cut
- Results of the sample tests
1 runnable example (Python) · 1 exercise with sample tests · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher · Warm-up
Why does a board write new settings to a temporary file and rename it, instead of rewriting config.json in place?
Opening config.json with 'w' empties it at once, so until the last byte is written the file holds no valid settings. An error during the save, a flush() halfway through or, on FAT, a power cut leaves it incomplete, and the next boot falls back to its defaults. Writing the new version to config.json.tmp leaves the old file untouched until the new one is complete and closed; os.rename() then switches to it, which LittleFS does atomically, even through a power loss. At boot a valid config.json is used and a leftover temporary file deleted; if config.json is missing or broken, a valid temporary file is renamed into place, which also covers FAT, where MicroPython's rename removes the old file first. Every state a cut can leave then loads the old settings or the new ones.
-
Mid to senior
After a site's power outage, 3 of 200 Pico-based devices came back with factory settings: their config.json was empty or half-written. The save uses with open('config.json', 'w') and json.dump(). How could that happen on LittleFS, and what do you change in the code and in the tests?
-
Mid to senior
A Pico logs a 32-byte reading every second by appending it to a file on LittleFS and closing the file each time. Will the flash last the planned five years? How do you estimate it, and what would you change?
-
Fresher to mid
After a power cut, a device's append-only log ends in half a record. Since then it has logged a reading every minute, but the export, which parses the log from the start, gets nothing new from it. What happened, and how do the writer and the reader fix it?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why can open('config.json', 'w') leave a board without valid settings?
It empties the file before the first byte is written, so an error halfway through the save, or a power cut on FAT, leaves only what was written so far.
-
When does LittleFS commit a file's changes?
When the file is closed or synced. A power cut halfway keeps the old version, but a close() after an error, or a flush() halfway, commits the half-written file.
-
What is the safe way to save a settings file?
Write config.json.tmp, close it, then os.rename() it over config.json. LittleFS renames in one atomic step, even through a power cut.
-
How does MicroPython's FAT driver rename over an existing file, and what must the boot code do?
It removes config.json, then renames the temporary file, so a cut between the two leaves only config.json.tmp, complete. The boot code renames a valid .tmp into place.
-
What does the boot code do with config.json and a leftover config.json.tmp?
A valid config.json wins and the .tmp is deleted; a missing or broken config.json is replaced by a valid .tmp; with neither, the board uses its defaults.
-
Why not call flush() in the middle of a save on LittleFS?
flush() commits the file as it is at that moment, so half a file becomes permanent. Leave the commit to close(), once every byte has been written.
-
What does each record of an append-only log on flash carry?
Its length and a CRC-32, so that a reader recognises a torn record left by a cut and stops there.
Key takeaways
open(path, 'w')empties a file at once, so a save stopped by an error, or by a power cut on FAT, leaves it without valid settings, and on LittleFS closing or flushing commits whatever is there.- Safe writes put the new version in
config.json.tmp, close it and rename it overconfig.json, which LittleFS does in one atomic step, even through power cuts. - At boot, a valid
config.jsonwins and a leftover.tmpis deleted; a missing or brokenconfig.jsonis replaced by a valid.tmp, which covers FAT’s two-step rename; with neither, the board uses its defaults.
Terms in this lesson
- atomic
- Done in one step that cannot be seen half-done: after a power cut, an atomic rename has either happened completely or not at all.
- torn record
- A record that a power cut or a reset stopped partway through its write, so that only its first bytes reached the storage. A reader recognises it by a length that runs past the end of the data or a checksum that does not match.
- wear levelling
- Spreading the erases of a flash device over many blocks, so that no block reaches its rated number of erases long before the others. LittleFS does it for the blocks it allocates.
References
- Working with filesystems (MicroPython project)
- os – basic “operating system” services (rename, remove, sync) (MicroPython project)
- binascii – binary/ASCII conversions (crc32) (MicroPython project)
- json – JSON encoding and decoding (MicroPython project)
- struct – pack and unpack primitive data types (MicroPython project)
- littlefs README (power-loss resilience, atomic operations, commits on sync or close) (littlefs project)
- The design of littlefs (DESIGN.md, metadata pairs) (littlefs project)
- littlefs 2.11 as MicroPython 1.29.0 ships it (lib/littlefs/lfs2.h) (MicroPython project)
- The LittleFS driver's configuration (extmod/vfs_lfsx.c, v1.29.0) (MicroPython project)
- LittleFS files in MicroPython (extmod/vfs_lfsx_file.c, v1.29.0) (MicroPython project)
- The FAT driver's rename (extmod/vfs_fat.c, v1.29.0) (MicroPython project)
- The rp2 port's boot script (ports/rp2/modules/_boot.py, v1.29.0) (MicroPython project)
- pico-sdk hardware_flash: flash_range_erase and flash_range_program (Raspberry Pi)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress