Atomic save files in Godot 4, so a crash can't corrupt them

A power drop mid-write can leave a Godot 4 save half-written and unloadable. Fix the corruption with a temp file plus an atomic rename on the Lite core.

The player hits save at a checkpoint, the write starts, and half a second in the laptop battery gives out or the game hard-crashes on some unrelated null. Now the save file on disk is whatever bytes made it out before the process died. Maybe that's valid JSON that happens to be missing the second half of the inventory. More likely it's a truncated string that ends mid-key, and the next time the player boots, the loader chokes on it. Their save is gone. Worse, it took their last good save with it, because you wrote straight over the file that used to be fine. That is the Godot 4 save file corruption this post exists to kill.

The Lite core does not protect you from this failure, and the docs say so up front. Here is SaveLite.save() as it actually ships in addons/save_load_lite/save_lite.gd:

func save(path: String = DEFAULT_PATH) -> bool:
	var data: Dictionary = {
		"version": 1,
		"nodes": {},
	}
	for node in get_tree().get_nodes_in_group(CONTRACT_GROUP):
		if not node.has_method("save_state") or not node.has_method("get_save_id"):
			continue
		var id := String(node.get_save_id())
		var state = node.save_state()
		if state is Dictionary:
			data.nodes[id] = state
	var f := FileAccess.open(path, FileAccess.WRITE)
	if f == null:
		save_failed.emit("cannot_open: %s" % path)
		return false
	f.store_string(JSON.stringify(data, "  "))
	f.close()
	save_completed.emit(path)
	return true

Look at what FileAccess.open(path, FileAccess.WRITE) does the instant it succeeds. It truncates the file at path to zero length. Your old, good, complete save is destroyed on line one, before a single byte of the new one is written. Everything between that open and the close is a window where the file on disk is neither the old save nor the new one. It's nothing, or it's half of something. Crash inside that window and you've got the corruption.

The fix is old and boring and completely reliable. You never write over the live file. You write the new save to a temporary file next to it, get that temp file fully flushed to disk, then rename it over the real path. The rename is the whole trick: on every OS Godot targets, renaming a file onto an existing name is atomic. Either the directory entry points at the old file or it points at the new one, and there's no observable instant where it points at half a file. If power drops during the rename, you still have a complete file at the end of it, just possibly the old complete one.

Write to a sibling, then rename over the target

The Lite save() returns true on success and emits save_completed, so the cleanest way to add atomicity without forking the addon is to wrap it. Point the wrapper at a temp path, let the core do its normal write there, and if that came back true, swap it into place. Keep the temp file in the same directory as the target, because a rename is only atomic within a single filesystem. Drop the temp in a global scratch folder and you might land on a different volume, at which point the OS does a copy-then-delete under the hood and your atomicity is gone.

# AtomicSave.gd  --  wraps SaveLite so a crash mid-write can't corrupt the slot.
extends Node

func save_atomic(path: String = "user://save.json") -> bool:
	var tmp := path + ".tmp"        # same directory, same filesystem

	# Let the Lite core do its normal write, but into the temp file.
	if not SaveLite.save(tmp):
		# save() already emitted save_failed. The real file is untouched.
		return false

	# Temp file is complete on disk. Now swap it into place atomically.
	var err := DirAccess.rename_absolute(tmp, path)
	if err != OK:
		DirAccess.remove_absolute(tmp)   # don't leave a stray .tmp behind
		push_error("atomic rename failed: %d" % err)
		return false
	return true

That's the core of it. SaveLite.save(tmp) runs the exact same serialization the addon always runs. It just aims at save.json.tmp instead of save.json. If it fails partway, it failed on the temp file, and the real save.json next to it is still the last good save, byte for byte. DirAccess.rename_absolute is the atomic swap. The old file gets replaced in a single filesystem operation, and a reader either sees the whole old save or the whole new one.

So for the version you actually ship, don't lean on the Lite save() for the temp write. Do the temp write directly, where you control the flush, and only reuse the core for gathering state. The save_lite.gd serialization is a dozen lines and you can mirror it: walk the save_load_contract_lite group, collect each node's save_state() into a dictionary, and stringify. Here's the temp write with the flush made explicit:

