Serializing Lua Tables to Disk

· 8 min read · Updated April 1, 2026 · intermediate
lua serialization guides

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

MethodHuman-ReadableHandles nilHandles CyclesPerformanceEcosystem
String concatYesNoNoSlowNone
table.concatYesNoWith seen tableMediumNone
json.luaYesStrippedNoMediumExcellent
MessagePackNoYesYesFastGood
C API + msgpack-cNoYesYesFastestGood

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