Saltmire
Practical Godot 4 tutorials — game feel, saving, and shipping polish fast.

"Fixing Godot 4 save-file load errors: JSON parse error, null instance, and missing files"

You shipped a save system, it worked on your machine, and now the console is red on load. Save/load bugs are nasty because they only show up after a write already happened — the bad data is already on disk. Here are the three errors that break Godot 4 saves most often, why each one happens, and the exact fix.

1. JSON Parse Error: ... at line N

Symptom. On load you get something like:

JSON Parse Error: Unexpected end of stream at line 1

or a parse error pointing at a spot in the middle of your file.

Cause. The file was written partially. This almost always means the game crashed (or was force-quit) during the write, or two systems wrote to the same file at once. FileAccess flushes on close(); if you never reach close(), you get a truncated, unparseable file.

Fix. Never write in place. Write to a temp file, then rename it over the real one — rename is atomic on every desktop OS, so the save is either the old file or the complete new one, never a half of each:

func save_state(data: Dictionary, path := "user://save.json") -> void:
    var tmp := path + ".tmp"
    var f := FileAccess.open(tmp, FileAccess.WRITE)
    if f == null:
        push_error("Save failed to open %s: %d" % [tmp, FileAccess.get_open_error()])
        return
    f.store_string(JSON.stringify(data))
    f.close()  # flush BEFORE the rename
    DirAccess.rename_absolute(ProjectSettings.globalize_path(tmp),
                              ProjectSettings.globalize_path(path))

And always guard the read instead of trusting JSON.parse_string:

func load_state(path := "user://save.json") -> Dictionary:
    if not FileAccess.file_exists(path):
        return {}
    var text := FileAccess.get_file_as_string(path)
    var parsed = JSON.parse_string(text)
    if parsed == null or typeof(parsed) != TYPE_DICTIONARY:
        push_warning("Save at %s is corrupt — falling back to empty." % path)
        return {}
    return parsed

2. Invalid call ... Attempt to call ... on a null instance

Symptom.

Invalid call. Nonexistent function 'get' in base 'Nil'.

right after you read the save back.

Cause. JSON.parse_string returns null on failure, and you used the result without checking. It also cannot return your original types — a Vector2 you stored becomes a plain array or null, a custom object becomes a Dictionary. So saved.player_pos.x blows up because player_pos is not a Vector2 anymore.

Fix. Serialize non-JSON types explicitly on the way out, and rebuild them on the way in. Don't assume the shape survived:

# on save
data["player_pos"] = {"x": player.position.x, "y": player.position.y}

# on load
var d: Dictionary = load_state()
if d.has("player_pos"):
    var p = d["player_pos"]
    player.position = Vector2(p.get("x", 0.0), p.get("y", 0.0))

Every read from the loaded dictionary should use .get(key, default) so a missing key gives a sane value instead of a Nil.

3. Error opening file / values silently wrong after an update

Symptom. Either the file just isn't there on a fresh install, or a returning player loads an old save and half their state is missing after you added fields.

Cause. Two separate issues wearing the same coat: (a) you read before any save exists, and (b) the save's schema changed between versions and old files don't have the new keys.

Fix. Version your saves and migrate forward. Stamp a version number on write, and run migrations on read so an old file becomes a valid current one:

const SAVE_VERSION := 3

func migrate(d: Dictionary) -> Dictionary:
    var v := int(d.get("version", 1))
    if v < 2:
        d["settings"] = d.get("settings", {"volume": 1.0})  # added in v2
    if v < 3:
        d["playtime"] = d.get("playtime", 0.0)              # added in v3
    d["version"] = SAVE_VERSION
    return d

Now old saves keep loading instead of erroring, and new fields get safe defaults.

The pattern behind all three

Every one of these bugs is the same root cause: treating the disk as trustworthy. It isn't. A robust save layer writes atomically, validates on read, defaults every missing key, and migrates old schemas — none of which is hard, but all of which is easy to forget until a player loses a save. If you'd rather not re-derive the atomic write, corruption guard, backup fallback, and schema migration in every project, that's exactly the boring, tested wiring Saltmire Save is.

Built this the long way once too many times. Saltmire Save does it as a drop-in tool: https://saltmire.itch.io/saltmire-save