luaguides

Parsing and Generating JSON in Lua

JSON is the lingua franca of data exchange. Whether you’re reading a config file, consuming an API response, or logging structured data, you will encounter JSON in almost every Lua project. The catch: Lua ships with no built-in JSON support. You need a library.

This guide focuses on dkjson, the most widely-used pure-Lua JSON library. It is dependency-free, works across Lua 5.1 through 5.4 and LuaJIT, and can be installed in seconds or simply copied into your project. I’ll also show you the alternatives and the key differences that matter.

Installing a JSON Library

Via LuaRocks

The fastest route on any system with LuaRocks installed:

luarocks install dkjson

This single command fetches dkjson from the LuaRocks repository and places it where your Lua installation can find it. If you are working on a project that may be distributed to users who do not have LuaRocks, consider pinning the exact version — luarocks install dkjson 2.8 — to avoid surprises when a new upstream release changes the API.

For the C-based alternative:

luarocks install lua-cjson

The C library is dramatically faster for large documents, but it requires a C compiler at install time and links against your system’s Lua headers. If you are on a minimal container or an embedded platform without a build toolchain, stick with dkjson — it is pure Lua and has zero native dependencies.

Manual install

dkjson is a single .lua file with no dependencies. Download it from David Kolf’s GitHub repository and drop it anywhere in your package.path, or alongside your script. This makes it ideal for projects where you cannot or do not want to manage external dependencies. Many Lua game engines and embedded environments ship dkjson this way — just include the raw file in your project tree and you are done.

Once installed, load it the same way as any other module:

local json = require("dkjson")

The require call searches the standard Lua module path for dkjson.lua. If you placed the file alongside your script rather than in a standard location, you may need to adjust package.path first — package.path = "./?.lua;" .. package.path tells Lua to check the current directory before system paths.

Encoding Lua tables to JSON

JSON encoding converts a Lua table into a JSON string. With dkjson, you pass a table to json.encode():

local json = require("dkjson")

local data = {
  name = "Grace Hopper",
  born = 1906,
  active = true,
  languages = { "COBOL", "FLOW-MATIC", "ALGOL" }
}

local str = json.encode(data)
print(str)

The json.encode() function traverses your table recursively and builds a JSON string. Boolean values become true/false, numbers stay as numbers, strings get quoted with proper escaping, and nested tables become nested JSON objects or arrays. One subtle behaviour: a table with contiguous integer keys starting at 1 produces a JSON array, while a table with non-sequential or string keys produces a JSON object. This mapping is automatic and usually correct, but it can surprise you if your table has intentional gaps in the array portion.

Output:

{"name":"Grace Hopper","born":1906,"active":true,"languages":["COBOL","FLOW-MATIC","ALGOL"]}

This compact representation is what you would send over a network or write to a file. For debugging or inspecting data, however, the single-line output is hard to read, which is where pretty-printing comes in.

Pretty-printing

By default, the output is compact. Pass { indent = true } as the second argument to get formatted output:

local str = json.encode(data, { indent = true })
print(str)

The indented output is far easier to read when debugging or logging configuration data. The indent option also increases payload size, so avoid it in production API responses where every byte counts on the wire. Additionally, dkjson provides a level option alongside indent — setting it to a number controls how many spaces each nesting level receives, with a default of 2.

{
  "name": "Grace Hopper",
  "born": 1906,
  "active": true,
  "languages": ["COBOL", "FLOW-MATIC", "ALGOL"]
}

This formatted output shows the structure clearly. In many real-world scenarios, you will want to control the exact order of fields — especially when the JSON is consumed by humans or by systems that expect keys in a specific sequence. That is where keyorder becomes useful.

Controlling key order

JSON objects have no defined key order, and by default dkjson outputs keys in whatever order Lua’s table iteration produces. Use keyorder to enforce a predictable order:

local str = json.encode(data, {
  keyorder = { "name", "born", "active", "languages" },
  indent = true
})

