Why use Lua instead of XML dialplan in FreeSWITCH?

XML dialplan is declarative and becomes unwieldy for complex, stateful logic. Lua, enabled via mod_lua, lets developers write that logic as real imperative code while still calling FreeSWITCH's native applications and APIs directly.

Lua vs JavaScript vs a native C module in FreeSWITCH — which should I use?

Use Lua for most custom call-flow logic — lowest overhead, simplest API. Use mod_v8 (JavaScript) if your team's expertise is JavaScript. Write a native C module only for performance-critical, low-level media processing.

FreeSWITCH Lua Scripting: Complete 2026 Guide

XML dialplan gets unwieldy fast once your call logic needs branching, database lookups, or loops. mod_lua lets you write that logic as real code — here's how to set it up, with runnable scripts.

This guide covers the session API in depth, event handling and hooks for asynchronous call logic, debugging techniques, performance characteristics at scale, how to bring in external Lua libraries, a second runnable example beyond the standard IVR menu, and the mistakes we see most often in production Lua codebases — the parts most FreeSWITCH Lua tutorials skip past after the first IVR example.

By Kaushik Parmar·15 min read·July 6, 2026

How a Lua Script Fits the Call Flow

XML Dialplanaction="lua"Lua ScriptDatabase Queryfreeswitch.Dbh()session:execute("bridge", route) — call routed

Enabling mod_lua

# modules.conf.xml
<load module="mod_lua"/>

# From the dialplan, invoke a script instead of native apps:
<action application="lua" data="ivr_menu.lua"/>

Lua scripts live in FreeSWITCH's scripts/ directory by default. Once mod_lua is loaded, the dialplan's lua application hands off control of the call to your script, giving it a session object that mirrors FreeSWITCH's native dialplan applications.

Runnable Script: IVR Menu with Database Lookup

-- ivr_menu.lua
session:answer()
session:streamFile("welcome.wav")

local digits = session:playAndGetDigits(1, 1, 3, 5000, "#",
  "main_menu.wav", "invalid.wav", "\\d")

if digits == "1" then
  -- Look up the destination for "sales" in a database
  local dbh = freeswitch.Dbh("odbc://my_dsn")
  local route = nil
  dbh:query("SELECT sip_uri FROM routes WHERE dept='sales'", function(row)
    route = row.sip_uri
  end)
  if route then
    session:execute("bridge", "sofia/gateway/carrier/" .. route)
  end
elseif digits == "2" then
  session:execute("transfer", "support XML default")
else
  session:hangup()
end

This single script replaces what would otherwise be several XML dialplan extensions plus a curl-based lookup via mod_xml_curl — with ordinary conditional logic and a direct database query.

The Session API: What You Actually Have Available

The session object passed into every Lua script is the same call-control surface FreeSWITCH exposes to its native dialplan applications, just wrapped in a Lua-friendly API. The methods you'll use in almost every script: session:answer() and session:hangup() for basic call state, session:streamFile() and session:playAndGetDigits() for prompts and DTMF collection, session:execute() to invoke any native FreeSWITCH application (bridge, transfer, record) from inside the script, and session:setVariable()/session:getVariable() to read and write channel variables that persist back into the dialplan after the script exits. Beyond the session object, the global freeswitch table exposes API-level calls — freeswitch.API():execute() lets a script run any FreeSWITCH CLI command (like sofia status or a custom module command) programmatically, which is useful for scripts that need to inspect system state rather than just control the current call.

Event Handling and Hooks

Beyond straight-line dialplan scripts, Lua can also subscribe to FreeSWITCH events asynchronously via freeswitch.EventConsumer(), which is the pattern to reach for when a script needs to react to something happening on a call other than the one it's currently controlling — a supervisor script monitoring all active calls for a specific condition, for example, or a billing script that needs to catch every CHANNEL_HANGUP_COMPLETE event system-wide to trigger CDR post-processing.

-- event_watcher.lua (run as a standalone script, not inline dialplan)
local con = freeswitch.EventConsumer("CHANNEL_HANGUP_COMPLETE")

while true do
  local e = con:pop(true, 1000)
  if e then
    local uuid = e:getHeader("Unique-ID")
    local cause = e:getHeader("Hangup-Cause")
    freeswitch.consoleLog("info", "Call " .. uuid .. " ended: " .. cause .. "\n")
    -- write to billing DB, trigger webhook, etc.
  end
end

Run this kind of script via luarun from the FreeSWITCH CLI, or as a background service started alongside FreeSWITCH itself — not inline in the dialplan, since it's meant to run continuously rather than per-call.

Debugging Lua Scripts

