Parse JSON in Lua: lua-cjson, dkjson, RapidJSON-Lua, and Lua 5.x Tables

Last updated:

Lua has no built-in JSON support — the standard library stops at strings, tables, and string.format. JSON parsing happens through a third-party module, and the ecosystem has settled around four choices: lua-cjson (C extension, the default), dkjson (pure Lua, no build step), json.lua (single-file pure Lua), and RapidJSON-Lua (C++ binding, the fastest). Two environments ship JSON support out of the box: OpenResty includes a forked lua-cjson, and Neovim ships vim.json since 0.6. The single biggest gotcha is that Lua does not distinguish arrays from objects — both are tables — so empty tables and sparse tables need explicit metatable hints to encode correctly. This guide covers the libraries, the install paths, the array/object ambiguity, null handling with cjson.null, and the OpenResty and Neovim specifics.

Decoded a JSON response in Lua and the result looks wrong? Paste the original payload into Jsonic's JSON Validator first — it shows exactly which keys are arrays vs objects, which values are null vs missing, and where any syntax errors live with line and column numbers.

Validate JSON payloads

Lua JSON library landscape: lua-cjson, dkjson, json.lua, RapidJSON-Lua

Lua leaves serialization to userland. Picking a JSON module is a one-time decision per project, and the right choice depends on three factors: whether you can compile a C extension, how strict the JSON parsing must be, and whether you need pretty-printed output. The four libraries that cover essentially every Lua workload are lua-cjson, dkjson, json.lua, and RapidJSON-Lua.

LibraryImplementationInstallBest for
lua-cjsonC extensionLuaRocks + compilerDefault for server-side Lua, OpenResty, anywhere you can compile
dkjsonPure LuaLuaRocks or single fileEmbedded Lua, game engines, environments without a C toolchain
json.luaPure Lua, single fileCopy one fileTiny scripts, no package manager, MIT-licensed drop-in
RapidJSON-LuaC++ bindingLuaRocks + C++ buildHot paths parsing megabyte-scale JSON, RFC-7159 strict mode

lua-cjson is the de facto standard. The current 2.1.x series compiles against Lua 5.1 through 5.4 and LuaJIT, the API is small (six exported functions), and the encode/decode loops are tight C code. If you do not have a strong reason to pick something else, pick this one.

dkjson trades speed for portability. The whole module is one Lua file with no native code, so it runs anywhere Lua does — including platforms where you cannot install a compiler (mobile game scripting, embedded firmware, some serverless runtimes). It also supports configurable indentation, which lua-cjson omits.

Installation: LuaRocks vs OpenResty bundles vs system packages

The canonical install path for any Lua JSON library is LuaRocks, the Lua package manager. LuaRocks fetches source from the central rocks repository, compiles any C code against your installed Lua headers, and installs the module into the user or system Lua path so require finds it.

# Install LuaRocks first
# macOS:    brew install luarocks
# Ubuntu:   apt install luarocks lua5.4 lua5.4-dev build-essential
# Fedora:   dnf install luarocks lua-devel gcc

# Install lua-cjson for the system Lua
luarocks install lua-cjson

# Or pin a specific Lua version (when multiple are installed)
luarocks --lua-version 5.4 install lua-cjson

# Pure-Lua alternatives — no compiler needed
luarocks install dkjson

# Test the install
lua -e 'local cjson = require("cjson"); print(cjson.encode({hello = "world"}))'
# => {"hello":"world"}

When LuaRocks is not an option, three fallbacks work. Distribution packagesship pre-built binaries: apt install lua-cjson on Debian and Ubuntu, apk add lua5.4-cjson on Alpine, dnf install lua-cjson on Fedora. These are usually slightly older versions but require no compiler.

OpenResty bundles its own lua-cjson fork — installing OpenResty (which is what almost everyone running production nginx+Lua uses) gives you JSON support with zero extra steps. Inside an OpenResty config, require "cjson"just works.

Single-file modules like json.lua work when even package managers are off the table. Download the one file, drop it next to your script, and require "json" picks it up via the package path. This is the dominant install path for game scripting, embedded Lua, and CI scripts.

