file = FileAccess.open("user://save_01.json", FileAccess.WRITE)
file.store_string(JSON.stringify(data))
Enter fullscreen mode Exit fullscreen mode
Where it bites: JSON does not natively support Godot types. `Vector2(10, 20)` becomes `[10, 20]` and you write conversion code by hand both ways. Miss one type and the load silently produces an `Array`, not a `Vector2`, and your character spawns at `null.x`.
Use JSON when:
- You need humans (modders, QA, designers) to read the save file
- Save data is mostly primitives (strings, numbers, arrays)
- You are syncing with a web backend that speaks JSON anyway
## [](#3-binary-serialization-with-raw-storevar-endraw-raw-getvar-endraw-)3\. Binary serialization with `store_var` / `get_var`
Most overlooked native option. [`FileAccess.store_var()` and `FileAccess.get_var()`](https://docs.godotengine.org/en/stable/tutorials/io/binary_serialization_api.html) write Variant types directly to a binary file. Vector2 stays Vector2. Dictionary stays Dictionary. No conversion code.
var file = FileAccess.open("user://save_01.dat", FileAccess.WRITE)
file.store_var({"hp": 80, "pos": Vector2(10, 20)})
Enter fullscreen mode Exit fullscreen mode
The docs note this format is "secure by default because it prevents saving and loading objects, which are what enable code execution in Godot." That security comes from refusing to deserialize objects, which is also its limit: you cannot save scene references or class instances directly.
Where it bites: binary files cannot be diffed in version control. If your save data is config-like and you need PR review, this is the wrong tool. Use it for hot-path saves where speed matters and you do not need humans reading the file.
## [](#4-custom-resources-resourcesaver)4\. Custom Resources + ResourceSaver
The pattern Godot itself wants you to use. Define a `Resource` subclass with `@export` properties for every saved field, populate an instance, and call `ResourceSaver.save()`. [GDQuest's resource save guide](https://www.gdquest.com/library/save_game_godot4/) calls this "the most concise method with full type safety."
class_name SaveData extends Resource
@export var hp: int = 100
@export var pos: Vector2 = Vector2.ZERO
@export var inventory: Array[String] = []
Enter fullscreen mode Exit fullscreen mode
var save = SaveData.new()
save.hp = 80
save.pos = player.position
ResourceSaver.save(save, "user://save_01.tres")
var loaded: SaveData = load("user://save_01.tres")
Enter fullscreen mode Exit fullscreen mode
Static typing. Code completion. Automatic conversion. Most importantly, you can save the same Resource class to either `.tres` (text, diffable) or `.res` (binary, smaller) by changing the extension.
Where it bites: schema migration. Adding a field is fine, but renaming or removing fields breaks existing saves. You either keep the old field around forever or write migration code. [Godot's official saving docs](https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html) flag this as the trade-off you accept in exchange for type safety.
## [](#5-the-hybrid-pattern-resource-for-state-configfile-for-settings)5\. The hybrid pattern: Resource for state, ConfigFile for settings
This is what shipping games actually do. I have not seen a single non-trivial Godot game use just one of the four patterns above. The pattern:
- `user://settings.cfg` (ConfigFile) for audio, controls, display
- `user://save_01.tres` (custom Resource) for game state
- Optional: `user://stats.json` (JSON) for analytics or stuff you want to inspect manually
[Slay the Spire 2](https://godotengine.org/showcase/slay-the-spire-2/) saves to the standard Godot user folder, with [run state and persistent unlocks in separate files](https://www.xmodhub.com/info/xmod-blog/slay-the-spire-2-save-file-location/) so a run crash does not nuke your meta-progress. Splitting state by lifecycle (per-run, per-profile, per-install) is the actual lesson, not the format choice.
## [](#what-ai-assistants-get-wrong-here)What AI assistants get wrong here
This is a 2026 problem: ask ChatGPT or Claude for a Godot 4 save system and you will almost always get one of three patterns:
1. **`func _save():` with `var file = File.new()`.** That is Godot 3 syntax. `File` was removed in 4.0. The replacement is `FileAccess.open()` as a static call.
2. **JSON with manual Vector serialization but no `@export` annotation suggestion.** Generic models do not know that `@export` on a Resource subclass is the modern path.
3. **`store_line()` for save data.** That works for plain text but loses every Godot type. It also encourages the JSON-with-manual-conversion antipattern.
This is the same drift I covered in my earlier post on [Godot 4 API calls AI assistants still get wrong](https://dev.to/ziva). The fix is the same: use a Godot-aware tool that reads your project's `project.godot` and knows which Godot version you are actually on. Tools like [Ziva](https://ziva.sh/) inject the current Godot docs into the model at inference time, which is how you avoid the `File.new()` rabbit hole on a 4.7 project.
## [](#quick-decision-table)Quick decision table
Pattern
Use for
Avoid for
ConfigFile
Settings, keybinds
Game state
JSON
Modder-editable saves, web sync
Type-heavy state
Binary `store_var`
Fast saves, big state
Files you need to diff
Custom Resource
Type-safe game state
Schema-volatile data
Hybrid
Real shipped games
Tiny prototypes
If you are starting a new Godot 4 project today, default to **Custom Resources for state and ConfigFile for settings**. Migrate the analytics/debug stuff to JSON only if you actually need to read it by hand.
The cost of getting this wrong is not "your save file is ugly." It is "you ship a patch in month six and 5% of your players post one-star Steam reviews because their save vanished." Pick the pattern that survives that month-six rewrite.