Embedding Lua in Go with gopher-lua
You are building a game server. Administrators want to write custom scripts to change loot drops, spawn monsters, or trigger events based on player behavior. You do not want to restart the server every time a rule changes. You also do not want to give administrators raw access to your Go source code. You need a sandbox where untrusted code can run safely, interact with your system, and be hot-reloaded.
Lua is the classic choice for this problem. It is lightweight, fast, and designed for embedding. gopher-lua brings that capability to Go as a pure Go implementation. There is no C code. There is no cgo. This means you can cross-compile your application to any platform without installing a Lua development library. The interpreter runs entirely within your Go process, sharing memory and goroutines.
The calculator inside your application
Think of embedding Lua like putting a calculator inside your application. Go is the heavy machinery: the database, the network stack, the business logic. Lua is the small, fast calculator you hand to users. They can do math, write loops, and make decisions, but they cannot touch the factory walls.
gopher-lua implements the Lua 5.1 specification. The core of the library is the LState. This is the virtual machine instance. It holds the execution context, the global environment, and the stack. Every interaction with Lua happens through this state. You load scripts, call functions, and exchange data by pushing and popping values on the stack.
The stack is the bridge between Go and Lua. Lua is a stack-based virtual machine. Arguments are pushed onto the stack before a call. Results are pushed onto the stack after a call. Go code must manage this stack explicitly. Index -1 is the top of the stack. Index 1 is the bottom. This indexing scheme is consistent across the API.
Minimal example: run a script and call a function
Here is the simplest way to run a Lua script and invoke a function from Go. The code creates a state, loads a script, calls a function, and retrieves the result.
package main
import (
"fmt"
"github.com/yuin/gopher-lua"
)
func main() {
// Create a new Lua state. This allocates the VM and global table.
L := lua.NewState()
// Close releases resources when main exits. Always defer this.
defer L.Close()
// DoString parses and executes the Lua script.
// It returns an error if the script has syntax or runtime errors.
if err := L.DoString(`
function greet(name)
return "Hello, " .. name
end
`); err != nil {
panic(err)
}
// CallByParam invokes a Lua function with Go-managed parameters.
// Protect: true catches Lua panics and returns them as Go errors.
// NRet: 1 tells the VM to expect one return value on the stack.
if err := L.CallByParam(lua.P{
Fn: L.GetGlobal("greet"),
NRet: 1,
Protect: true,
}, lua.LString("Gopher")); err != nil {
panic(err)
}
// Get(-1) retrieves the top value from the stack.
// The result of the function call sits at the top.
result := L.Get(-1)
// Pop(1) removes the value to keep the stack balanced.
// Forgetting to pop causes stack leaks and eventual crashes.
L.Pop(1)
fmt.Println(result.String())
}
Gopher-lua is pure Go. Cross-compile with confidence.
How the stack works
The stack is a contract between Go and Lua. When you call a Lua function, you push arguments, invoke the function, and the function pushes results. You must pop the results. If you push and forget to pop, the stack grows. The VM eventually runs out of space and panics with stack overflow.
Indices are relative. -1 is always the top. -2 is the element below the top. 1 is the bottom. This allows you to access values without knowing the exact stack depth. Get(-1) is safe as long as the stack is not empty. Get(1) is safe as long as the stack has at least one element.
CallByParam simplifies this pattern. It pushes arguments, calls the function, and handles the return count. You still need to pop the results. The Protect flag is critical. If a Lua script errors, the VM panics. Protect: true catches the panic and returns a Go error. Without it, a Lua error crashes your Go program.
Exposing Go functions to Lua
Real applications need bidirectional flow. Lua scripts often need to call back into Go to access the database, send messages, or read configuration. You register Go functions as Lua globals using SetGlobal and NewFunction.
The Go function receives the LState as its first argument. It reads arguments from the stack, computes the result, pushes the result back, and returns the number of results. This pattern mirrors the Lua C API but in pure Go.
func registerMathFunctions(L *lua.LState) {
// Expose a Go function as a Lua global named "add".
// NewFunction wraps the Go closure so Lua can call it.
L.SetGlobal("add", L.NewFunction(func(L *lua.LState) int {
// CheckInt reads the first argument and validates the type.
// It panics if the argument is missing or not a number.
a := L.CheckInt(1)
// CheckInt reads the second argument at stack index 2.
b := L.CheckInt(2)
// Push the result onto the stack for Lua to read.
L.Push(lua.LNumber(a + b))
// Return 1 to tell Lua there is one return value.
// The return value must match the number of pushed results.
return 1
}))
}
Lua calls Go through the stack. Validate inputs, push outputs, return the count.
Working with tables
Tables are the heart of Lua. They act as arrays, maps, and objects. gopher-lua exposes tables as *lua.LTable. You can read and write fields, iterate entries, and convert to Go maps.
When reading configuration from Lua, you often get a table. ForEach iterates over key-value pairs without manual stack management. This is safer than manual iteration because it handles the stack internally.
func readConfig(L *lua.LState) map[string]string {
// GetGlobal retrieves the config table from the global environment.
// Type assertion to *lua.LTable is safe after checking the type.
configTable := L.GetGlobal("config").(*lua.LTable)
result := make(map[string]string)
// ForEach iterates over table entries safely.
// It yields key-value pairs without polluting the stack.
L.ForEach(configTable, func(k, v lua.LValue) {
key := k.String()
val := v.String()
result[key] = val
})
return result
}
Tables are Lua's only data structure. Treat them as maps, arrays, or objects depending on the keys.
Pitfalls and errors
Stack management is the most common source of bugs. If you push a value and never pop it, the stack grows. The error stack overflow appears when the limit is reached. If you access an index that does not exist, you get attempt to index a nil value or a panic. Always check stack depth or use helper methods like CheckInt that validate arguments.
Error handling requires discipline. Lua scripts can fail at runtime. DoString returns an error for syntax and runtime failures. CallByParam with Protect: true returns an error for function panics. If you omit Protect, a Lua panic propagates as a Go panic. The compiler rejects code with undefined: lua if you forget to import the package. The compiler rejects code with imported and not used if you import gopher-lua but do not use it.
gopher-lua implements Lua 5.1. This is an old version. It lacks features like bitwise operations, coroutines improvements, and string patterns from 5.2 and 5.3. For most scripting tasks, 5.1 is sufficient. If you need modern Lua features, you must use a different library or cgo. The trade-off is portability. gopher-lua works everywhere Go works.
Convention aside: defer L.Close() is standard practice. The state holds resources. Closing it releases memory. The community expects if err != nil checks after DoString and CallByParam. The boilerplate makes the unhappy path visible.
When to use gopher-lua
Use gopher-lua when you need a pure Go scripting engine with zero external dependencies. Use the Lua C API via cgo when you require Lua 5.3+ features or maximum performance for heavy numerical workloads. Use Go templates when you only need to inject data into static text and do not require executable logic. Use JSON or YAML configuration when your settings are data-only and users never need conditional logic or loops. Use a dedicated plugin framework like goplugin when you need to compile Go code dynamically at runtime.
Pick the tool that matches the complexity of the problem. Lua is for logic. JSON is for data. Go is for systems.