Three techniques cover most debugging needs. First, freeswitch.consoleLog(level, message) writes to the FreeSWITCH console/log at the level you specify (debug, info, notice, warning, err) — sprinkle these liberally during development and filter by level in production. Second, the FreeSWITCH CLI command luarun /path/to/script.lua lets you execute and iterate on a script directly from the console without placing a real call, which is far faster than dialing in repeatedly to test logic changes. Third, wrap risky operations — especially database queries and external API calls — in Lua's pcall(), since an uncaught Lua error partway through a script can leave a call in an undefined state rather than failing gracefully to a known fallback.

local ok, err = pcall(function()
  local dbh = freeswitch.Dbh("odbc://my_dsn")
  dbh:query("SELECT ...", function(row) ... end)
end)

if not ok then
  freeswitch.consoleLog("err", "DB lookup failed: " .. tostring(err) .. "\n")
  session:execute("transfer", "fallback XML default")
end

Performance Considerations

Lua's overhead relative to native XML dialplan actions is small enough that it's rarely the bottleneck in a real deployment — the interpreter itself executes typical IVR and routing logic in low single-digit milliseconds. What does matter at scale is what your script calls out to: a synchronous database query or HTTP request inside the Lua script blocks that call's dialplan execution until it returns, and under load, a slow downstream dependency (a database under contention, a third-party API having a bad day) will manifest as calls hanging at exactly the point your script makes that call, not as a generic FreeSWITCH performance problem. Connection-pool your database handles rather than opening a fresh one per call, set explicit timeouts on any HTTP calls a script makes, and monitor script execution time separately from overall call setup time so a slow dependency is visible in your metrics rather than hidden inside "call setup took longer than usual."

At meaningful scale — a few hundred concurrent calls each invoking a Lua script — the aggregate effect of even small per-call inefficiencies compounds quickly. A database query that takes 20ms feels instant in isolation, but at 300 concurrent calls each holding open a connection for that query, you're one slow query plan away from exhausting your database's connection pool entirely, which then cascades into every other call's script stalling on the same resource. Load-test Lua-heavy dialplans at your expected peak concurrency, not just functionally test them one call at a time, since the failure modes that matter in production only appear under concurrent load.

Lua Libraries and Modules

FreeSWITCH's embedded Lua interpreter can load standard Lua modules the same way any Lua environment does, provided they're installed where FreeSWITCH's Lua path can find them — cjson or dkjson for JSON encoding/decoding when a script needs to call a REST API, luasocket for lower-level network operations beyond what freeswitch.Dbh() and the built-in HTTP helpers cover, and luafilesystem if a script needs to read configuration or state from the filesystem directly. Installing these via luarocks (Lua's package manager) works the same as any standalone Lua installation — the one gotcha is making sure the Lua version luarocks installs against matches the Lua version FreeSWITCH's mod_lua was compiled against, which is worth verifying with freeswitch.consoleLog output at startup if a required module fails to load silently.

Lua vs JavaScript (mod_v8) vs a Native C Module

ApproachBest ForOverheadLearning Curve
Lua (mod_lua)Most custom call-flow logic, IVR, routing decisionsLowLow — small language, mirrors dialplan apps
JavaScript (mod_v8)Teams already fluent in JS, npm-style librariesLow-MediumLow if JS-fluent already
Native C modulePerformance-critical, low-level media processingNone (compiled)High — full FreeSWITCH module API

A Second Runnable Example: Call Queue Position Announcement

The IVR example above shows branching and a database lookup; a common second use case is a script that runs periodically inside an active call to announce the caller's position in queue — logic that would be awkward to express as static XML at all, since it needs a loop with a sleep and a live queue-position check on every iteration.

-- queue_position.lua
session:answer()
session:streamFile("please-wait.wav")

while session:ready() do
  local api = freeswitch.API()
  local pos = api:execute("fifo_status", "sales_queue count")
  session:streamFile("you-are-number.wav")
  session:sayNumber(tonumber(pos) or 0)
  session:sleep(15000)
end

session:ready() returns false the moment the caller hangs up, which cleanly ends the loop without needing to poll hangup state manually — a small but important detail that prevents the script from spinning after the call has already ended.

Testing Lua Scripts Without Placing Real Calls

Beyond luarun for quick syntax and logic checks, FreeSWITCH's originate API command lets you place a test call into a script from the CLI without an actual phone or SIP client, which is the fastest iteration loop for testing dialplan-invoked scripts end to end: originate loopback/1000 &lua(ivr_menu.lua) originates a loopback call directly into the script, exercising the exact same session object a real inbound call would provide. For scripts with external dependencies — a database lookup, an HTTP call to a CRM — build a small test harness that mocks those calls behind a feature flag read from a channel variable, so the same script can run against a test double in CI and against the real dependency in production, without maintaining two separate script versions.

