Quick answer: The most common cause is saving to res:// instead of user://. The res:// path points to the project directory and is read-only in exported builds. Always use user:// for save data, which maps to a writable, persistent directory specific to your game on every platform.

Here is how to fix Godot save load game data not persisting. Your save system works fine in the editor, but the moment you export the game, all save data vanishes between sessions. Or worse, it seems to save but loads back default values. This is one of the most common and most frustrating issues in Godot 4 development. The root cause is almost always a path problem, a serialization mistake, or a platform-specific storage behavior. This guide walks through every cause and its fix.

Understanding user:// vs res:// Paths

The number one cause of save data not persisting is writing to res:// instead of user://. In the Godot editor, res:// points to your project directory on disk, and it is fully writable. This makes it seem like saving to res://save_data.json works perfectly during development. But when you export the game, res:// gets packed into the PCK file and becomes read-only.

The user:// path is the correct location for all runtime data. It maps to a persistent, writable directory that varies by platform:

Windows: %APPDATA%\Godot\app_userdata\YourProject\ (or the custom path set in Project Settings)

macOS: ~/Library/Application Support/Godot/app_userdata/YourProject/

Linux: ~/.local/share/godot/app_userdata/YourProject/

Android: Internal app storage (not accessible without root)

iOS: The app’s Documents directory

You can customize the user directory name in Project Settings → Application → Config → Custom User Dir Name. This is important for production games because it controls where save files live on the player’s machine.

# WRONG — works in editor, fails in export
var save_path := "res://save_game.json"

# CORRECT — works everywhere
var save_path := "user://save_game.json"

The Godot 4 FileAccess API

Godot 4 replaced the old File class with a static FileAccess API. If you are following a Godot 3 tutorial, the syntax will be wrong and may fail silently. Here is the correct Godot 4 pattern for saving and loading:

const SAVE_PATH := "user://save_game.json"

func save_game(data: Dictionary) -> void:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Cannot open save file: %s" % FileAccess.get_open_error())
        return
    var json_string := JSON.stringify(data, "  ")
    file.store_string(json_string)
    # File is closed automatically when the reference goes out of scope

func load_game() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        print("No save file found, using defaults")
        return {}
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        push_error("Cannot read save file: %s" % FileAccess.get_open_error())
        return {}
    var json_string := file.get_as_text()
    var data = JSON.parse_string(json_string)
    if data == null:
        push_error("Failed to parse save file JSON")
        return {}
    return data

The most critical mistake is not checking for null after FileAccess.open(). If the open fails (wrong path, permissions, disk full), the method returns null and any subsequent call on the file handle will crash with a null reference error. Always check and use FileAccess.get_open_error() for diagnostics.

JSON Serialization Pitfalls

JSON is the most portable serialization format for save data, but it only supports basic types: strings, numbers, booleans, arrays, and objects. If your game state includes Godot-specific types like Vector2, Color, or Resource references, JSON.stringify() will either skip them or convert them incorrectly.

# This dictionary contains types JSON cannot handle directly
var game_state := {
    "player_name": "Hero",
    "position": Vector2(100, 200),   # Not JSON-native
    "color": Color.RED,               # Not JSON-native
    "health": 85,
    "inventory": ["sword", "shield"]
}

# Option A: Convert Godot types to JSON-safe representations
func serialize_state(state: Dictionary) -> Dictionary:
    var safe := state.duplicate()
    if safe.has("position"):
        var pos: Vector2 = safe["position"]
        safe["position"] = {"x": pos.x, "y": pos.y}
    if safe.has("color"):
        safe["color"] = safe["color"].to_html()
    return safe

# Option B: Use var_to_str for Godot-native serialization
func save_with_var_to_str(state: Dictionary) -> void:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    file.store_string(var_to_str(state))

func load_with_str_to_var() -> Dictionary:
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    return str_to_var(file.get_as_text())

The var_to_str() and str_to_var() pair preserves Godot types but produces a format that is not standard JSON—it is Godot-specific. Use this approach if you only need to read the save file in Godot. Use JSON if you need the save data to be portable or human-readable.

A safer middle ground is to use JSON.stringify() but write explicit serialization and deserialization functions for each Godot type in your save state. This keeps the save file portable while handling complex types correctly.

Using ConfigFile for Settings

