Plugin Development Best Practices
This guide collects the coding practices that make a custom Lua plugin behave like a first-class, built-in plugin. That means predictable configuration, correct request and response handling, and good performance under load.
The single most important habit is to build on the apisix.core library rather than call the raw OpenResty (ngx.*) APIs directly. Every built-in plugin does this. apisix.core is the gateway's plugin toolkit. It wraps the low-level OpenResty primitives with request-scoped caching, consistent error handling, and helpers that already solve the edge cases you would otherwise have to rediscover.
local core = require("apisix.core")
Use apisix.core, Not Raw ngx APIs
Raw ngx.* calls often work in a quick test but cause subtle problems in production. core.* wrappers exist because they:
- Cache request-scoped reads.
core.request.headers(ctx)andctx.var.<name>compute a value once and reuse it for the rest of the request, while repeatedngx.req.get_headers()calls re-parse every time. - Stay consistent with the rest of the gateway. When one plugin sets a header through
core.request.set_header, the cached view other plugins read stays correct. Mixing rawngx.req.set_headercan leave stale cached values behind. - Handle the awkward defaults for you. For example,
core.request.get_uri_argsreads arguments with no truncation limit, whereasngx.req.get_uri_args()silently caps at 100 arguments. - Centralize behavior. Logging, JSON encoding, and schema validation all run through one place, so level filtering, error handling, and formatting are uniform.
ngx to core Mapping
Reach for the core equivalent first. The table below covers the substitutions you will make most often.
Raw ngx API | Preferred core API | Notes |
|---|---|---|
ngx.log(ngx.ERR, ...) | core.log.error(...) (.warn, .info, .debug) | Level is filtered automatically; pass multiple arguments instead of concatenating. |
ngx.req.get_headers() | core.request.headers(ctx) | Cached on ctx; keys are lower-cased for case-insensitive lookup. |
ngx.req.get_headers()[name] | core.request.header(ctx, name) | Case-insensitive; unwraps multi-value headers. |
ngx.req.set_header(k, v) | core.request.set_header(ctx, k, v) | Also invalidates the cached header view. |
ngx.req.get_uri_args() | core.request.get_uri_args(ctx) | No truncation limit; result is cached. |
ngx.req.set_uri_args(a) | core.request.set_uri_args(ctx, a) | Accepts a table or string. |
ngx.req.read_body() + ngx.req.get_body_data() | core.request.get_body() | Also reads a body buffered to a temp file; may still return nil. |
ngx.req.get_method() | core.request.get_method() | |
ngx.var.<name> | ctx.var.<name> | Lazy and cached; also exposes gateway variables such as route_id and consumer_name. |
ngx.header[k] = v | core.response.set_header(k, v) | Guards against headers that were already sent. |
ngx.say / ngx.print / ngx.exit | core.response.exit(code, body) | A table body is JSON-encoded; the call must be returned. |
cjson.encode / cjson.decode | core.json.encode / core.json.decode | decode returns nil, err on bad input. |
cjson.encode inside a log line | core.json.delay_encode(data) | Encodes only if the log line is actually emitted. |
| JSON Schema validation | core.schema.check(schema, conf) | Caches the compiled validator. |
table.insert / table.new / table.clear | core.table.insert / core.table.new / core.table.clear | core.table also proxies the standard table library. |
ngx.timer.every (recurring job) | core.timer.new(name, cb, opts) | Runs the callback under pcall for error isolation. |
The Core Library at a Glance
core groups its helpers into sub-modules. These are the ones plugin authors use most:
| Sub-module | Purpose |
|---|---|
core.request | Read and modify the incoming client request. |
core.response | Set response headers and short-circuit the request. |
core.ctx / ctx.var | Access request context and Nginx or gateway variables. |
core.log | Level-aware logging. |
core.json | Encode and decode JSON, including lazy encoding for logs. |
core.schema | Validate plugin configuration against a JSON schema. |
core.table | Table helpers: allocate ahead of time, clear, count, deep copy, merge. |
core.string | Fast plain-text string helpers (prefix, suffix, find). |
core.utils | Miscellaneous helpers: variable substitution, DNS, UUID. |
core.lrucache | Per-worker LRU cache, keyed to a plugin configuration. |
core.timer | Managed background timers. |
Read and Modify the Request
Most functions in core.request take ctx as the first argument.
function _M.access(conf, ctx)
-- Read a header (case-insensitive) and a query argument.
local token = core.request.header(ctx, "Authorization")
local args = core.request.get_uri_args(ctx)
-- Set a request header before the request is proxied upstream.
core.request.set_header(ctx, "X-Consumer-Tier", conf.tier)
end
Key functions:
core.request.header(ctx, name)— a single header value, case-insensitive.core.request.headers(ctx)— all headers as a table with lower-cased keys.core.request.set_header(ctx, name, value)— set a header; passvalue = nilto remove it.core.request.get_uri_args(ctx)— parsed query arguments.core.request.get_body()— the raw request body as a string. It may returnnil(for example, when there is no body), so always check before using it.core.request.get_method()— the HTTP method.core.request.get_remote_client_ip(ctx)— the downstream client IP.
Reading the request body as JSON is common enough to have a dedicated helper:
local body, err = core.request.get_json_request_body_table()
if not body then
core.log.warn("failed to read JSON body: ", core.json.delay_encode(err))
return 400, { message = "invalid request body" }
end
Set the Response and Short-Circuit
Header setters on core.response do not take ctx.
To stop a request and return a response from within a plugin, return a status code and body. The plugin runner turns the returned tuple into a response for you, so you rarely call core.response.exit directly:
function _M.access(conf, ctx)
if not is_authorized(ctx) then
core.response.set_header("WWW-Authenticate", "ApiKey")
return 401, { message = "unauthorized" }
end
end
A table body is automatically JSON-encoded. If you call core.response.exit yourself, you must return its result:
return core.response.exit(429, { message = "rate limit exceeded" })
To modify response headers in the header_filter phase, use core.response.set_header and core.response.add_header. If your plugin rewrites the response body, call core.response.clear_header_as_body_modified() in header_filter so that Content-Length, Content-Encoding, ETag, and Last-Modified are cleared before the client sees a body that no longer matches them.
Access Context Variables
ctx.var.<name> reads Nginx variables and gateway-specific variables through one lazy, cached interface. Prefer it over ngx.var:
function _M.log(conf, ctx)
core.log.info("request to ", ctx.var.uri,
" matched route ", ctx.var.route_id,
" for consumer ", ctx.var.consumer_name)
end
Beyond raw Nginx variables, ctx.var exposes gateway values such as route_id, route_name, service_id, service_name, consumer_name, balancer_ip, and balancer_port. Reads are cached after the first access, so repeated lookups are cheap.
Log at the Right Level
core.log provides error, warn, info, and debug (and the rarer notice, crit, alert). The gateway filters messages below the configured error-log level, so a core.log.info call costs almost nothing in production when the level is set to warn.
Pass values as separate arguments rather than concatenating them, and use core.json.delay_encode for tables so the JSON is produced only when the line is actually written:
-- Good: no work happens if info logging is disabled.
core.log.info("plugin conf: ", core.json.delay_encode(conf))
-- Avoid: encodes and concatenates on every request, even when the line is dropped.
core.log.info("plugin conf: " .. core.json.encode(conf))
Log at error for conditions that need operator attention, warn for recoverable problems, and info or debug for diagnostics.
Encode and Decode JSON
core.json.encode(data)— encode to a JSON string. Passcore.json.encode(data, true)to force-serialize values that cannot normally be encoded.core.json.decode(str)— decode; returnsnil, erron failure, so check both return values.core.json.delay_encode(data)— lazy encoding for log lines.core.json.canonical_encode(data)— deterministic, key-sorted output, useful for cache keys or signatures.
Work With Tables and Strings
core.table proxies the standard table library and adds helpers:
core.table.new(narr, nrec)— allocate a table in advance when you know its size.core.table.clear(t)— reuse a table without reallocating.core.table.nkeys(t)— count keys.core.table.insert/core.table.insert_tail— append one or several values.core.table.deepcopy(t)andcore.table.clone(t)— copy configuration you must not mutate.core.table.try_read_attr(t, "a", "b", "c")— safely read a nested field, returningnilif any level is missing.
core.string proxies the standard string library and adds fast, plain-text helpers:
core.string.has_prefix(s, prefix)andcore.string.has_suffix(s, suffix).core.string.find(haystack, needle)— a plain-text find, not a Lua pattern match.
Validate Configuration With a Schema
Every plugin must define a JSON schema and a check_schema function. The schema is your plugin's contract: it rejects invalid configuration at write time, applies defaults, and documents the accepted fields.
local schema = {
type = "object",
properties = {
tier = { type = "string", enum = { "free", "pro" }, default = "free" },
timeout = { type = "integer", minimum = 1, default = 30 },
},
required = { "tier" },
}
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
Guidance:
-
Constrain values with
enum,minimum,maximum,minLength,pattern, andrequired. The more the schema enforces, the less defensive code your handlers need. -
Set sensible
defaultvalues so operators only configure what they must. -
Let the schema do the validation. Once a value passed
check_schema, do not re-clamp or re-check it in the handler. -
If your plugin also has consumer or metadata configuration, dispatch on the schema type:
function _M.check_schema(conf, schema_type)
if schema_type == core.schema.TYPE_METADATA then
return core.schema.check(metadata_schema, conf)
end
return core.schema.check(schema, conf)
end
Choose the Right Phase and Priority
Run your logic in the earliest phase that has the data you need:
| Phase | Use it for |
|---|---|
rewrite | Rewriting the URI or request line before route matching completes. |
access | Authentication, authorization, rate limiting, and request mutation. |
before_proxy | Last-moment changes just before the request is sent upstream. |
header_filter | Reading or modifying response headers. |
body_filter | Reading or modifying the response body. |
log | Recording metrics and logs after the response is sent. |
Priority determines the order in which plugins run within a phase: a higher number runs earlier. When your plugin depends on another, set its priority accordingly. For example, a plugin that reads the authenticated consumer must run after the authentication plugin, so it needs a lower priority. Pick a priority that does not collide with the built-in plugins you rely on, and keep it stable once the plugin is in use.
Handle Errors and nil Safely
- Check every value that can be
nil:core.request.get_body(),core.json.decode(), and header reads all can returnnil. - Return a status and body to reject a request cleanly (
return 400, { message = "..." }); do not callngx.exitdirectly. - Do not
error()out of a phase handler for expected conditions. Log the problem and return a controlled response instead, so one bad request does not turn into a500.
Performance Practices
Plugin code runs on every matching request, so small inefficiencies add up:
-
Cache expensive, per-configuration work with
core.lrucache. Compiling a pattern, building a lookup table, or parsing a credential list should happen once per plugin configuration, not once per request:local lrucache = core.lrucache.new({ ttl = 300, count = 512 })
local function create_matcher(conf)
-- expensive: runs once per unique conf
return build_matcher(conf.rules)
end
function _M.access(conf, ctx)
local matcher = lrucache(conf.rules, nil, create_matcher, conf)
-- ...
end -
Do not block the event loop. Never call blocking OS functions or
os.execute. For network or storage access, use non-blocking OpenResty APIs andlua-resty-*libraries. For deliberate delays, usecore.sleep, which yields instead of blocking the worker. -
Reuse tables with
core.table.newandcore.table.clearin hot paths to reduce garbage collection. -
Avoid heavy work in
body_filter. It runs once per response chunk. Buffer only when you must (see below), and keep per-chunk work minimal.
When Raw ngx Is Still Required
core does not wrap everything. These raw APIs are the idiomatic choice and are used throughout the built-in plugins:
-
ngx.arg[1]andngx.arg[2]inbody_filter— the current body chunk and the end-of-stream flag. To work on the full body, buffer chunks withcore.response.hold_body_chunk(ctx), which returnsniluntil the final chunk arrives:function _M.body_filter(conf, ctx)
local body = core.response.hold_body_chunk(ctx)
if not body then
return -- more chunks to come
end
ngx.arg[1] = transform(body)
end -
ngx.re.match/ngx.re.gsub/ngx.re.find— regular expressions. There is nocore.re. -
ngx.shared.<dict>— shared-memory dictionaries for cross-worker state. There is nocore.shared. The dictionary must be declared in the gateway configuration. -
lua-resty-*libraries and non-blocking socket APIs — network clients that yield instead of blocking the worker. -
ngx.timer.at— a one-shot timer is fine to use directly; prefercore.timer.newfor recurring background jobs.
Common Pitfalls
core.response.exitmust be returned.return core.response.exit(...), or return acode, bodytuple. A bare call does not reliably stop the request.core.request.get_body()can returnnileven without an error. Handle the empty-body case.ctx.varwrites are limited. Assigningctx.var.x = vonly updates the real Nginx variable for variables the gateway allows to be written; otherwise it updates the cache only.- Do not
requireone uploaded custom plugin from another. Uploaded plugins are not on the module search path and are not guaranteed to load in dependency order. Put shared code on the Lua package path instead — see Add Dependency Libraries. core.string.findis plain text, not a pattern match, and it errors if the first argument is not a string.- Do not set response headers after the body has started.
core.response.set_headerraises an error once headers have been sent.
Test and Debug
- Check for Lua syntax errors before uploading:
luac -p my-plugin.lua. - Validate behavior on a route, following Test the Plugin.
- Inspect
error.logon the gateway node (by default/usr/local/apisix/logs/error.log) for yourcore.logoutput. - Roll out to a test gateway group first, then promote to production — see Roll Out Across Gateway Groups.
Next Steps
- Develop Custom Lua Plugins — the end-to-end workflow for creating, registering, and testing a plugin.
- Serverless Functions or Custom Plugins — choose the right extension mechanism for your use case.
- Plugins — plugin execution order and scopes.