Using tables in Godot
Sun Jun 07 2026
If you have a collection of things, and those things have properties, a good way to document them is by putting them in a table. Among the tables authored using a computer, one of the more human-friendly is the CSV—it's plain text, so it's version control friendly and can be opened in not just spreadsheet processors but also any text editor. It's just Comma Separated Values.
Here's a list of various water creatures with two additional columns, rated using a very refined ten-point rating system:
name,why I like them,rating
shark,badass,8
dolphin,really smart,9
whale,big,7
salmon,tasty,6
flounder,really tasty (when smoked),7
piranha,scary,5
I made this in LibreOffice Calc, but I could have very well written it by hand. And we can just open this up and read it in Godot:
var file: FileAccess = FileAccess.open("res://water-being-list.csv", FileAccess.READ)
while not file.eof_reached():
prints(file.get_csv_line())
file.close()
and that'll produce the following output:
["name", "why I like them", "rating"]
["shark", "badass", "8"]
["dolphin", "really smart", "9"]
["whale", "big", "7"]
["salmon", "tasty", "6"]
["flounder", "really tasty (when smoked)", "7"]
["piranha", "scary", "5"]
[""]
Basically, each line is a PackedStringArray, which indicates the only downside of using CSV in comparison to something like JSON is that everything is a string, so we have to perform data deserialization ourselves, e.g.:
class WaterBeing:
var name: String
var reason: String
var rating: int
var file: FileAccess = FileAccess.open("res://water-being-list.csv", FileAccess.READ)
var entries: Array[WaterBeing] = []
var header_ignored: bool = false
while not file.eof_reached():
var line: PackedStringArray = file.get_csv_line()
if not header_ignored:
# ignore the first line
header_ignored = true
continue
if line.size() < 3:
# ignore anything with less than 3 columns,
# including the last line, which is [""]
continue
var entry: WaterBeing = WaterBeing.new()
entry.name = line[0]
entry.reason = line[1]
var rating: String = line[2]
if not rating.is_valid_int():
push_error("Rating %s is not a valid rating for %s" % [rating, entry.name])
continue
entry.rating = int(rating)
entries.append(entry)
file.close()
which might also be an upside since it locks you into a schema of sorts; otherwise if we swap the "why i like them" and "rating" columns or just have an invalid rating value, the above code will start logging errors, allowing us to catch some bugs early. We could go harder on the validation, but let's keep it short for brevity.
This works fine, apart from two things:
- Godot has hijacked the CSV extension for themselves and tries to interpret the fish list as a translation file.
- Now the deserialization of the CSV is done during runtime, which, for a big list, will incur some runtime cost. Also, this just doesn't feel "right" to me.
Benefits of a Resource
Everything that can be read from a file that the engine can use is a Resource, including scenes and scripts (both PackedScene and GDScript inherit from Resource). They are a natural part of the asset pipeline in the engine, so wouldn't it be nice if we could have the same for our game data? Thankfully it's pretty simple to achieve.
After we're done, our CSV will:
- get reimported every time Godot detects that it's changed, moving the deserialization and data parsing part from runtime to editor-time.
- be readable from anywhere in the code through normal
load()andpreload()calls just like any other file format supported by Godot.
Writing the importer
OK, let's crack our knuckles and start by creating a plugin. Go to Project → Project Settings → Plugins and click Create New Plugin. You only need to give it a name; the rest is optional. Call it something like importers and click Create and enable it by ticking the checkmark next to its name. Now your project will have gained a folder structure like this:
.
└── addons/
└── importers/
├── importers.gd
└── plugin.cfg
First, you need to create the actual resource definitions. In the importers folder, create a subfolder and call it resources. In this case, the folder structure is not important; it's just for organization purposes. We will assign class names to these resources anyway, and then it stops mattering where the scripts are actually located within the project.
Create a script called water_being.gd and give it the following contents:
class_name WaterBeing
extends Resource
@export var name: String
@export var reason: String
@export var rating: int
This is for our single entry, and now we need a Resource that'll describe the list of our entries. That will also be the type that load() returns.
Create a script called water_being_collection.gd and give it these contents:
class_name WaterBeingCollection
extends Resource
@export var entries: Array[WaterBeing]
(We could also use a dictionary as the type of entries, then we could key the entries by name or by some other property. Then we could have superfast O(1) access to a specific WaterBeing, but, again, keeping it simple.)
OK, let's write the actual importer. Create a new script and call it water_being_importer.gd. Start it with this template:
@tool
extends EditorImportPlugin
func _get_importer_name() -> String:
return "water_being.importer"
func _get_visible_name() -> String:
return "WaterBeing collection"
func _get_recognized_extensions() -> PackedStringArray:
return ["wblist"]
func _get_save_extension() -> String:
return "res"
func _get_resource_type() -> String:
return "Resource"
func _get_priority() -> float:
return 1.0
func _get_preset_count() -> int:
return 1
func _get_import_order() -> EditorImportPlugin.ImportOrder:
return EditorImportPlugin.ImportOrder.IMPORT_ORDER_DEFAULT
func _get_import_options(path: String, preset_index: int) -> Array[Dictionary]:
return []
func _get_preset_name(preset_index) -> String:
return "Default"
func _import(source_file: String, save_path: String, options: Dictionary, platform_variants: Array[String], gen_files: Array[String]) -> int:
pass
It seems like a lot at first, but it's just a bunch of boilerplate, so let me walk you through the important methods fast:
_get_importer_name()is a unique key for the importer. I'm fairly certain the content doesn't matter as long as it doesn't clash with other importers._get_visible_name()is the "human-readable" name for the kind of the resource. The godot docs describe it as a continuation to "Import as", e.g. "Import as Special Mesh"._get_recognized_extensions()returns a list of extensions that will trigger this importer. As I mentioned before, Godot took "csv" for themselves for translations, and I'm sure that we could somehow work around that, but I usually just come up with something that ends withlistand that has worked just fine so far.
The rest is just stuff that we'd only need if we were writing a more advanced importer, which we're not. The main point of focus here is the _import() method that will do the actual importing. In general, we'll take what we wrote to read the CSV above and update it to create the actual resources:
func _import(source_file: String, save_path: String, options: Dictionary, platform_variants: Array[String], gen_files: Array[String]) -> int:
var file: FileAccess = FileAccess.open(source_file, FileAccess.READ)
var collection: WaterBeingCollection = WaterBeingCollection.new()
var entries: Array[WaterBeing] = []
var header_ignored: bool = false
while not file.eof_reached():
var line: PackedStringArray = file.get_csv_line()
if not header_ignored:
# ignore the first line
header_ignored = true
continue
if line.size() < 3:
# ignore anything with less than 3 columns,
# including the last line, which is [""]
continue
var entry: WaterBeing = WaterBeing.new()
entry.name = line[0]
entry.reason = line[1]
var rating: String = line[2]
if not rating.is_valid_int():
push_error("Rating %s is not a valid rating for %s" % [rating, entry.name])
continue
entry.rating = int(rating)
entries.append(entry)
collection.entries = entries
return ResourceSaver.save(collection, save_path + "." + _get_save_extension()
)
As you can see, the code in _import has changed little from the runtime-loading implementation. Now we don't use a hardcoded path for the CSV, but we use the source_file argument, which means that we're not limited to a single list anymore. We assign our entries array to a collection variable, which is our WaterBeingCollection resource. Then we use ResourceSaver to save our parsed collection as a file with the res extension. Again, I want to mention that this is not the "final form" of what the importer could be, but I just want to show how easy it is to convert the runtime code to an importer.
Now we just need to load the importer from the importers plugin so Godot knows about it. Open addons/importers/importers.gd and replace its content with this:
@tool
extends EditorPlugin
var water_being_importer
func _enter_tree() -> void:
water_being_importer = load("res://addons/importers/water_being_importer.gd").new()
add_import_plugin(water_being_importer)
func _exit_tree() -> void:
remove_import_plugin(water_being_importer)
water_being_importer = null
Just in case, here's the final folder structure:
.
└── addons/
└── importers/
├── resources/
│ ├── water_being.gd
│ └── water_being_collection.gd
├── importers.gd
├── plugin.cfg
└── water_being_importer.gd
Now go to the Plugins tab in Project Settings and disable and re-enable the importers addon, so it actually loads our custom importer!
Now when you rename water-being-list.csv to water-being-list.wblist, it should trigger the importer. You can double-click the file in the FileSystem tab and check out the imported entries as resources:

We can see that alongside our wblist, as one would expect, an .import file was also generated. When we peek inside, we can see that it used our importer and stored a path to the resource we generated.
So, now we can load the CSV in code using load() and iterate over the entries:
var collection: WaterBeingCollection = load("res://water-being-list.wblist")
for entry in collection.entries:
prints(entry.name, entry.reason, entry.rating)
Conclusion
Now you have a foundation for an expandable content format, which you can edit in a plain text editor or in a spreadsheet processor.
I know there are plugins and addons that solve this problem for you, but I feel like knowing how to write an importer will unlock way better understanding for you, give you ideas for other tooling, and allow you to remove friction from the content creation and iteration process!
Debate me on whether a dolphin is a fish or not on Bluesky or Mastodon!