luaguides

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, and serializing Lua data means converting these in-memory structures into a storable format. 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 compact 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))

Running this function on a small table produces a Lua-like text representation with quoted string keys and unquoted numeric values. Notice that the function handles nil, numbers, strings, and booleans by converting each to its literal form, while tables are recursed into with indentation. The output reveals the table structure:

{
  ["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

Once json.lua is available to your project through require, you can encode any flat or nested Lua table into a JSON string. The encoder walks the table recursively, converting Lua numbers, strings, booleans, and nested tables into their JSON equivalents. Arrays with consecutive integer keys starting at 1 become JSON arrays; tables with mixed or non-consecutive keys become JSON objects. Here is the basic encode-decode round-trip:

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

Encoding is only half the story. To persist data across program runs, you need to write the JSON string to a file and read it back later. The helper functions below wrap io.open with error handling, using "w" mode for writing and "r" for reading. Calling file:read("*a") slurps the entire file into a single string, which json.decode then parses back into a Lua table:

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 linear arrays or key-value objects in other languages depending on how the encoder handles sparse integer keys.

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

MessagePack encodes Lua tables into a compact binary string where each value is tagged with its type, avoiding the quoting overhead that JSON pays for every key and string. Nil values are preserved explicitly rather than stripped, and the packed data can contain raw bytes without escaping. The library exposes two primary functions, pack for encoding and unpack for 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

MessagePack’s binary output cannot be written in text mode. You must open files with "wb" for writing and "rb" for reading, which treat the content as raw bytes rather than attempting newline translation or character encoding. The save and load functions below follow the same pattern as the JSON helpers but use binary mode, and they guard against corrupted data by wrapping msgpack.unpack in pcall:

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 over every byte that touches the stack. The tradeoff is complexity: you manage memory explicitly, push and pop values from the Lua stack by hand, and compile your extension as a shared library. For embedded systems, game engines, and high-throughput data pipelines where JSON encoding costs measurable milliseconds, the C path eliminates all Lua-side overhead. 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;
}

The C code above defines two functions: l_serialize, which copies a table to a result table via the stack, and l_save_table, which iterates the table with lua_next and writes key-value pairs to a file. Both are registered in a luaL_Reg array and exposed through luaopen_myserial. After compiling this C extension into a shared library, you load it from Lua with require like any other module:

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")

The pattern demonstrated by myserial is a common one: push the heavy serialization logic into C for speed, but keep the Lua-side API clean and familiar. The C functions manipulate the Lua stack directly, so you call luaL_checktype to validate arguments and lua_pushvalue to duplicate stack entries before passing them to functions like lua_settable. This same stack-based approach works in reverse: you can call a Lua serializer from C, which is useful when the serialization logic lives in Lua but the driver is a C application:

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 }

The Lua module above exposes a single save function that takes a filename and a data table, then delegates to json.encode before writing to disk. From the C side, you load this module with require, push the function and its arguments onto the Lua stack, and call lua_pcall to execute it. The stack discipline is critical: push the callable first, then the arguments in order, and pass the argument count to lua_pcall. After the call, the return value (if any) sits on top of the stack:

/* 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));
}

The C API path gives you the most control over memory and I/O, but for most Lua applications the MessagePack or JSON approach is more practical. To see how all the pieces fit together in a real scenario, the example below simulates a complete game session: it creates a nested state table with player stats, inventory, and world data, serializes it with MessagePack, and then loads it back in a fresh session:

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