JSON in Lua: cjson encode/decode, dkjson, OpenResty, Redis Scripts & Roblox
Last updated:
Lua JSON handling centers on two libraries: cjson (C extension, fastest, bundled with OpenResty and nginx) and dkjson (pure Lua, no C dependency, works in any Lua environment). cjson.encode(table) serializes a Lua table to a JSON string; cjson.decode(str) parses JSON to a Lua table. Lua arrays (sequential integer keys starting at 1) serialize to JSON arrays; tables with string keys serialize to JSON objects — mixing both causes an encode error. require "cjson.safe" returns nil, err instead of raising on invalid input. In Redis Lua scripts, cjson is pre-loaded in the sandbox — redis.call("SET", key, cjson.encode(data)) stores JSON without external dependencies. Roblox uses HttpService:JSONEncode and HttpService:JSONDecode as built-in wrappers. This guide covers cjson and dkjson APIs, Lua table-to-JSON mapping rules, safe error handling, OpenResty JSON responses, Redis Lua JSON, and Roblox JSON patterns.
cjson: Encoding and Decoding JSON in Lua
lua-cjson is the standard JSON library for Lua environments with C extension support. It is bundled with OpenResty, available via LuaRocks (luarocks install lua-cjson), and included in many Lua distributions. The API has two require paths: require "cjson" raises Lua errors on failure, and require "cjson.safe" returns nil, error_message — prefer the safe variant at runtime boundaries.
-- Install: luarocks install lua-cjson
local cjson = require "cjson"
-- ── Encoding: Lua table → JSON string ─────────────────────────
local user = {
id = 1,
name = "Alice",
email = "alice@example.com",
active = true,
}
local json_str = cjson.encode(user)
print(json_str)
-- {"id":1,"name":"Alice","email":"alice@example.com","active":true}
-- ── Encoding an array (sequential integer keys) ────────────────
local colors = { "red", "green", "blue" }
print(cjson.encode(colors))
-- ["red","green","blue"]
-- ── Decoding: JSON string → Lua table ─────────────────────────
local data = cjson.decode('{"name":"Bob","scores":[10,20,30]}')
print(data.name) -- Bob
print(data.scores[1]) -- 10 (Lua arrays are 1-indexed)
print(data.scores[2]) -- 20
-- ── Decoding a JSON array ──────────────────────────────────────
local arr = cjson.decode('[1, 2, 3, 4, 5]')
print(arr[1], arr[5]) -- 1 5
print(#arr) -- 5
-- ── Null handling ─────────────────────────────────────────────
-- JSON null decodes to cjson.null (a userdata sentinel)
local obj = cjson.decode('{"value":null}')
print(obj.value == cjson.null) -- true
-- Encode cjson.null back to null:
print(cjson.encode({ value = cjson.null }))
-- {"value":null}
-- ── Nested structures ─────────────────────────────────────────
local order = {
id = "ORD-001",
total = 99.99,
items = {
{ sku = "A", qty = 2, price = 19.99 },
{ sku = "B", qty = 1, price = 59.99 + 0.02 },
},
}
print(cjson.encode(order))
-- {"id":"ORD-001","total":99.99,"items":[{"sku":"A","qty":2,"price":19.99},...]}
-- ── Configuration ─────────────────────────────────────────────
-- Per-instance configuration (OpenResty 1.19+ / cjson 2.1.0+)
local cjson_instance = cjson.new()
cjson_instance.encode_max_depth(100) -- default: 23
cjson_instance.decode_max_depth(100)
cjson_instance.encode_number_precision(14) -- decimal digits: default 14cjson.encode produces compact JSON with no whitespace. There is no built-in pretty-print option in cjson — for debugging, pipe through jq or use dkjson which accepts an indent option. The cjson.null sentinel is a special userdata value that round-trips through encode/decode as JSON null. Lua's nil cannot be placed in a table (it removes the key), so cjson.null is the correct way to represent nullable JSON fields in Lua.
Lua Tables to JSON: Arrays vs Objects
Lua's single table type serves as both array and hash map. cjson infers the JSON type from the table's key structure: a table with only consecutive integer keys from 1 to #t encodes as a JSON array; any other key pattern encodes as a JSON object. Mixed tables (both integer and string keys) raise an error by default — you must normalize them before encoding.
local cjson = require "cjson"
-- ── Array: sequential integer keys 1..N ───────────────────────
local arr = { 10, 20, 30 }
print(cjson.encode(arr)) -- [10,20,30]
-- Explicit integer keys — still an array if consecutive from 1
local arr2 = { [1] = "a", [2] = "b", [3] = "c" }
print(cjson.encode(arr2)) -- ["a","b","c"]
-- ── Object: string keys ────────────────────────────────────────
local obj = { x = 1, y = 2, z = 3 }
print(cjson.encode(obj)) -- {"x":1,"y":2,"z":3}
-- ── Gap in integer keys → treated as object (sparse) ──────────
-- WARNING: this can error with cjson default settings
local sparse = { [1] = "a", [3] = "c" }
-- cjson will raise: "table is too sparse (use cjson.array_mt)"
-- Fix: convert gaps to cjson.null or use cjson.array_mt
-- ── Force array encoding with array_mt ────────────────────────
local forced = { [1] = "a", [3] = "c" }
setmetatable(forced, cjson.array_mt)
-- Now encodes as: ["a",null,"c"] (gap filled with null)
-- ── Empty table: ambiguous — defaults to object {} ────────────
print(cjson.encode({})) -- {} (empty object)
-- Force empty array:
print(cjson.encode(setmetatable({}, cjson.empty_array_mt)))
-- []
-- ── Mixed table: error ─────────────────────────────────────────
-- local mixed = { "a", key = "b" }
-- cjson.encode(mixed) → ERROR: mixed array/object
-- Fix mixed: iterate and build a clean object
local mixed_raw = { "first", "second", label = "test" }
local clean = { label = mixed_raw.label, items = {} }
for i = 1, #mixed_raw do
clean.items[i] = mixed_raw[i]
end
print(cjson.encode(clean))
-- {"label":"test","items":["first","second"]}
-- ── Decoding arrays always uses integer keys ────────────────────
local decoded = cjson.decode('[true, false, null]')
print(decoded[1]) -- true
print(decoded[2]) -- false
print(decoded[3] == cjson.null) -- true
-- ── Iterating decoded arrays ───────────────────────────────────
local tags = cjson.decode('["lua","json","openresty"]')
for i, tag in ipairs(tags) do
print(i, tag) -- 1 lua / 2 json / 3 openresty
end
-- ── Iterating decoded objects ──────────────────────────────────
local config = cjson.decode('{"host":"localhost","port":6379}')
for k, v in pairs(config) do
print(k, v) -- host localhost / port 6379
endThe distinction between array and object encoding matters in APIs: a JSON array and a JSON object have different semantics for clients. Always design your Lua tables with consistent key types — either all integer keys (array semantics) or all string keys (object semantics). If your data is genuinely mixed, separate it into an items array and a metadata object before encoding. The cjson.array_mt metatable is the standard escape hatch for sparse arrays that must be preserved as arrays.
Safe Error Handling with cjson.safe
Production code parsing external JSON should never let a malformed payload crash the process. require "cjson.safe" returns the same cjson module but with encode and decode returning nil, error_message instead of raising. This is the idiomatic Lua error-handling pattern and avoids pcall boilerplate.
-- ── cjson.safe: nil-returning variant ────────────────────────
local cjson_safe = require "cjson.safe"
-- ── Safe decode ────────────────────────────────────────────────
local function parse_json(str)
local data, err = cjson_safe.decode(str)
if data == nil then
return nil, "JSON parse error: " .. tostring(err)
end
return data
end
local ok_data, ok_err = parse_json('{"name":"Alice","age":30}')
print(ok_data.name) -- Alice
print(ok_err) -- nil
local bad_data, bad_err = parse_json('{"name": "Alice",}') -- trailing comma
print(bad_data) -- nil
print(bad_err) -- JSON parse error: Expected value but found invalid token at ...
-- ── Safe encode ────────────────────────────────────────────────
local ok_str, enc_err = cjson_safe.encode({ name = "Bob", score = 100 })
print(ok_str) -- {"name":"Bob","score":100}
-- Encode with unsupported type (e.g., a function)
local function my_fn() end
local fail_str, fail_err = cjson_safe.encode({ fn = my_fn })
print(fail_str) -- nil
print(fail_err) -- Cannot serialise function: type not supported
-- ── Alternative: pcall with cjson (non-safe) ──────────────────
local cjson = require "cjson"
local function safe_decode(str)
local ok, result = pcall(cjson.decode, str)
if not ok then
return nil, result -- result is the error message in pcall failure
end
return result
end
-- ── dkjson: built-in error handling ───────────────────────────
local dkjson = require "dkjson"
local data, pos, err = dkjson.decode('{"key": "value"}')
if err then
print("Error at position", pos, ":", err)
else
print(data.key) -- value
end
local bad, pos2, err2 = dkjson.decode('{invalid}')
if err2 then
print("Parse failed at pos", pos2, ":", err2)
-- Parse failed at pos 2 : ...
end
-- ── Validation pattern: check required fields after decode ────
local function decode_user(json_str)
local data, err = cjson_safe.decode(json_str)
if not data then
return nil, err
end
if type(data.id) ~= "number" then
return nil, "missing or invalid field: id"
end
if type(data.name) ~= "string" or #data.name == 0 then
return nil, "missing or invalid field: name"
end
return data
end
local user, user_err = decode_user('{"id":1,"name":"Alice"}')
print(user.name) -- Alice
local _, user_err2 = decode_user('{"name":"Bob"}')
print(user_err2) -- missing or invalid field: idThe safe variant is zero-overhead compared to pcall — it is the same C code path with the error handler changed. Use cjson.safe consistently in any code that parses user-controlled or network-originated JSON. After decoding, always validate the structure: check that expected keys exist and have the correct types before accessing nested values.
dkjson: Pure-Lua JSON for Any Environment
dkjson (David Kolf's JSON module) is a single-file pure-Lua JSON library compatible with Lua 5.1 through 5.4. Install with luarocks install dkjson or drop dkjson.lua directly into your project. It is the correct choice when C extensions are unavailable — embedded Lua, sandboxed environments, or standalone scripts without LuaRocks.
-- Install: luarocks install dkjson
-- Or: copy dkjson.lua to your project directory
local dkjson = require "dkjson"
-- ── Encoding ──────────────────────────────────────────────────
local data = {
name = "Charlie",
scores = { 85, 92, 78 },
meta = { level = 3, active = true },
}
-- Compact (default)
local compact = dkjson.encode(data)
print(compact)
-- {"meta":{"active":true,"level":3},"name":"Charlie","scores":[85,92,78]}
-- Pretty-printed (indent parameter)
local pretty = dkjson.encode(data, { indent = true })
print(pretty)
-- {
-- "meta": {
-- "active": true,
-- "level": 3
-- },
-- "name": "Charlie",
-- "scores": [
-- 85,
-- 92,
-- 78
-- ]
-- }
-- Custom indent string (e.g., 2 spaces)
local pretty2 = dkjson.encode(data, { indent = " " })
-- Sort keys for deterministic output
local sorted = dkjson.encode(data, { keyorder = { "name", "scores", "meta" } })
-- ── Decoding ──────────────────────────────────────────────────
local json_str = '{"id":42,"tags":["lua","json"],"active":null}'
local result, pos, err = dkjson.decode(json_str)
if err then
print("Error at pos " .. pos .. ": " .. err)
else
print(result.id) -- 42
print(result.tags[1]) -- lua
print(result.active) -- nil (JSON null → Lua nil in dkjson by default)
end
-- ── Null handling in dkjson ────────────────────────────────────
-- dkjson decodes JSON null as Lua nil by default
-- To preserve null distinction, use the "null" option:
local dkjson_null = dkjson.decode(
'{"a":null,"b":1}',
1, -- start position
nil, -- options
dkjson.null -- sentinel value for JSON null
)
-- dkjson_null.a is dkjson.null (not Lua nil)
-- dkjson_null.b is 1
-- ── Streaming with lpeg (optional, faster) ────────────────────
-- If lpeg is installed, dkjson uses it automatically for better performance:
-- luarocks install lpeg
-- Then dkjson uses lpeg patterns for tokenization (≈2× faster decode)
-- dkjson.use_lpeg() -- explicitly activate if needed
-- ── Encoding special values ────────────────────────────────────
-- dkjson.null for JSON null:
local with_null = dkjson.encode({ value = dkjson.null, count = 0 })
print(with_null)
-- {"count":0,"value":null}
-- Empty array:
local empty_arr = dkjson.encode(setmetatable({}, { __tostring = function() return "[]" end }))
-- Better: use a wrapper
local function array(t)
return setmetatable(t or {}, { __is_array = true })
end
-- For empty arrays, dkjson encodes {} as {}
-- Workaround: pass explicit keyorder or use userdata sentinel
-- ── dkjson.use_number ─────────────────────────────────────────
-- Control number precision (Lua 5.3+ only)
-- dkjson.use_number = true -- decode numbers as "number" userdata preserving precisiondkjson's indent option makes it uniquely useful for generating human-readable JSON configuration files or debug output — cjson offers no indentation. The keyorder option produces deterministic output by specifying the order of object keys, which matters for checksums or snapshot testing. When the lpeg library is available, dkjson uses it automatically for approximately 2× faster tokenization while remaining pure-Lua compatible.
OpenResty: JSON HTTP Responses in nginx
OpenResty embeds LuaJIT and cjson into nginx, making JSON API development straightforward. cjson is available as a global and also via require "cjson". The standard pattern for a JSON API endpoint uses content_by_lua_block in the nginx configuration.
-- ── nginx.conf: basic JSON API endpoint ──────────────────────
--
-- server {
-- listen 8080;
-- location /api/user {
-- content_by_lua_block {
-- local cjson = require "cjson.safe"
--
-- -- Parse query string param
-- local args = ngx.req.get_uri_args()
-- local user_id = tonumber(args.id)
--
-- if not user_id then
-- ngx.status = 400
-- ngx.header["Content-Type"] = "application/json"
-- ngx.say(cjson.encode({ error = "Missing or invalid 'id' param" }))
-- return
-- end
--
-- -- Build response
-- local user = { id = user_id, name = "Alice", role = "admin" }
-- ngx.header["Content-Type"] = "application/json; charset=utf-8"
-- ngx.say(cjson.encode(user))
-- }
-- }
-- }
-- ── Parse JSON request body ───────────────────────────────────
-- In content_by_lua_block:
local function read_json_body()
local cjson_safe = require "cjson.safe"
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body or body == "" then
return nil, "empty body"
end
local data, err = cjson_safe.decode(body)
if not data then
return nil, "invalid JSON: " .. tostring(err)
end
return data
end
-- ── POST endpoint example ─────────────────────────────────────
-- local cjson = require "cjson.safe"
-- local data, err = read_json_body()
-- if not data then
-- ngx.status = 400
-- ngx.header["Content-Type"] = "application/json"
-- ngx.say(cjson.encode({ error = err }))
-- return
-- end
-- -- Validate required fields
-- if not data.name or type(data.name) ~= "string" then
-- ngx.status = 422
-- ngx.header["Content-Type"] = "application/json"
-- ngx.say(cjson.encode({ error = "name is required and must be a string" }))
-- return
-- end
-- -- Success
-- ngx.status = 201
-- ngx.header["Content-Type"] = "application/json"
-- ngx.say(cjson.encode({ id = 1, name = data.name, created = true }))
-- ── Shared memory JSON cache (ngx.shared.DICT) ────────────────
-- nginx.conf: lua_shared_dict json_cache 10m;
--
-- local cache = ngx.shared.json_cache
-- local cached = cache:get("config")
-- if not cached then
-- local fresh = { timeout = 30, retries = 3, debug = false }
-- cache:set("config", cjson.encode(fresh), 60) -- TTL 60 seconds
-- cached = cjson.encode(fresh)
-- end
-- local config = cjson.decode(cached)
-- ngx.say("timeout: " .. config.timeout)
-- ── Cosocket HTTP client: fetch + parse external JSON ─────────
-- local http = require "resty.http"
-- local httpc = http.new()
-- local res, err = httpc:request_uri("https://api.example.com/data", {
-- method = "GET",
-- headers = { ["Accept"] = "application/json" },
-- ssl_verify = false,
-- })
-- if not res then
-- ngx.log(ngx.ERR, "HTTP error: ", err)
-- ngx.exit(502)
-- end
-- local payload = cjson_safe.decode(res.body)
-- ngx.say(payload.field)In OpenResty, always call ngx.req.read_body() before ngx.req.get_body_data() — nginx does not buffer the request body by default. For high-throughput APIs, pre-encode static JSON responses during initialization and cache them as Lua strings to avoid re-encoding on every request. The ngx.shared.DICT shared memory zone is ideal for caching decoded JSON config that multiple worker processes need.
Redis Lua Scripts: JSON in the Sandbox
Redis embeds a Lua sandbox for EVAL and EVALSHA commands. The cjson library is pre-loaded in that sandbox — no require needed, though it still works. Redis Lua scripts execute atomically, making them ideal for compare-and-swap operations on JSON values stored in Redis keys.
-- ── Basic JSON store/retrieve pattern ────────────────────────
-- EVAL usage from redis-cli:
-- EVAL <script> <num_keys> [key [key ...]] [arg [arg ...]]
-- Store JSON object in Redis
-- EVAL "redis.call('SET', KEYS[1], cjson.encode({score=tonumber(ARGV[1]), ts=ARGV[2]})); return 1" 1 user:42 950 "2026-05-28"
-- ── Atomic increment of a JSON field ──────────────────────────
local increment_score_script = [[
local raw = redis.call("GET", KEYS[1])
local data
if raw then
data = cjson.decode(raw)
else
data = { score = 0, updated = "" }
end
data.score = data.score + tonumber(ARGV[1])
data.updated = ARGV[2]
redis.call("SET", KEYS[1], cjson.encode(data))
return data.score
]]
-- EVAL <increment_score_script> 1 user:42 10 "2026-05-28T12:00:00Z"
-- ── Rate limiter with JSON metadata ───────────────────────────
local rate_limit_script = [[
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- seconds
local now = tonumber(ARGV[3])
local raw = redis.call("GET", key)
local state
if raw then
state = cjson.decode(raw)
if now - state.reset_at >= window then
-- Window expired, reset
state = { count = 0, reset_at = now }
end
else
state = { count = 0, reset_at = now }
end
state.count = state.count + 1
local allowed = state.count <= limit
redis.call("SET", key, cjson.encode(state))
redis.call("EXPIRE", key, window * 2)
-- Return { allowed, current_count, limit }
return { allowed and 1 or 0, state.count, limit }
]]
-- EVAL <rate_limit_script> 1 ratelimit:ip:1.2.3.4 100 60 1716897600
-- ── cjson.safe in Redis sandbox ───────────────────────────────
-- cjson.safe is available as a table field:
local safe_decode_script = [[
local raw = redis.call("GET", KEYS[1])
if not raw then return nil end
local data, err = cjson.safe.decode(raw)
if not data then
redis.log(redis.LOG_WARNING, "JSON decode error: " .. tostring(err))
return nil
end
return data.field
]]
-- ── EVALSHA: pre-load script for production ───────────────────
-- Load script once: SCRIPT LOAD "<script>" → returns SHA1
-- Execute repeatedly: EVALSHA <sha1> <num_keys> ...
-- Benefit: avoids sending script text on every call
-- ── Lua table to Redis Hash mapping ───────────────────────────
local store_fields_script = [[
local data = cjson.decode(ARGV[1])
-- Store each JSON field as a Redis hash field
for k, v in pairs(data) do
local val
if type(v) == "table" then
val = cjson.encode(v) -- nested → JSON string
else
val = tostring(v)
end
redis.call("HSET", KEYS[1], k, val)
end
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))
return redis.call("HLEN", KEYS[1])
]]Redis Lua scripts run in a deterministic, sandboxed environment — no file I/O, no network calls, no os or io libraries. The available libraries are cjson, cjson.safe, cmsgpack, bit, and the Redis API (redis.call, redis.pcall, redis.log). Use EVALSHA in production to avoid re-transmitting script text on every call — load the script once with SCRIPT LOAD and reuse the SHA1 hash.
Roblox: HttpService JSONEncode and JSONDecode
Roblox uses a modified Lua 5.1 runtime called Luau. JSON is handled through the HttpService class, which provides JSONEncode and JSONDecode as built-in wrappers around a C JSON library. This is not cjson or dkjson — it is Roblox-specific and unavailable outside the Roblox engine.
-- ── Get HttpService ───────────────────────────────────────────
local HttpService = game:GetService("HttpService")
-- ── Encode: Lua table → JSON string ───────────────────────────
local data = {
name = "Player1",
score = 1500,
inventory = { "sword", "shield", "potion" },
settings = { music = true, sfx = false },
}
local json_str = HttpService:JSONEncode(data)
print(json_str)
-- {"name":"Player1","score":1500,"inventory":["sword","shield","potion"],...}
-- ── Decode: JSON string → Lua table ───────────────────────────
local decoded = HttpService:JSONDecode(json_str)
print(decoded.name) -- Player1
print(decoded.score) -- 1500
print(decoded.inventory[1]) -- sword (1-indexed)
-- ── Error handling ────────────────────────────────────────────
local ok, result = pcall(function()
return HttpService:JSONDecode('{"invalid": ,}') -- syntax error
end)
if not ok then
warn("JSON decode failed: " .. tostring(result))
end
-- ── DataStore persistence with JSON ───────────────────────────
-- DataStore stores strings; JSON is the standard encoding for tables
local DataStoreService = game:GetService("DataStoreService")
local playerStore = DataStoreService:GetDataStore("PlayerData")
local function savePlayerData(userId, data)
local ok, err = pcall(function()
playerStore:SetAsync(tostring(userId), HttpService:JSONEncode(data))
end)
if not ok then
warn("Save failed for " .. userId .. ": " .. tostring(err))
end
end
local function loadPlayerData(userId)
local ok, raw = pcall(function()
return playerStore:GetAsync(tostring(userId))
end)
if not ok or not raw then
return { score = 0, level = 1, inventory = {} } -- default
end
local parsed_ok, data = pcall(HttpService.JSONDecode, HttpService, raw)
if not parsed_ok then
return { score = 0, level = 1, inventory = {} } -- fallback on corrupt data
end
return data
end
-- ── HTTP API request + JSON response ──────────────────────────
-- Requires: Game Settings → Security → Allow HTTP Requests = ON
local function fetchLeaderboard()
local ok, res = pcall(function()
return HttpService:RequestAsync({
Url = "https://api.example.com/leaderboard",
Method = "GET",
Headers = { ["Content-Type"] = "application/json" },
})
end)
if not ok then
warn("HTTP error: " .. tostring(res))
return nil
end
if not res.Success then
warn("HTTP " .. res.StatusCode)
return nil
end
-- res.Body is already a string; decode it
local parse_ok, data = pcall(HttpService.JSONDecode, HttpService, res.Body)
if not parse_ok then
warn("Invalid JSON response")
return nil
end
return data
end
-- ── POST JSON payload ──────────────────────────────────────────
local function reportEvent(event_data)
local body = HttpService:JSONEncode(event_data)
local ok, res = pcall(function()
return HttpService:RequestAsync({
Url = "https://api.example.com/events",
Method = "POST",
Headers = { ["Content-Type"] = "application/json" },
Body = body,
})
end)
return ok and res.Success
end
-- ── JSONEncode limitations ────────────────────────────────────
-- These types cause JSONEncode to error:
-- Roblox Instance (Part, Player, etc.)
-- Functions
-- Coroutines
-- Userdata (non-serializable)
-- Convert these before encoding:
local player = game.Players:GetPlayers()[1]
if player then
local safe_data = {
name = player.Name, -- string ✓
userId = player.UserId, -- number ✓
-- player itself would error
}
print(HttpService:JSONEncode(safe_data))
endRoblox DataStore values have a 4 MB size limit (4,194,304 characters) as of 2024. For large player data, split it across multiple DataStore keys rather than encoding one giant JSON blob. Always wrap both JSONEncode/JSONDecode and DataStore calls in pcall — network errors and data corruption are common in live games. Use UpdateAsync instead of SetAsync for concurrent writes to avoid data loss from race conditions.
FAQ
How do I encode a Lua table to JSON?
Use cjson.encode(table) to serialize a Lua table to a JSON string. Install lua-cjson (available via LuaRocks: luarocks install lua-cjson) and then call local cjson = require "cjson" followed by local json_str = cjson.encode({ name = "Alice", age = 30 }). The result is the string {"name":"Alice","age":30}. For pure-Lua environments without C extensions, use dkjson: local dkjson = require "dkjson" and local json_str = dkjson.encode({ name = "Alice", age = 30 }). In OpenResty, cjson is bundled and available as a global — no require needed — though require "cjson" still works. cjson.encode is typically 3–5x faster than dkjson.encode for large tables because it is implemented in C. For arrays, make sure your table uses sequential integer keys starting at 1; mixed tables (both integer and string keys) will cause a cjson.encode error.
How do I parse a JSON string in Lua?
Use cjson.decode(str) to parse a JSON string into a Lua table. After local cjson = require "cjson", call local data = cjson.decode('{"name":"Alice","age":30}'). Access fields with data.name ("Alice") and data.age (30). For JSON arrays like '[1,2,3]', the result is a Lua table with integer keys: data[1] == 1. cjson.decode raises a Lua error on invalid JSON — wrap it in pcall for error handling: local ok, result = pcall(cjson.decode, json_str). For production code, prefer require "cjson.safe" which returns nil, error_message instead of raising, avoiding the need for pcall. dkjson.decode(str) follows a similar pattern and returns nil plus an error position and message on failure, so error handling is built in without pcall.
How does Lua handle JSON arrays vs objects?
Lua has only one data structure — the table — which can act as both an array (sequential integer keys starting at 1) and a hash map (string keys). cjson maps JSON arrays to Lua tables with integer keys: ["a","b","c"] becomes {[1]="a",[2]="b",[3]="c"}. JSON objects map to Lua tables with string keys: {"x":1} becomes {x=1}. When encoding, cjson checks if a table is a sequence (all keys are consecutive integers from 1 to #t): if yes, it emits a JSON array; if no, it emits a JSON object. A mixed table with both integer and string keys causes a cjson.encode error by default. You can explicitly annotate tables using cjson.empty_array_mt for empty arrays or cjson.array_mt to force array encoding: setmetatable(t, cjson.array_mt). The #t operator in Lua counts only consecutive integer keys, so tables with gaps can cause unexpected behavior during both length calculation and JSON serialization.
What is the difference between cjson and dkjson?
cjson (lua-cjson) is a C extension that links against a fast JSON parser. It is bundled with OpenResty, nginx-lua, and many Lua runtimes. cjson is the fastest Lua JSON library — typically 3–10x faster than pure-Lua alternatives for both encoding and decoding. Its API uses require "cjson" for the throwing variant and require "cjson.safe" for the nil-returning variant. cjson does not support trailing commas or comments in JSON. dkjson is a pure-Lua library (100% Lua, no C dependency) written by David Kolf, available via LuaRocks: luarocks install dkjson. It works in any Lua 5.1–5.4 environment including sandboxed or embedded runtimes where C extensions are not available. dkjson.decode returns nil, pos, error_message on parse failure — no pcall needed. dkjson also supports a callback-based streaming API and an indent option for pretty-printing. Choose cjson when performance matters and C extensions are allowed; choose dkjson for maximum portability.
How do I handle JSON parse errors in Lua safely?
Use require "cjson.safe" instead of require "cjson" to get the safe variant of the cjson library. cjson.safe follows the same API but returns nil, error_message on failure instead of raising a Lua error. Example: local cjson_safe = require "cjson.safe" and then local data, err = cjson_safe.decode(json_str). If data is nil, err contains the error message. This avoids wrapping every decode call in pcall. For the non-safe cjson, use pcall: local ok, data = pcall(cjson.decode, json_str). If ok is false, data contains the error message. For dkjson, error handling is built in: local data, pos, err = dkjson.decode(json_str) — data is nil on failure, pos is the character position of the error, and err is the error message. In OpenResty, prefer cjson.safe for request body parsing where the input might be malformed, and log the error with ngx.log(ngx.ERR, err) before returning a 400 status.
How do I use JSON in a Redis Lua script?
Redis embeds a Lua 5.1 sandbox for EVAL and EVALSHA commands, and cjson is pre-loaded in that sandbox — no require statement is needed. You can call cjson.encode and cjson.decode directly. A typical pattern: store a JSON blob with redis.call("SET", KEYS[1], cjson.encode({score = tonumber(ARGV[1])})), then retrieve and decode it: local raw = redis.call("GET", KEYS[1]) and local data = cjson.decode(raw). The Redis Lua sandbox also provides cjson.safe, so local data, err = cjson.safe.decode(raw) works without pcall. Note that Redis Lua scripts cannot call external HTTP APIs, use coroutines, or access the filesystem. EVALSHA executes pre-loaded scripts identified by SHA1 hash, reducing per-call overhead vs EVAL. Lua scripts in Redis execute atomically — no other command runs between instructions, making them suitable for compare-and-swap operations on JSON values. As of Redis 7.x, Lua 5.4 is available with OBJECT ENCODING lua54.
How do I return JSON from an OpenResty/nginx handler?
In OpenResty (nginx + ngx_lua), set the Content-Type header and write the JSON body in a content_by_lua_block. The minimal pattern is: ngx.header["Content-Type"] = "application/json; charset=utf-8" followed by local data = { status = "ok", count = 42 } and ngx.say(cjson.encode(data)). cjson is available as a global in OpenResty — require "cjson" also works and is preferred for clarity. For error responses, combine ngx.status = 400 with ngx.say(cjson.encode({ error = "Bad request" })). Use cjson.safe for decoding request bodies: call ngx.req.read_body() first, then local body = ngx.req.get_body_data() and local data, err = cjson_safe.decode(body). If data is nil, return a 400 response. OpenResty 1.19+ ships with lua-cjson 2.1.0 which supports cjson.new() for per-instance configuration including max_depth and max_string_length. For high-throughput APIs, pre-allocate response tables outside the request handler and reuse them where safe, since cjson.encode is fast but table allocation is the dominant cost at 100k+ req/s.
How do I use JSON in Roblox Lua?
Roblox uses a modified Lua 5.1 environment (Luau) and provides JSON through the HttpService: local HttpService = game:GetService("HttpService") and then local json_str = HttpService:JSONEncode({ name = "Player1", score = 500 }) for encoding, or local data = HttpService:JSONDecode(json_str) for decoding. HttpService:JSONEncode and JSONDecode are built-in wrappers around a C JSON library — they are not cjson or dkjson. JSONEncode errors on unsupported types such as Roblox Instances, functions, or mixed tables. JSONDecode errors on invalid JSON. Wrap both in pcall for safety: local ok, result = pcall(function() return HttpService:JSONDecode(json_str) end). Roblox uses JSON to communicate with external APIs via HttpService:RequestAsync, which automatically makes the response Body a string that must be decoded. Note: HttpService requires HTTP Requests to be enabled in Game Settings. DataStore values are stored as serialized JSON strings — use JSONEncode before calling DataStore:SetAsync and JSONDecode after GetAsync to store complex data structures. The max DataStore value size is 4 MB (4,194,304 characters) as of 2024.
Further reading and primary sources
- lua-cjson Documentation — Official lua-cjson API reference: encode/decode, configuration, and null handling
- dkjson on LuaRocks — dkjson module page with installation instructions, API reference, and changelog
- OpenResty Reference Manual: ngx_lua — OpenResty directives and API for content_by_lua_block, ngx.say, ngx.req, and shared memory
- Redis EVAL Command Reference — Redis EVAL and EVALSHA documentation: Lua scripting, cjson, atomicity, and SCRIPT LOAD
- Roblox HttpService API — Roblox Developer Hub reference for JSONEncode, JSONDecode, and RequestAsync