Skip to main content

Version: 3.10.x

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) and ctx.var.<name> compute a value once and reuse it for the rest of the request, while repeated ngx.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 raw ngx.req.set_header can leave stale cached values behind.
  • Handle the awkward defaults for you. For example, core.request.get_uri_args reads arguments with no truncation limit, whereas ngx.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 APIPreferred core APINotes
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] = vcore.response.set_header(k, v)Guards against headers that were already sent.
ngx.say / ngx.print / ngx.exitcore.response.exit(code, body)A table body is JSON-encoded; the call must be returned.
cjson.encode / cjson.decodecore.json.encode / core.json.decodedecode returns nil, err on bad input.
cjson.encode inside a log linecore.json.delay_encode(data)Encodes only if the log line is actually emitted.
JSON Schema validationcore.schema.check(schema, conf)Caches the compiled validator.
table.insert / table.new / table.clearcore.table.insert / core.table.new / core.table.clearcore.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-modulePurpose
core.requestRead and modify the incoming client request.
core.responseSet response headers and short-circuit the request.
core.ctx / ctx.varAccess request context and Nginx or gateway variables.
core.logLevel-aware logging.
core.jsonEncode and decode JSON, including lazy encoding for logs.
core.schemaValidate plugin configuration against a JSON schema.
core.tableTable helpers: allocate ahead of time, clear, count, deep copy, merge.
core.stringFast plain-text string helpers (prefix, suffix, find).
core.utilsMiscellaneous helpers: variable substitution, DNS, UUID.
core.lrucachePer-worker LRU cache, keyed to a plugin configuration.
core.timerManaged 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; pass value = nil to remove it.
  • core.request.get_uri_args(ctx) — parsed query arguments.
  • core.request.get_body() — the raw request body as a string. It may return nil (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. Pass core.json.encode(data, true) to force-serialize values that cannot normally be encoded.
  • core.json.decode(str) — decode; returns nil, err on 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) and core.table.clone(t) — copy configuration you must not mutate.
  • core.table.try_read_attr(t, "a", "b", "c") — safely read a nested field, returning nil if any level is missing.

core.string proxies the standard string library and adds fast, plain-text helpers:

  • core.string.has_prefix(s, prefix) and core.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, and required. The more the schema enforces, the less defensive code your handlers need.

  • Set sensible default values 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:

PhaseUse it for
rewriteRewriting the URI or request line before route matching completes.
accessAuthentication, authorization, rate limiting, and request mutation.
before_proxyLast-moment changes just before the request is sent upstream.
header_filterReading or modifying response headers.
body_filterReading or modifying the response body.
logRecording 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 return nil.
  • Return a status and body to reject a request cleanly (return 400, { message = "..." }); do not call ngx.exit directly.
  • 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 a 500.

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 and lua-resty-* libraries. For deliberate delays, use core.sleep, which yields instead of blocking the worker.

  • Reuse tables with core.table.new and core.table.clear in 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] and ngx.arg[2] in body_filter — the current body chunk and the end-of-stream flag. To work on the full body, buffer chunks with core.response.hold_body_chunk(ctx), which returns nil until 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 no core.re.

  • ngx.shared.<dict> — shared-memory dictionaries for cross-worker state. There is no core.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; prefer core.timer.new for recurring background jobs.

Common Pitfalls

  • core.response.exit must be returned. return core.response.exit(...), or return a code, body tuple. A bare call does not reliably stop the request.
  • core.request.get_body() can return nil even without an error. Handle the empty-body case.
  • ctx.var writes are limited. Assigning ctx.var.x = v only updates the real Nginx variable for variables the gateway allows to be written; otherwise it updates the cache only.
  • Do not require one 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.find is 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_header raises 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.log on the gateway node (by default /usr/local/apisix/logs/error.log) for your core.log output.
  • Roll out to a test gateway group first, then promote to production — see Roll Out Across Gateway Groups.

Next Steps

API7.ai Logo

The digital world is connected by APIs,
API7.ai exists to make APIs more efficient, reliable, and secure.

Sign up for API7 newsletter

Product

API7 Gateway

SOC2 Type IIISO 27001HIPAAGDPRRed Herring

Copyright © APISEVEN PTE. LTD 2019 – 2026. Apache, Apache APISIX, APISIX, and associated open source project names are trademarks of the Apache Software Foundation