//rp.wtf

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:

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:

Writing the importer

OK, let's crack our knuckles and start by creating a plugin. Go to ProjectProject SettingsPlugins 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:

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:

Godot's inspector showing a WaterBeing collection with eight WaterBeing entries with the first one being expanded, and it says name -> shark, reason -> badass, rating -> 8

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!