Basic cjson.encode and cjson.decode

The lua-cjson API is small. cjson.encode(value) turns a Lua value into a compact JSON string. cjson.decode(string) turns a JSON string into a Lua value. Both raise an error on malformed input — use pcall to catch parse failures, or the cjson.safe variant that returns nil and an error message instead of throwing.

local cjson = require("cjson")

-- Encode a Lua table to JSON
local user = {
  name = "Alice",
  age = 30,
  roles = {"admin", "editor"},
  active = true,
  manager = cjson.null,  -- explicit JSON null
}
local json_str = cjson.encode(user)
-- {"name":"Alice","age":30,"roles":["admin","editor"],"active":true,"manager":null}

-- Decode JSON back to a Lua table
local payload = '{"id": 42, "tags": ["lua", "json"], "meta": null}'
local data = cjson.decode(payload)
print(data.id)         -- 42
print(data.tags[1])    -- "lua"  (1-indexed!)
print(data.tags[2])    -- "json"
print(#data.tags)      -- 2
print(data.meta == cjson.null)  -- true

-- Safe variant: nil + error instead of raise
local safe = require("cjson.safe")
local ok, err = safe.decode("not valid json")
if ok == nil then
  print("decode failed:", err)
end

The mapping between Lua and JSON types is straightforward. Lua string ↔ JSON string, Lua number ↔ JSON number, Lua boolean ↔ JSON boolean. Lua table maps to either JSON object or array depending on its shape (the next section covers when that decision goes wrong), and the sentinel cjson.null maps to JSON null in both directions.

For a broader survey of how different languages handle JSON round-tripping, see JSON across languages.

Lua's array vs object ambiguity (empty table problem)

Lua has one composite type — the table — that does double duty as both array and hash map. { "a", "b", "c" } is a table with integer keys 1, 2, 3. { name = "Alice" }is a table with the string key "name". JSON, in contrast, has two distinct types (array and object), so encoders have to guess which one a Lua table represents. The heuristic is: if all keys are positive integers 1..N with no gaps, encode as a JSON array; otherwise encode as a JSON object.

The heuristic breaks on the empty table. {} has no keys at all, and the encoder has nothing to go on — it falls back to encoding as a JSON object {}. If your API contract says "always return a JSON array of tags, possibly empty", you get {} instead of [] and the consumer breaks.

local cjson = require("cjson")

-- The problem
print(cjson.encode({}))           -- {} (object) — wrong if you wanted []
print(cjson.encode({1, 2, 3}))    -- [1,2,3]   (correct)
print(cjson.encode({a = 1}))      -- {"a":1}   (correct)

-- The fix: cjson.array_mt forces array encoding
local empty_tags = setmetatable({}, cjson.array_mt)
print(cjson.encode(empty_tags))   -- []  ✓

-- Or use the pre-built sentinel
print(cjson.encode(cjson.empty_array))  -- []  ✓

-- For tables you might populate later, set the metatable at creation
local tags = setmetatable({}, cjson.array_mt)
table.insert(tags, "lua")
table.insert(tags, "json")
print(cjson.encode(tags))         -- ["lua","json"]

-- And cjson.empty_array_mt for tables that may or may not be empty
local maybe_empty = setmetatable({}, cjson.empty_array_mt)
print(cjson.encode(maybe_empty))  -- []

dkjson uses a different convention — set t.__jsontype = "array" or pass {exception = ...} to dkjson.encode. RapidJSON-Lua has its own metatable, rapidjson.array. The takeaway is identical across libraries: tag tables at creation time if their JSON identity matters, especially for tables that may legitimately be empty.

For a deeper treatment of when to model data as an array vs an object — and how this plays out in API contracts — see Array vs Object.

Null handling: cjson.null vs nil

Lua's nil is not equivalent to JSON's null. Storing nil as a table value deletes the key: after t.manager = nil, the key "manager" no longer exists in t. That makes nil unusable as a JSON null carrier — round-tripping {"manager": null} through Lua would lose the key entirely.

The fix is the cjson.null sentinel — a unique lightuserdata value that encodes to JSON null and is what cjson.decodeemits when it reads a JSON null. Code that needs to distinguish "field exists and is null" from "field is absent" compares against this sentinel.

local cjson = require("cjson")

-- Decode JSON null into Lua
local data = cjson.decode('{"name": "Bob", "manager": null}')

-- The key exists and holds cjson.null
print(data.manager == cjson.null)  -- true
print(data.manager == nil)         -- false (important!)

-- Check three states: present-and-set, present-and-null, absent
if data.manager == nil then
  print("manager field absent from JSON")
elseif data.manager == cjson.null then
  print("manager field present, explicitly null")
else
  print("manager:", data.manager)
end

-- Encode Lua null back to JSON
local out = {
  name = "Carol",
  manager = cjson.null,
}
print(cjson.encode(out))
-- {"name":"Carol","manager":null}

-- WRONG: this loses the manager key entirely
local broken = { name = "Carol", manager = nil }
print(cjson.encode(broken))
-- {"name":"Carol"}  — the null is gone

-- Tunable: cjson.decode_invalid_numbers(true)
-- controls whether NaN/Infinity from non-strict JSON decode
-- as Lua numbers or raise.

dkjson uses its own sentinel, dkjson.null; RapidJSON-Lua uses rapidjson.null; Neovim's vim.json uses vim.NIL. The pattern is identical: a library-specific singleton that compares unequal to nil and survives the round trip. Always import the sentinel from whichever library you are using and compare with ==, never with implicit truthiness checks (since cjson.null is truthy in Lua — if data.manager then evaluates to true even when manager is JSON null).

Sparse arrays and the 1-indexed gotcha

Lua arrays are 1-indexed. JSON arrays are 0-indexed in the specification, but in practice everyone reads them as 1-indexed because of how each language exposes them — and Lua exposes them as 1-indexed table positions. cjson.decode('["a","b"]') returns a table where t[1] == "a", t[2] == "b", and t[0] is nil. Iteration uses ipairs and length uses #t, both of which assume 1-based indexing.

That works fine as long as the array is dense. A sparse array — one with gaps in the integer keys — confuses both the length operator and the encoder. #tis officially undefined for sparse tables; it might return the position of the first nil, the largest integer key, or anywhere in between depending on the implementation and the table's internal hash/array split.

local cjson = require("cjson")

-- Dense array (no gaps) — works fine
local dense = {"a", "b", "c"}
print(#dense)              -- 3
print(cjson.encode(dense)) -- ["a","b","c"]
for i, v in ipairs(dense) do
  print(i, v)              -- 1 a / 2 b / 3 c
end

-- Sparse array — undefined behavior territory
local sparse = {}
sparse[1] = "a"
sparse[3] = "c"          -- gap at index 2!
print(#sparse)            -- 1 OR 3 — implementation-defined

-- cjson encodes this as an object because keys aren't 1..N
print(cjson.encode(sparse))
-- {"1":"a","3":"c"} or similar — not what you wanted

-- Fix: fill the gap explicitly with cjson.null or a real value
sparse[2] = cjson.null
print(cjson.encode(sparse))
-- ["a",null,"c"]  — proper JSON array

-- Or rebuild as a dense array
local dense_rebuild = {}
for i = 1, 3 do
  dense_rebuild[i] = sparse[i] or cjson.null
end
print(cjson.encode(dense_rebuild))
-- ["a",null,"c"]

The 1-indexed gotcha bites hardest when porting algorithms from C-family languages. An off-by-one error in Lua often manifests as "the first element is missing" (looped from 0 instead of 1) or "the last element is missing" (looped while i < #t instead of i <= #t). When the data round-trips through JSON, those off-by-ones become silent data loss in API responses.

OpenResty / nginx Lua: pre-bundled cjson

OpenResty is the production-grade bundle of nginx + LuaJIT + a curated set of Lua modules, including a fork of lua-cjson. It is what most teams running Lua in production at scale actually deploy — Cloudflare, Kong, and Apache APISIX all build on it. The JSON support is built in: no LuaRocks, no compiler, no install step. Inside any Lua phase directive — content_by_lua_block, access_by_lua_block, init_by_lua_block, log_by_lua_block — you can require cjson directly.

# nginx.conf snippet — JSON API endpoint in OpenResty
location /api/echo {
    content_by_lua_block {
        local cjson = require "cjson.safe"

        -- Read request body
        ngx.req.read_body()
        local body = ngx.req.get_body_data() or ""

        -- Decode — cjson.safe returns nil + err instead of raising
        local data, err = cjson.decode(body)
        if not data then
            ngx.status = 400
            ngx.say(cjson.encode({error = "invalid JSON: " .. err}))
            return
        end

        -- Echo back with a server timestamp
        data.received_at = ngx.time()

        -- Force tags to be a JSON array even when empty
        data.tags = data.tags or setmetatable({}, cjson.array_mt)

        ngx.header.content_type = "application/json"
        ngx.say(cjson.encode(data))
    }
}

Always prefer cjson.safe in nginx Lua handlers. A malformed request body that raises through cjson.decode can produce a 500-class response and clutter logs; cjson.safe.decode returns nil and an error message so you can return a proper 400. The encode side is the same — when encoding values that may not be representable in JSON (functions, threads), safe variants return nil instead of throwing.

OpenResty's cjson fork has a per-worker encode buffer that is reused across calls. That makes encoding very fast in tight loops but means nested encodes in the same coroutine can clobber each other's buffers. If you find yourself encoding inside a function that is itself being encoded, force a fresh buffer with cjson.encode_keep_buffer(false) at handler init.

Performance: cjson vs dkjson vs RapidJSON

The three libraries occupy clearly separated performance tiers, and the right choice for a project depends on whether JSON parsing is on a hot path. Rough numbers from standard benchmarks (1 MB JSON document, LuaJIT 2.1 on x86-64, 2026 hardware):

LibraryDecode (MB/s)Encode (MB/s)RelativeNotes
RapidJSON-Lua~500~6001.0x (fastest)C++ SIMD parser, strict RFC 7159
lua-cjson (OpenResty fork)~400~5001.2x slowerPer-worker buffer, production-tested at scale
lua-cjson (upstream)~350~4501.4x slowerDefault LuaRocks install
dkjson~30~40~10x slowerPure Lua, supports indented output
json.lua~20~30~15x slowerSingle-file pure Lua

The C-based libraries are an order of magnitude faster than the pure-Lua ones — not because Lua is slow but because string parsing benefits enormously from byte-level code with no per-character overhead. For most application code (parsing a few KB per request, encoding API responses), even dkjson is fast enough — its overhead is measured in microseconds, not milliseconds. For high-throughput services parsing webhook payloads, log streams, or analytics events, the C libraries pay back their install complexity many times over.

Compared to other languages: Lua + cjson is roughly comparable to Parse JSON in Python's orjsonand to Parse JSON in Ruby's oj — all three are C-extension parsers that benchmark in the same range. Pure-Lua dkjson sits closer to Parse JSON in PHP's built-in json_decode and to Parse JSON in Perl's JSON::PP. RapidJSON-Lua is competitive with the fastest parsers in any language — simdjson-class numbers — because the underlying C++ library is one of the fastest JSON parsers in existence.

For pretty-printed output, dkjson is the easy default — dkjson.encode(data, {indent = true}) produces nicely indented JSON in two lines of code. RapidJSON-Lua also supports a pretty option. lua-cjson does not pretty-print at all by design — pipe through an external formatter or use a separate library for the output step.

-- Pretty-printing with dkjson
local dkjson = require("dkjson")
local data = { name = "Alice", roles = {"admin", "editor"} }
print(dkjson.encode(data, { indent = true }))
-- {
--   "name":"Alice",
--   "roles":["admin","editor"]
-- }

-- Error handling with pcall around lua-cjson
local cjson = require("cjson")
local ok, result = pcall(cjson.decode, '{"bad: json}')
if not ok then
  print("decode failed:", result)
  -- decode failed: Expected colon but found invalid token at character 13
end

-- Neovim's built-in vim.json (Neovim 0.6+)
-- No install needed inside a Neovim Lua script:
local ok, data = pcall(vim.json.decode, '{"name": "Bob"}')
if ok then
  print(data.name)  -- Bob
end
print(vim.json.encode({ status = "ok", count = 42 }))
-- {"status":"ok","count":42}

Key terms

lua-cjson
The default Lua JSON library — a C extension exposing cjson.encode, cjson.decode, the cjson.null sentinel, and the cjson.array_mt metatable. Current series is 2.1.x, installable via LuaRocks, and the basis for OpenResty's bundled JSON support.
dkjson
A pure-Lua JSON library written by David Kolf — one file, no native code, slower than cjson but installable anywhere Lua runs. Supports indented output via the indent = true option, which lua-cjson lacks.
RapidJSON-Lua
A Lua binding to the C++ RapidJSON library. The fastest JSON parser available for Lua, with SIMD-accelerated parsing and strict RFC 7159 compliance. Adds a C++ build dependency that the other libraries do not have.
cjson.null
A unique lightuserdata sentinel that represents JSON null in Lua. Necessary because Lua's nil deletes table keys when assigned as a value, so nil cannot survive a JSON round trip on its own.
cjson.array_mt
A metatable that, when set on a Lua table, forces cjson to encode it as a JSON array even when empty or sparse. Solves the empty-table-becomes-object problem that breaks API contracts expecting [].
OpenResty
A production-grade bundle of nginx, LuaJIT, and a curated set of Lua modules — including a fork of lua-cjson. Used by Cloudflare, Kong, and APISIX. JSON support is built in, no LuaRocks required.
vim.json
Neovim's built-in JSON module, available since Neovim 0.6. Exposes vim.json.encode and vim.json.decode with the same API shape as cjson, implemented in C against Neovim's internal JSON parser.

Frequently asked questions

Which Lua JSON library should I use?

For most Lua projects, lua-cjson is the default answer. It is fast (C-based), widely packaged, and is what OpenResty ships by default — meaning if you deploy on nginx + Lua you already have it. For pure-Lua environments where you cannot compile C code (game scripting, embedded Lua, Roblox, some serverless platforms), use dkjson or the single-file json.lua — both are slower but install with a single Lua file and zero build step. For workloads that parse very large documents or need RFC 7159 strictness, RapidJSON-Lua wraps the well-known C++ RapidJSON library and is the fastest option, though it adds a C++ build dependency. The decision tree is: can you compile C? Use lua-cjson. Cannot compile but need speed? Pre-built RapidJSON binary if available. Pure Lua only? dkjson or json.lua.

How do I install lua-cjson?

The standard install path is LuaRocks: run luarocks install lua-cjson and the package manager fetches the source, compiles the C extension against your Lua headers, and places cjson.so in your Lua module path. As of 2026 the current series is 2.1.x. Common failure modes: missing Lua headers (install lua5.3-dev or lua5.4-dev on Debian/Ubuntu, or lua-devel on RHEL/Fedora), wrong Lua version (LuaRocks defaults to the system Lua — pin with luarocks --lua-version 5.4 install lua-cjson), and missing compiler (apt install build-essential). On macOS, brew install luarocks first, then the rest works the same. For environments that cannot run LuaRocks, distribution packages exist (lua-cjson on Debian/Ubuntu, lua-cjson on Alpine), and OpenResty bundles a fork called lua-cjson-2 with extra fixes — no install step at all when you use OpenResty.

Why does my empty Lua table become {} but I wanted []?

Lua does not distinguish between arrays and objects at the language level — both are just tables. An empty table {} has no integer keys and no string keys, so cjson cannot tell whether you meant an empty array or an empty object. By default cjson.encode({}) returns the string "{}" (object), which breaks API contracts that expect "[]". The fix is to mark the table with cjson.array_mt or cjson.empty_array_mt: setmetatable(t, cjson.array_mt) tells cjson to encode the table as a JSON array regardless of its contents, and cjson.empty_array is a pre-built sentinel value that always encodes to []. For dkjson the equivalent is dkjson.encode(t, {indent = false}) combined with the __jsontype = "array" metatable hint. Always set the metatable when you build the table — adding it after-the-fact in a hot encode loop costs measurable performance.

How does cjson handle Lua null values?

Lua has nil, not null — and nil cannot be stored as a table value (assigning nil deletes the key). To round-trip JSON null through Lua, cjson uses a sentinel: cjson.null is a lightuserdata value that encodes to JSON null on output and is what cjson.decode produces when it reads a JSON null. So {"name": "alice", "manager": null} decodes to {name = "alice", manager = cjson.null} — the manager key exists and holds cjson.null, distinct from the key being absent. When you check a field, compare against cjson.null explicitly: if data.manager == cjson.null then handle_unmanaged() end. By default cjson.encode_empty_table_as_object is true, and cjson.decode_invalid_numbers controls whether NaN and Infinity decode as Lua numbers or raise an error. Configure these per-process with cjson.encode_keep_buffer and related setters.

Can I parse JSON in Roblox Lua?

Yes. Roblox ships HttpService:JSONEncode(table) and HttpService:JSONDecode(json_string) — no external library needed. Roblox uses Luau (a typed Lua dialect) and these methods are first-class on the HttpService instance: local HttpService = game:GetService("HttpService"); local data = HttpService:JSONDecode('{"hp": 100}'). The same array vs object problem applies — an empty Luau table encodes as {} rather than [] — but Roblox has no equivalent of cjson.array_mt. The standard workaround is to put a single sentinel element in the table before encoding and strip it on the receiving end, or to encode arrays as JSON strings yourself for the empty case. JSONDecode raises an error on malformed input, so wrap calls in pcall(HttpService.JSONDecode, HttpService, json) when consuming untrusted data from HttpService:GetAsync responses.

What is OpenResty's bundled cjson?

OpenResty (nginx + LuaJIT + a curated module set) ships its own fork of lua-cjson called lua-cjson-2, pre-compiled and on the LuaJIT path by default. You do not install anything — local cjson = require "cjson" works in any content_by_lua_block, access_by_lua_block, or init_by_lua_block. OpenResty&apos;s fork has small differences from upstream: the encode buffer is per-worker (faster, but be careful with reentrant encoding), it ships a cjson.safe variant that returns nil + error message instead of raising on bad input (require "cjson.safe"), and the array detection heuristic is slightly different. In high-throughput nginx workloads, prefer cjson.safe so a single malformed request body cannot crash the Lua handler. The encode/decode API is otherwise identical to upstream lua-cjson, so code written against one runs against the other with no changes.

How do I pretty-print JSON in Lua?

lua-cjson does not pretty-print — cjson.encode always emits the most compact form with no whitespace. For pretty output, the easiest path is dkjson: dkjson.encode(data, {indent = true}) returns a multi-line string with 2-space indentation. If you are already on cjson and do not want a second dependency, you can pipe the compact output through an external tool (echo "$json" | jq . in a subprocess) or write a small recursive formatter (50 lines or so) that walks tables and emits indented JSON. RapidJSON-Lua supports pretty-printing via a writer option: rapidjson.encode(data, {pretty = true}). For one-off debugging, validating in a tool like Jsonic&apos;s JSON Validator gives you a formatted view without writing any code — paste the compact output and the validator renders it indented with collapsible nodes.

How do I parse JSON in Neovim Lua scripts?

Neovim ships vim.json built in — no external library required. Available since Neovim 0.6, the API mirrors cjson: vim.json.encode(table) returns a JSON string, vim.json.decode(json_string) returns a Lua table. It is implemented in C against the same underlying parser as the rest of Neovim, so it is fast enough for plugin work — language servers, LSP message parsing, config files. The empty-table problem still applies: vim.json.encode({}) returns "{}" by default. To force array output, pass an options table: vim.json.encode(t, {array = true}) is not standard — instead, set vim.empty_dict() for objects and use a populated table for arrays. For LSP plugins, prefer vim.lsp.json or the streaming parser in newer Neovim builds when handling very large notifications. Wrap decode calls in pcall when parsing user-provided config to avoid crashing the editor on syntax errors.

Further reading and primary sources