For simple key-value data like player settings (audio volume, graphics quality, keybindings), Godot’s ConfigFile class is often a better choice than raw JSON. It handles type serialization automatically and supports sections:

const SETTINGS_PATH := "user://settings.cfg"

func save_settings() -> void:
    var config := ConfigFile.new()
    config.set_value("audio", "master_volume", 0.8)
    config.set_value("audio", "music_volume", 0.6)
    config.set_value("video", "fullscreen", true)
    config.set_value("video", "resolution", Vector2i(1920, 1080))
    var err := config.save(SETTINGS_PATH)
    if err != OK:
        push_error("Failed to save settings: %d" % err)

func load_settings() -> void:
    var config := ConfigFile.new()
    var err := config.load(SETTINGS_PATH)
    if err != OK:
        print("No settings file, using defaults")
        return
    var master_vol: float = config.get_value("audio", "master_volume", 1.0)
    var fullscreen: bool = config.get_value("video", "fullscreen", false)
    # Apply settings...

ConfigFile natively supports Vector2, Vector2i, Color, and other Godot types. The third argument to get_value() is a default value returned when the key is missing, which makes it safe to add new settings in updates without breaking old save files.

Export vs Editor Path Differences

Beyond the res:// vs user:// issue, there are subtler path problems that only appear in exported builds:

Case sensitivity: Windows is case-insensitive, but Linux and exported PCK files are case-sensitive. If your code opens user://SaveGame.json but saves to user://savegame.json, it will work on Windows but fail on Linux.

Directory creation: The user:// base directory exists automatically, but subdirectories do not. If you try to save to user://saves/slot1.json without creating the saves directory first, the write will fail silently.

# Always create directories before writing to subdirectories
func ensure_save_directory() -> void:
    var dir := DirAccess.open("user://")
    if not dir.dir_exists("saves"):
        dir.make_dir("saves")

func save_to_slot(slot: int, data: Dictionary) -> void:
    ensure_save_directory()
    var path := "user://saves/slot_%d.json" % slot
    var file := FileAccess.open(path, FileAccess.WRITE)
    if file == null:
        push_error("Cannot write to %s: %s" % [path, FileAccess.get_open_error()])
        return
    file.store_string(JSON.stringify(data))

Mobile storage: On Android and iOS, storage behaves differently. Android may clear app data when the user clears the app cache. iOS backs up the Documents directory to iCloud by default. For critical save data on mobile, consider implementing a cloud save backup using an HTTP API.

“The number one rule of save systems in Godot: if it works in the editor but not in the export, you are writing to res:// somewhere. Search your entire project for res:// in any save-related code and replace it with user://.”

Debugging Save/Load Issues

When save data is not persisting and you cannot figure out why, add comprehensive logging to narrow down the problem:

func save_game_debug(data: Dictionary) -> void:
    var path := "user://save_game.json"
    print("[Save] Attempting to save to: ", path)
    print("[Save] Resolved path: ", ProjectSettings.globalize_path(path))
    print("[Save] Data keys: ", data.keys())

    var file := FileAccess.open(path, FileAccess.WRITE)
    if file == null:
        push_error("[Save] FAILED — error: %s" % FileAccess.get_open_error())
        return

    var json_string := JSON.stringify(data, "  ")
    print("[Save] JSON length: ", json_string.length(), " characters")
    file.store_string(json_string)
    print("[Save] Write complete")

    # Verify the file was written correctly
    var verify := FileAccess.open(path, FileAccess.READ)
    if verify != null:
        print("[Save] Verification — file size: ", verify.get_length(), " bytes")
    else:
        push_error("[Save] Verification FAILED — cannot reopen file")

The ProjectSettings.globalize_path() call is particularly useful because it shows you the actual filesystem path where the file is being written. You can then navigate to that directory on your system to verify the file exists and inspect its contents.

Another common debugging technique is to print the loaded data immediately after reading it, before your game processes it. This catches cases where the file loads correctly but your game logic overwrites the loaded values with defaults during initialization.

Related Issues

If your HTTP-based cloud save system is not completing requests, see Fix: Godot HTTPRequest Not Completing or Timing Out. For exported builds missing resources entirely, check Fix: Godot Exported Game Missing Resources. For tracking save-related bugs reported by players, read Bug Reporting Tools for Godot Developers.

Always use user:// for save data, always check for null after FileAccess.open(), and always test your save system in an actual exported build before shipping. The editor hides path problems that will break things for players.