Representing null values

This is where Lua trips up many newcomers. Lua tables cannot contain nil as a value — setting a key to nil deletes the key entirely. So if you want to represent JSON null, you need dkjson’s sentinel value json.null:

local record = {
  title = "Draft Report",
  content = "Some text here",
  reviewed_by = json.null  -- this will become null in JSON
}

local str = json.encode(record, { indent = true })
print(str)

The json.null sentinel is a special value that dkjson recognises during encoding. When the encoder encounters it in a table, it writes a literal null to the JSON output instead of treating the key as absent. This is important for APIs where null carries meaning — for example, a reviewed_by: null in a document API might mean “not yet reviewed,” which is different from omitting the key entirely (which might mean “review status is unknown or not applicable”).

{
  "title": "Draft Report",
  "content": "Some text here",
  "reviewed_by": null
}

Without json.null, the reviewed_by key would simply disappear from the output. In production code, you should document which fields in your JSON API can legally be null so that consumers of your data know whether to check for nil or for a missing key — these two conditions have different meanings and getting them confused leads to subtle bugs in data processing pipelines.

Decoding JSON to Lua tables

Parsing JSON with dkjson uses json.decode(). It takes a JSON string and returns a Lua table along with a byte position and an error message:

local str = '{"currency": "\u20AC", "amount": 42}'

local obj, pos, err = json.decode(str, 1, nil)
if err then
  print("Parse error at position " .. pos .. ": " .. err)
else
  print("Currency:", obj.currency)  --> Currency:  €
  print("Amount:", obj.amount)       --> Amount:   42
end

The second argument to decode is the starting position in the string (almost always 1), and the third is an optional nil sentinel. dkjson decodes Unicode escape sequences like \u20AC to their corresponding UTF-8 characters, so the euro sign appears correctly in the output if your terminal supports UTF-8. This automatic Unicode handling means you can work with JSON containing characters from any language without extra decoding steps — the output of json.decode is always a Lua string ready for concatenation or display.

Important: dkjson decodes JSON null to Lua nil. This means you cannot distinguish between a missing key and an explicit null after decoding — both result in nil. If you need to tell them apart, pass a sentinel value as the third argument:

local obj = json.decode('{"active": null, "name": "Test"}', 1, "NULL_MARKER")
print(obj.active)   --> NULL_MARKER
print(obj.name)      --> Test

Error handling

Always check the third return value when decoding. A malformed JSON string causes dkjson to return nil, nil, "error message". The position value (the second return) tells you exactly where in the string the parser got stuck, which is critical when debugging malformed JSON from third-party APIs. A common mistake is to assume the first return value will always be a table — if you skip the error check and the parse failed, your code will crash on the next line when it tries to index nil.

local function safe_decode(str)
  local obj, pos, err = json.decode(str, 1, nil)
  if err then
    return nil, "JSON parse error at position " .. pos .. ": " .. err
  end
  return obj
end

local ok, result = safe_decode('{"valid": true}')
print(ok, result.valid)  --> true  true

local ok2, err2 = safe_decode('{"broken": }')
print(ok2, err2)  --> nil  JSON parse error at position 12: <value> expected near '}'

Wrapping your decode calls this way lets you surface clear errors to the user or logs instead of silently failing. The error position from dkjson is precise — it tells you the byte offset where parsing stopped, which is invaluable when debugging malformed input from third-party APIs.

Working with nested data

Real-world JSON is rarely flat. Here is a more complex example showing nested objects and arrays:

local config = {
  server = {
    host = "api.example.com",
    port = 443,
    tls = true
  },
  routes = {
    { method = "GET",    path = "/health" },
    { method = "POST",   path = "/events" },
    { method = "DELETE", path = "/events/{id}" }
  },
  limits = {
    requests_per_second = 100,
    burst = json.null  -- no burst limit
  }
}

local str = json.encode(config, { indent = true })
print(str)