func _write_temp(tmp: String, payload: Dictionary) -> bool:
	var f := FileAccess.open(tmp, FileAccess.WRITE)
	if f == null:
		return false
	f.store_string(JSON.stringify(payload, "  "))
	f.flush()   # force the OS to push it toward disk before we rename
	f.close()
	return true

flush() is the line the Lite core leaves out, and it's the difference between "probably fine" and "correct." It doesn't guarantee the platter is written on every platform, but it closes the ordinary buffering window, which is the one you'll actually hit.

Keep the last good save as a backup

The rename gets you a save that's never half-written. It does not get you a save that's never wrong. Serialization can produce a file that's perfectly valid JSON and still garbage, because you introduced a bug in some node's save_state(), or a schema change slipped through and now the load path can't make sense of it. The atomic write faithfully preserves your mistake. So keep one generation of history: before the rename, move the current good file aside to a .bak.

func save_atomic(path: String = "user://save.json") -> bool:
	var tmp := path + ".tmp"
	var bak := path + ".bak"

	var payload := _collect_state()
	if not _write_temp(tmp, payload):
		return false

	# Roll the current good save into .bak before we overwrite it.
	if FileAccess.file_exists(path):
		DirAccess.remove_absolute(bak)          # clear the old backup first
		DirAccess.rename_absolute(path, bak)    # old save becomes the backup

	var err := DirAccess.rename_absolute(tmp, path)
	if err != OK:
		DirAccess.remove_absolute(tmp)
		return false
	return true


func _collect_state() -> Dictionary:
	var data := {"version": 1, "nodes": {}}
	for node in get_tree().get_nodes_in_group(SaveLite.CONTRACT_GROUP):
		if node.has_method("save_state") and node.has_method("get_save_id"):
			var state = node.save_state()
			if state is Dictionary:
				data.nodes[String(node.get_save_id())] = state
	return data

Now there are two renames and both are atomic, so the sequence is crash-safe at every step. If you die after moving save.json to .bak but before the temp is renamed into place, boot-time recovery finds no save.json and a valid .bak, and you promote the backup. If you die during the final rename, either the old file is still there under .bak or the new one has fully landed. There's no ordering of the crash that leaves you with zero readable saves.

One deliberately annoying detail: DirAccess.rename_absolute will not clobber an existing destination on every platform, which is why the code removes the stale .bak before rolling the current save into it. On Windows especially, renaming onto a name that already exists can just fail. Removing first costs you a hair of atomicity on the backup slot, but the backup is the disposable copy. The file that has to stay intact is the real save, and the final rename onto it is the one that matters. The order is chosen so the irreplaceable file is never the one at risk during a non-atomic step.

Recovering on load

The Lite loader already fails cleanly instead of crashing. When it hits a truncated or malformed file, SaveLite.load() emits load_failed with a reason string that begins with malformed_json, and it returns false rather than throwing. That's the hook you need. Try the real save. If the load reports failure, fall back to the backup. Since your writer keeps a .bak, the fallback almost always has something valid in it.

func load_with_recovery(path: String = "user://save.json") -> bool:
	if SaveLite.has_save(path) and SaveLite.load(path):
		return true

	# Real save missing or malformed. Try the backup.
	var bak := path + ".bak"
	if SaveLite.has_save(bak) and SaveLite.load(bak):
		push_warning("primary save unreadable, recovered from backup")
		# Promote the backup so next save history stays sane.
		DirAccess.copy_absolute(bak, path)
		return true
	return false

Wire that up and connect SaveLite.load_failed to a logger during development so you can see which path tripped and why. The addon emits a reason string prefixed by the failure kind. A not_found prefix means the file was missing altogether, while a malformed_json prefix means it was present but broken, and each one wants a different response. A missing primary with a good backup is a clean recovery. A malformed primary and a malformed backup means your serialization is producing bad data on the happy path, and no amount of atomic renaming will save you from that. That's a bug to go fix in whatever node's save_state() is lying.

That's the whole pattern, and none of it needs anything the Lite tier can't reach. Write beside the target, flush, rename over it, and keep the displaced file as a backup you can fall back to. The atomic rename is doing the heavy lifting, and it's a single DirAccess.rename_absolute call. Everything else is just being careful about the order you do things in, so that whichever instant the power picks to die, there's always one complete, readable save on disk.

Or use mine

Save/Load Lite, free on GitHub · Save/Load System, $11.24 (25% off)

All Godot posts · Home