When Lua Beats XML Dialplan

  • Routing decisions that depend on a database or API lookup mid-call, not just static rules
  • Complex conditional branching that would require dozens of XML extensions to express
  • Anything involving loops — retry logic, hunting through a list of carriers, polling for an agent
  • Rapid iteration during development — editing a Lua script and reloading is faster than XML reload cycles for complex logic

Common Mistakes We See in Production Lua Scripts

Reviewing client FreeSWITCH deployments over the years, the same handful of mistakes recur often enough to call out specifically:

  • Opening a new database handle on every call instead of pooling connections — under load this exhausts the database's max-connections limit long before FreeSWITCH itself is under any real strain
  • Not checking session:ready() inside loops, leading to scripts that keep executing (and consuming a worker thread) after the caller has already hung up
  • Hard-coding file paths and DSNs directly in scripts rather than reading them from FreeSWITCH channel variables or a config file, making the same script impossible to reuse across a dev/staging/production split without editing the script itself
  • Swallowing errors silently with an empty pcall() catch block, which turns a real failure into silent call behavior nobody can explain later from the logs alone
  • Writing all call logic in one large monolithic script instead of splitting reusable logic into separate Lua modules loaded with require(), which becomes a real maintenance cost once more than one script needs the same lookup or routing logic

Structuring Larger Lua Codebases

A single IVR script is easy to keep in one file, but a production FreeSWITCH deployment handling inbound routing, outbound campaigns, billing hooks, and CRM integration accumulates enough Lua that treating it as a proper codebase pays off quickly. The pattern we use: a small set of shared modules (database access, HTTP client wrapper, common logging helper) loaded via Lua's require() from every call-specific script, so a change to how database connections are pooled, for example, happens in one place rather than being copy-pasted across a dozen scripts. Keep call-specific scripts thin — answer, gather input, call into a shared module for the actual business logic, execute the routing decision — so the parts of the codebase that need frequent changes (business rules) stay separate from the parts that rarely change (connection handling, logging).

FAQ

Can I call Lua scripts from an existing XML dialplan?

Yes — the lua dialplan application hands off a call to a script from anywhere in an existing XML dialplan, so you can migrate specific complex extensions to Lua incrementally rather than rewriting the entire dialplan at once.

Does Lua scripting slow down call setup?

The overhead is negligible for typical IVR and routing logic — Lua execution time is dwarfed by network I/O (database queries, HTTP calls) in almost every real script, not by the scripting layer itself.

Can a Lua script control multiple calls at once?

A dialplan-invoked Lua script controls only the session that invoked it, by design. To act across multiple calls system-wide, use an EventConsumer-based script run as a standalone background process rather than a dialplan action — that's the correct pattern for monitoring or billing logic spanning all active calls.

How do I pass parameters into a Lua script from the dialplan?

Set channel variables with session:setVariable() equivalents in the dialplan before invoking lua, or pass arguments directly in the action data field (e.g. lua myscript.lua arg1 arg2), which the script reads via Lua's standard arg table.

What Lua version does FreeSWITCH use?

mod_lua embeds a standard Lua interpreter, most commonly Lua 5.1 or 5.2 depending on your FreeSWITCH build — check your build's compile flags or run a version-print script via luarun if you need to confirm compatibility with a specific external library.

Is mod_lua actively maintained in 2026?

Yes — mod_lua ships as a core, actively maintained module in current FreeSWITCH releases and remains one of the most commonly used scripting layers for custom dialplan logic in production deployments.

Can Lua scripts modify call recordings or media in real time?

Lua itself doesn't process raw media directly — that's handled by FreeSWITCH's native C modules (mod_conference, mod_sndfile, etc.). A Lua script controls when recording starts and stops via session:execute('record', ...) and similar calls, but real-time audio manipulation belongs in a native module or an external process reached via mod_audio_fork, not in the Lua layer itself.

How do I share state between multiple Lua scripts across different calls?

Use FreeSWITCH's built-in limit/group APIs or an external store (Redis is the common choice) rather than in-process Lua variables, since each call typically runs its script in its own isolated Lua state — there's no shared memory between concurrent call scripts by default.

Summary

mod_lua earns its place in a FreeSWITCH deployment the moment call logic needs a database lookup, a loop, or branching complex enough that XML dialplan would require dozens of extensions to express the same thing. Start with the session API for straightforward dialplan-invoked scripts, reach for EventConsumer only when logic genuinely needs to run across calls rather than within one, and invest in connection pooling and proper error handling early — those two things account for most of the gap between a working demo script and one that survives real production call volume.

Need Custom FreeSWITCH Dialplan Logic?

CelloIP engineers build production FreeSWITCH routing, IVR, and billing logic in Lua, JavaScript, and native modules.