This example demonstrates several encoding behaviours at once. The server sub-table becomes a nested JSON object with string, numeric, and boolean values. The routes table, which has contiguous integer keys starting at 1, becomes a JSON array of objects. And json.null in the limits table produces a literal null for the burst field. dkjson handles all of these conversions automatically, which is why it is the go-to choice for most projects — you write Lua tables as you normally would, and the encoder maps them to sensible JSON structures without extra configuration.

Alternatives to dkjson

lua-cjson

The C extension lua-cjson is significantly faster than dkjson for large documents because it runs in C rather than interpreted Lua. Install it with luarocks install lua-cjson.

local cjson = require("cjson")

local str = cjson.encode({ key = "value" })
local tbl = cjson.decode('{"key": "value"}')

The API is simpler, but there are two critical differences:

  1. cjson.null is userdata, not nil. When you decode {"key": null}, the value is cjson.null (a userdata object). This means tbl.key == nil is false even though the JSON value was null. Use if tbl.key == cjson.null then to detect it.

  2. Encoding cjson.null raises an error. Where dkjson accepts json.null, cjson does not. Use nil when encoding, and cjson converts it to null automatically. If you pass cjson.null to encode(), you get an error.

For high-throughput services processing megabytes of JSON per second, cjson’s speed is worth the quirks. For everything else, dkjson is the simpler choice.

json.lua

Pure Lua, faster than dkjson, and created by Ralph G.wei. Install it with luarocks install json-lua.

local json = require("json")

json.encode({ key = "value" })
json.decode('{"key": "value"}')

The API matches dkjson closely, but json.lua maps JSON null directly to Lua nil. Like dkjson, this means you cannot distinguish between a missing key and an explicit null after decoding.

Common gotchas

nil values disappear

Lua tables cannot hold nil. Any key set to nil is deleted. json.encode() silently omits these keys:

local data = { a = 1, b = nil, c = 3 }
print(json.encode(data))  --> {"a":1,"c":3}

If null semantics matter, use json.null with dkjson or cjson.null with cjson.

Array indices start at 1

JSON arrays are conceptually zero-indexed, but when dkjson decodes [1, 2, 3], you get a Lua array where t[1] == 1. This is almost always what you want — just be aware when interfacing with systems that expect zero-based indexing.

Large integers lose precision

Lua numbers are IEEE 754 doubles. Integers larger than 2^53 (about 9 quadrillion) cannot be represented exactly. If you are working with financial data or IDs from systems that use 64-bit integers, encode them as strings instead of numbers to preserve precision.

Circular references crash the encoder

JSON has no way to represent circular references. If your table has a self-referential structure, json.encode() will loop forever or raise an error. Validate your data structure before encoding:

local function is_cyclic(tbl)
  local seen = {}
  local function walk(t)
    if type(t) ~= "table" then return false end
    if seen[t] then return true end
    seen[t] = true
    for _, v in pairs(t) do
      if walk(v) then return true end
    end
    return false
  end
  return walk(tbl)
end

UTF-8 encoding required

dkjson expects UTF-8 input. If your Lua environment or source files use a different encoding, Unicode characters may be corrupted or raise errors. Stick to UTF-8 and you will not have problems.

Where to go next

If you found this useful, you might also explore Lua’s table serialization patterns (tables are the backbone of all data handling in Lua), and how Lua’s module system (require) loads libraries like dkjson. These topics come up frequently whenever you move beyond simple JSON tasks into more complex data pipelines.

For most JSON tasks in production Lua code, dkjson handles the job without fuss. Keep json.null in mind whenever you need explicit null semantics, and always validate your data structure before passing it to the encoder.

See also

  • lua-luarocks-guide — LuaRocks is the standard way to install dkjson and cjson as Lua packages
  • lua-lfs-filesystem — LuaFileSystem (lfs), commonly used alongside JSON for reading and writing data files
  • lua-closures — closures and higher-order functions, useful for building JSON transformation pipelines