Serializing Lua Tables to Disk
Lua tables are the language’s only data structure — they power arrays, dictionaries, objects, and modules. But tables live in memory. When your program exits, they vanish. Serialization is the process of converting a table into a string representation that can be written to disk and reconstructed later.
This guide covers every major approach: from quick built-in tricks to the robust MessagePack binary format and the Lua C API.
Why Serialize Tables?
You need to serialize tables when you want to:
- Persist configuration — save user settings between program runs
- Save game state — store a player’s inventory, progress, or world position
- Cache data — write computed results to disk to skip expensive recalculation
- Communicate between processes — exchange structured data over pipes, sockets, or files
Lua can’t write a table directly to a file. You must first convert it to a string, then write that string, then parse it back on load.
Built-in Approaches
String Concatenation
The most primitive method iterates over a table and builds a string manually:
local function serialize_basic(value, indent)
indent = indent or ""
local t = type(value)
if t == "nil" then
return "nil"
elseif t == "number" then
return tostring(value)
elseif t == "string" then
return string.format("%q", value)
elseif t == "boolean" then
return tostring(value)
elseif t == "table" then
local parts = {"{"}
for k, v in pairs(value) do
local key_str = serialize_basic(k, indent .. " ")
local val_str = serialize_basic(v, indent .. " ")
table.insert(parts, string.format("[%s] = %s,", key_str, val_str))
end
table.insert(parts, "}")
return table.concat(parts, "\n" .. indent .. " ")
else
error("Unsupported type: " .. t)
end
end
local data = {name = "Alice", score = 42, active = true}
print(serialize_basic(data))
Output:
{
["name"] = "Alice",
[2] = 42,
[3] = true,
}
This works for simple tables but breaks down with cyclic references (a key pointing back to itself) and shared references (the same table appearing under two different keys). You’ll hit a stack overflow on cycles.
Using table.concat for Performance
table.concat is far faster than repeated .. concatenation because it avoids creating intermediate strings. Here’s a refined serializer using it:
local function serialize_fast(value, seen)
seen = seen or {}
local t = type(value)
if t == "nil" then
return "nil"
elseif t == "number" then
return tostring(value)
elseif t == "string" then
return string.format("%q", value)
elseif t == "boolean" then
return tostring(value)
elseif t == "table" then
if seen[value] then
error("Cyclic reference detected")
end
seen[value] = true
local parts = {"{"}
for k, v in pairs(value) do
local key_str = serialize_fast(k, seen)
local val_str = serialize_fast(v, seen)
table.insert(parts, string.format("[%s] = %s,", key_str, val_str))
end
table.insert(parts, "}")
return table.concat(parts)
else
error("Unsupported type: " .. t)
end
end
The seen table tracks already-visited tables to detect cycles. Without this guard, serializing a self-referential table causes infinite recursion.
The json.lua Library
For human-readable, language-agnostic serialization, json.lua (based on dkjson or the pure-Lua version by rxi) is the standard choice for Lua projects.
Installation
# Using LuaRocks
luarocks install dkjson
# Or grab json.lua directly
curl -O https://raw.githubusercontent.com/rxi/json.lua/master/json.lua
Encoding and Decoding
local json = require("json")
-- Encode a table to a JSON string
local data = {
name = "Bob",
levels = {1, 2, 3},
metadata = {rank = "admin", logged_in = true}
}
local str = json.encode(data)
print(str)
-- {"name":"Bob","levels":[1,2,3],"metadata":{"rank":"admin","logged_in":true}}
-- Decode back to a table
local decoded = json.decode(str)
print(decoded.name) --> Bob
Saving to Disk
local json = require("json")
local function save_json(filename, data)
local file = io.open(filename, "w")
if not file then error("Could not open " .. filename) end
file:write(json.encode(data))
file:close()
end
local function load_json(filename)
local file = io.open(filename, "r")
if not file then return nil end
local content = file:read("*a")
file:close()
return json.decode(content)
end
-- Usage
save_json("config.json", {theme = "dark", volume = 75})
local config = load_json("config.json")
print(config.theme) --> dark
Advantages of JSON: Human-readable, widely supported, and works across language boundaries — a Python script or JavaScript application can read Lua’s JSON output.
Disadvantages: No native support for Lua-specific types like nil, functions, or userdata. You must strip metatables. JSON also has no concept of array vs. dictionary distinction — both become arrays/objects in other languages.
MessagePack
MessagePack is a binary serialization format. It produces compact binary data that’s faster to parse than JSON and handles more Lua types natively.
Installation
luarocks install lua-messagepack
Encoding and Decoding
local msgpack = require("msgpack")
local data = {
name = "Carol",
scores = {95, 87, 92},
extra = nil -- MessagePack handles nil explicitly
}
-- Encode to a binary string (can contain raw bytes)
local binary = msgpack.pack(data)
print(#binary .. " bytes") --> 21 bytes
-- Decode back
local decoded = msgpack.unpack(binary)
print(decoded.name) --> Carol
Saving MessagePack to Disk
local msgpack = require("msgpack")
local function save_game(filename, state)
local packed = msgpack.pack(state)
local file = io.open(filename, "wb") -- binary write mode
file:write(packed)
file:close()
end
local function load_game(filename)
local file = io.open(filename, "rb") -- binary read mode
if not file then return nil end
local packed = file:read("*a")
file:close()
return msgpack.unpack(packed)
end
local game_state = {
player = {x = 100, y = 200, health = 85},
inventory = {"sword", "potion", "key"},
quests = {main = true, side = false}
}
save_game("savegame.dat", game_state)
local loaded = load_game("savegame.dat")
print(loaded.player.health) --> 85
Advantages of MessagePack: Compact binary representation, handles nil correctly, faster encoding/decoding, and a single Lua implementation works across Lua, LuaJIT, and Lua 5.1–5.4.
Disadvantages: Not human-readable. You need the library installed. If you need to debug serialized data, you’ll need a tool like msgpack-tools to decode it.
C Serialization with the Lua API
When performance is critical or you need to embed serialization in a C extension, the Lua C API provides full control. This section assumes you have a basic familiarity with writing C extensions for Lua.
Serializing from C
The C API lets you iterate over Lua tables from C code:
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
static int l_serialize(lua_State *L) {
luaL_checktype(L, 1, LUA_TTABLE);
lua_newtable(L); /* result table */
int res_idx = lua_gettop(L);
lua_pushnil(L);
while (lua_next(L, 1) != 0) {
/* stack: table, key, value */
/* copy key and value to result */
lua_pushvalue(L, -2); /* push key again */
lua_pushvalue(L, -2); /* push value again */
lua_settable(L, res_idx);
}
return 1; /* return result table */
}
static int l_save_table(lua_State *L) {
const char *filename = luaL_checkstring(L, 2);
FILE *fp = fopen(filename, "wb");
if (!fp) return luaL_error(L, "Cannot open file: %s", filename);
/* Pack table to MessagePack via C (simplified) */
/* In production, use msgpack-c library here */
luaL_checktype(L, 1, LUA_TTABLE);
/* For this example, serialize as a simple text format */
fprintf(fp, "{-- serialized table --}\n");
lua_pushnil(L);
while (lua_next(L, 1) != 0) {
/* key at -2, value at -1 */
fprintf(fp, "%s=%s\n",
luaL_tolstring(L, -2, NULL),
luaL_tolstring(L, -1, NULL));
lua_pop(L, 1); /* remove value, keep key for next */
}
fclose(fp);
return 0;
}
static const luaL_Reg mylib[] = {
{"serialize", l_serialize},
{"save_table", l_save_table},
{NULL, NULL}
};
/* Register the library */
int luaopen_myserial(lua_State *L) {
luaL_newlib(L, mylib);
return 1;
}
Registering the C Module in Lua
-- myserial.lua (C binding)
local myserial = require("myserial")
local data = {name = "Dave", level = 7}
local serialized = myserial.serialize(data)
print(serialized.name) --> Dave
-- Save directly from C
myserializer.save_table(data, "output.dat")
Calling Lua Serializers from C
You can also invoke a Lua serializer from C using lua_pcall:
-- serializer.lua
local function save(filename, data)
local f = io.open(filename, "w")
f:write(require("json").encode(data))
f:close()
end
return { save = save }
/* From C */
lua_getglobal(L, "require");
lua_pushstring(L, "serializer");
if (lua_pcall(L, 1, 1, 0) != LUA_OK) {
printf("Error: %s\n", lua_tostring(L, -1));
return;
}
lua_getfield(L, -1, "save"); /* push serializer.save */
lua_pushstring(L, "save.dat"); /* filename */
lua_newtable(L); /* push data table */
/* ... populate data table ... */
if (lua_pcall(L, 2, 0, 0) != LUA_OK) {
printf("Save error: %s\n", lua_tostring(L, -1));
}
A Complete Save/Load Example
Here’s an end-to-end example using MessagePack that handles a realistic game scenario:
local msgpack = require("msgpack")
-- Simulated game state
local function create_initial_state()
return {
player = {
name = "Hero",
x = 0,
y = 0,
inventory = {},
stats = {hp = 100, mp = 50, strength = 12}
},
world = {
current_zone = "forest",
unlocked_doors = {"gate_a", "gate_b"},
npcs = {}
},
meta = {
save_version = 1,
saved_at = os.date("%Y-%m-%d %H:%M:%S")
}
}
end
local function save_game(filename, state)
local packed = msgpack.pack(state)
local file = io.open(filename, "wb")
if not file then return false, "Could not open file" end
file:write(packed)
file:close()
return true
end
local function load_game(filename)
local file = io.open(filename, "rb")
if not file then return nil end
local data = file:read("*a")
file:close()
local success, state = pcall(msgpack.unpack, data)
if not success then return nil end
return state
end
-- Simulate a gameplay session
local state = create_initial_state()
table.insert(state.player.inventory, "iron_sword")
state.player.stats.hp = 87
state.world.npcs.guard = {name = "Captain", hp = 50}
-- Save
local ok, err = save_game("savegame.dat", state)
print(ok and "Game saved!" or "Save failed: " .. err)
-- Load in a new session
local loaded = load_game("savegame.dat")
if loaded then
print(string.format("Resuming %s in %s (HP: %d)",
loaded.player.name,
loaded.world.current_zone,
loaded.player.stats.hp))
print("Inventory:", table.concat(loaded.player.inventory, ", "))
end
Choosing the Right Approach
| Method | Human-Readable | Handles nil | Handles Cycles | Performance | Ecosystem |
|---|---|---|---|---|---|
| String concat | Yes | No | No | Slow | None |
table.concat | Yes | No | With seen table | Medium | None |
| json.lua | Yes | Stripped | No | Medium | Excellent |
| MessagePack | No | Yes | Yes | Fast | Good |
| C API + msgpack-c | No | Yes | Yes | Fastest | Good |
Use JSON when you need readability or cross-language compatibility. Use MessagePack when performance or binary size matters. Use the C API when serialization is a bottleneck in a production extension and you’re already writing C.
See Also
- /guides/lua-table-sorting/ — Sorting tables is a common companion to serialization when you need deterministic output
- /tutorials/file-io/ — The fundamentals of reading and writing files in Lua
- /guides/lua-metatables/ — Metatables power custom serialization behaviors through
__serialize