November 18 2025
Godot contains multiple ways to serialize data, each with their own positives and negatives. While this is subjective, I hope it gives some context as to which might be right for you.
Godot's built-in JSON support comes in two flavors: static methods and a JSON type.
The static methods are very straightforward to use. Serialize data (any type!) with JSON.stringify(data), including pretty printing, and parse it with JSON.parse_string(string)
print(JSON.stringify(123, " "))
print(JSON.stringify([1, 2, 3], " "))
var typed_array: Array[StringName] = ["1", "2", "3"]
print(JSON.stringify(typed_array, " "))
var array = [1, "2", 3]
print(JSON.stringify(array, " "))
print(JSON.stringify({ "a": "b", "c": "hello", 100: "hi", "nested": { "dictionaries": true } }, " "))
results in
123
[
1,
2,
3
]
[
"1",
"2",
"3"
]
[
1,
"2",
3
]
{
"100": "hi",
"a": "b",
"c": "hello",
"nested": {
"dictionaries": true
}
}
Passing in
" "as the second argument makes it use indentations and newlines. Without it, everything would go on one like with no spacing.
Getting the data back is just as trivial: load the file as a String however you'd like, and pass it to JSON.parse_string. It's a "loose" (e.g. noncompliant) parser that allows trailing commas, but no comments.
As you can see, it doesn't yield the typed array literals that ConfigFile used. This is because those aren't valid JSON, and would make it unparsable by other implementations.
Speaking of "unparsable by other implementations", Godot used to have some "bad" behavior regarding
INFandNAN. Since they get stringified as plain string literals ("inf" and "nan") which are not valid JSON literals, nothing can read the JSON strings that are produced. Not even Godot.print(JSON.stringify({ "INF": INF })) print(JSON.stringify({ "NAN": NAN }))yields
{"INF":inf} {"NAN":nan}This was an incredibly strange bug which causes
JSON.parse_string(JSON.stringify(NAN))to returnnull(e.g. invalid JSON) instead ofNAN.A fix was implemented with #108837, but due to things happening (see this comment on #111496) it was reverted quickly due to a consensus not actually having been reached. It was then resubmitted on October 10th and remerged on October 30th with #111498, and is now fixed for 4.6-dev3. Maybe it'll get backported to a patch version of 4.5.
With this fix merged,
INFis stringified as a large float (1e99999) andNANis stringified asnull, matching other implementations.
Believe it or not (I didn't know before writing this!), JSON inherits from Resource. Any JSON files in the resource filesystem will actually get loaded into the engine as a JSON-typed object. You can get the data out of it with JSON.data and use JSON.parse to parse a new string into it. Beyond that, it's not very noteworthy.
Overall, JSON is a good pick for most serialization needs. It's easy to manipulate with tools like jq, many languages either have built-in support for JSON or very good third-party libraries, and it's very simple (looking at you YAML). This simplicity comes at a cost of some niceties, such as a lack of comments, no trailing commas on serialization (this one actually makes me so mad), and keys can only be strings. All of these things make it more annoying to human-edit.
ConfigFile is Godot's INI-style data serialization method. Its API is simple to use but somewhat lacking. Unlike JSON, it doesn't store structured objects like a Dictionary directly. Rather, each file is split into [section]s containing any number of key=value pairs. For example, this code
var config := ConfigFile.new()
config.set_value("a section", "a value", "hi")
generates this file:
[a section]
"a value"="hi"
Without any whitespace, escaped quotes, or = in the key, it generates without quotes:
a-value="hi"
You can also insert an entire Dictionary or Array as the value.
var typed_dict: Dictionary[String, int] = {
"hi": 1,
"hello": 2,
}
var packed_array: PackedInt32Array = [1, 2, 3]
var typed_array: Array[StringName] = ["1", "2", "3"]
var untyped_dict: Dictionary = {
"a": "b",
5: 10,
}
var untyped_array: Array = [1, "2", 3]
config.set_value("", "typed_dict", typed_dict)
config.set_value("", "packed_array", packed_array)
config.set_value("", "typed_array", typed_array)
config.set_value("untyped", "untyped_dict", untyped_dict)
config.set_value("untyped", "untyped_array", untyped_array)
typed_dict=Dictionary[String, int]({
"hello": 2,
"hi": 1
})
packed_array=PackedInt32Array(1, 2, 3)
typed_array=Array[StringName]([&"1", &"2", &"3"])
[untyped]
untyped_dict={
5: 10,
"a": "b"
}
untyped_array=[1, "2", 3]
Like Resources and unlike JSON, it uses the packed array (PackedInt32Array(1, 2, 3)), typed array (Array[StringName]([&"1", &"2", &"3"])), and dictionary literals (Dictionary[String, int({"hello": 2, "hi": 1 })) where a typed value is provided. Also, using an empty string as the section name inserts the key into the top level.
To save the ConfigFile's data to disk, you can use ConfigFile.save or one of its encrypted variants. While having this built-in encryption is nice, you can't save ConfigFiles to strings, only directly to a file, which limits its use cases. Of course, it's possible to make, save it to, and read it from a temp file but that's quite a bit more work. The built-in encrypted save methods are overshadowed by FileAccess.open_encrypted, which does effectively the same thing while being more flexible and much simpler.
To get data back out of the serialized file, you can use ConfigFile.load or one the encrypted variant that matches how you saved it. Again with the encryption, FileAccess.open_encrypted is basically a superset of ConfigFile's encryption facilities. There's also ConfigFile.parse, which merges the values of the given string into the ConfigFile object.
I think ConfigFiles an interface best suited for serializing a bunch of ad-hoc values. It has an immediate-mode API that seems nice on the surface, but becomes clunky once your serialization code is spread further across more types and functions. My general way of serializing data is to make functions that return Dictionaries, which I then merge together in more dictionaries and arrays, building up the data as I go and ConfigFile doesn't really support that. However, INI files are very easy to user-edit. The section-key-value layout is easy to visually parse and doesn't require you to track indentation. In addition, ConfigFile supports all Variant types, unlike JSON.
Fun fact: the
project.godotfile uses a ConfigFile
The secret method Big Godot doesn't want you knowing about.
Resources are Godot's primary data serialization format. Most things in the filesystem are resources or get imported as resources, like how a JSON file gets imported to a JSON Resource. However, things like scenes, themes, and curves are Resources rather than just being imported as resources. When you save one of those to the resource filesystem, it results in (normally) a .tres file containing all the data stored inside the resource object.
While Godot comes with many built-in resource types, you can make your own simply by extending the Resource class:
extends Resource
@export var property: String
By giving it a class name to make it a global class, it also shows up in the create resource dialogs:
class_name MyCustomResource extends Resource
@export var property: String
Like Node, you can use the different @export annotations to control how properties are displayed in the inspector. This gives Resources the unique attribute that not only can you serialize them to disk, Godot will auto-generate an editor for your resource from its script as well. You can even make it a tool script and implement custom validation logic:
@tool
class_name MyCustomResource extends Resource
@export var property: String:
set(value):
if not value.begins_with("h"):
return
property = value
Like Nodes,
@export_storagegives you a serialized but hidden property.
Like ConfigFile and unlike JSON, Resources can serialize any type of Variant including other Resources.
To save a resource, you can use ResourceSaver.save(resource, path = "", flags = 0). path can be a resource filesystem (e.g. res://) path, which really only works for editors, or it can be an absolute path (either absolute absolute or user:// path), or it can be empty (the default) to make resource get saved to wherever it was saved before. Saving a resource with a .tres file extension causes it to get saved in a text format, and .res makes it get saved in a binary format.
To load a resource, you can use load(path). While it's normally used on res:// paths, it can also be used to load external files.
When you deserialize a custom resource, you get your custom resource type back, making accessing properties completely type safe:
var resource := MyCustomResource.new()
resource.propery = "hello"
ResourceSaver.save("/home/fish/my_custom_resource.tres", resource)
# later...
var resource := load("/home/fish/my_custom_resource.tres") as MyCustomResource
print(resource.property)
I'm not sure why there isn't a simple
save(resource, path)counterpart toload(path)
A Resource's safety makes it an incredibly powerful tool for data serialization. Almost all Godot constructs are supported by it, and you can choose between a verbose text format and a compact binary format, making it very versatile. However, you can only save resources to file paths, because each resource needs to "know" where it's serialized to to correctly serialize subresources. If you're concerned about portability, JSON is definitely better.
Resource's text format, while not meant to be easily human editable, can be patched by hand. Its binary format on the other hand, is not easy to modify externally at all. If you're thinking about using this to add security to your save files, don't. People will reverse engineer your formats anyway, especially because Godot's binary resource format's source can be easily found and reverse engineered.
I like this function. It retains most of the good parts of JSON while sidestepping all the bad parts.
As the name implies, it takes one Variant and converts it to a string. For example,
print(var_to_str(123))
print(var_to_str([1, 2, 3]))
var typed_array: Array[StringName] = ["1", "2", "3"]
print(var_to_str(typed_array))
var array = [1, "2", 3]
print(var_to_str(array))
print(var_to_str({ "a": "b", "c": "hello", 100: "hi", "nested": { "dictionaries": true } }))
results in
123
[1, 2, 3]
Array[StringName]([&"1", &"2", &"3"])
[1, "2", 3]
{
100: "hi",
"a": "b",
"c": "hello",
"nested": {
"dictionaries": true
}
}
As you can see, it retains the typed literals of ConfigFile (they use the same system!), but is as simple and no-frills as JSON. If you want to serialize an array, just pass it an array. If you want to serialize some key/value pairs, just pass it a dictionary. And unlike JSON, it supports all types in Godot, even non-string map keys.
To serialize a Variant to a string, use var_to_str(variant). If you want to convert a string to a Variant, use str_to_var(string). It's as simple as JSON, but much more powerful.
var_to_bytes(variant) and bytes_to_var(variant) work the same as their string siblings, but use a PackedByteArray instead of a String. The returned value is much more compact, using the binary format specified in "Binary serialization API". Again, it's not a good security practice to rely on the fact that it's a binary format to avoid reverse engineering. var_to_bytes_with_objects(variant) and bytes_to_var_with_objects(bytes) are effectively the same, but they serialize scripts as well. This can create security issues as embedding scripts allow them to execute arbitrary code on deserialization. I'd just avoid these methods.
If I'm looking for an easy way to serialize lots of data, I normally reach for var_to_bytes(variant).
There are even more options than explored here. For example, you could use a SQLite database for high-performance queries. You could use YAML or TOML for more exotic markup languages. You could write your own if the standard library's offerings didn't meet your requirements.
The one that's best depends entirely on your needs.
For sunfish, I use var_to_bytes(variant) in combination with zstd compression to save and load the project files, since it's so much simpler conceptually and practically to use.