Embedding Lua in a host program

The C API stack, passing values in both directions, registering host functions, and sandboxing untrusted scripts.

The C API in one breath

Lua is designed to be embedded: the interpreter is a library, and your program owns the lua_State that holds the stack, the globals and the garbage collector. Everything crosses the boundary through that stack.

#include <stdio.h>
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>

int main(void) {
  lua_State *L = luaL_newstate();      /* one state per thread, never shared */
  luaL_openlibs(L);                    /* string, table, math, io, os, ... */

  if (luaL_dofile(L, "config.lua") != LUA_OK) {
    fprintf(stderr, "%s\n", lua_tostring(L, -1));
    lua_pop(L, 1);                     /* remove the error message */
    lua_close(L);
    return 1;
  }

  lua_getglobal(L, "width");           /* push the global onto the stack */
  int width = (int)lua_tointeger(L, -1);
  lua_pop(L, 1);                       /* pop it: keep the stack balanced */

  printf("width = %d\n", width);
  lua_close(L);
  return 0;
}
  • The stack is indexed from 1 at the bottom; negative indices count from the top, so -1 is always the most recent push.
  • Any function returning int returns a count of values left on the stack for Lua to consume.
  • luaL_dofile is luaL_loadfile plus lua_pcall; the pieces are separate when you want to compile once and run many times.
  • Compile with the C compiler, not C++, or put the headers inside extern "C".

Host functions and calling Lua

#include <math.h>

/* Registered as log(msg, level) inside Lua. */
static int host_log(lua_State *L) {
  const char *msg = luaL_checkstring(L, 1);   /* type error if not a string */
  int level = (int)luaL_optinteger(L, 2, 0);  /* optional with a default */
  fprintf(stderr, "[%d] %s\n", level, msg);
  return 0;                                   /* nothing to return */
}

/* Registered as distance(x1, y1, x2, y2) and returns one number. */
static int host_distance(lua_State *L) {
  double dx = luaL_checknumber(L, 1) - luaL_checknumber(L, 3);
  double dy = luaL_checknumber(L, 2) - luaL_checknumber(L, 4);
  lua_pushnumber(L, sqrt(dx * dx + dy * dy));
  return 1;
}

static void register_host(lua_State *L) {
  lua_pushcfunction(L, host_log);      lua_setglobal(L, "log");
  lua_pushcfunction(L, host_distance); lua_setglobal(L, "distance");
}

/* Call a Lua function that the script defined. */
static int call_update(lua_State *L, int entity_id) {
  lua_getglobal(L, "on_update");            /* 1: the function */
  if (!lua_isfunction(L, -1)) { lua_pop(L, 1); return 0; }
  lua_pushinteger(L, entity_id);            /* 2: argument */
  if (lua_pcall(L, 1, 1, 0) != LUA_OK) {    /* 1 arg, 1 result, no handler */
    fprintf(stderr, "on_update failed: %s\n", lua_tostring(L, -1));
    lua_pop(L, 1);
    return 0;
  }
  int ok = (int)lua_toboolean(L, -1);
  lua_pop(L, 1);
  return ok;
}
ConcernRule
Stack layoutIndex 1 is the bottom, -1 the top; the top is the only safe place to consume
ErrorsWrap calls in lua_pcall or use a luaL_ helper that does it for you
String lifetimeA pointer from lua_tolstring stays valid only while the value is on the stack
Garbage collectionValues anchored on the stack are reachable; popping makes them collectable
Multiple statesEach state has its own globals and GC; a state must not be used from two threads at once
C structsExpose them as userdata with a shared metatable for methods

Sandboxing untrusted scripts

A script loaded by luaL_dofile can open files and read the environment. For user-supplied configuration or plugins, load the chunk with a restricted environment table instead of the globals.

-- 5.2+ / 5.4: a chunk's globals live in its _ENV upvalue
local function sandbox(code, extra)
  local env = {
    math = math, string = string, table = table,
    ipairs = ipairs, pairs = pairs, tostring = tostring, tonumber = tonumber,
    print = print, error = error, type = type,
  }
  for k, v in pairs(extra or {}) do env[k] = v end

  local chunk, err = load(code, "sandbox", "t", env)
  if not chunk then return nil, err end
  local ok, result = pcall(chunk)
  if not ok then return nil, result end
  return result
end

local value = sandbox("return sum(2, 3)", { sum = function(a, b) return a + b end })
print(value)                        -- 5

local _, err = sandbox("return os.exit()")
print(err)                          -- sandbox:1: attempt to index a nil value (global 'os')
  • Omit io, os, package, require, load and dofile from the environment; each opens a door back to the host.
  • Set a memory ceiling with lua_setallocf, or count instructions with the lua_sethook debug library to stop infinite loops.
  • Lua's own share of the stack is not the C stack: a deep recursion inside a chunk can still exhaust the C stack when it calls into your host functions.
⚠️
Calling lua_error, lua_call or raising inside a metamethod when no protected call is active causes a panic, and the default panic handler aborts the process. Every entry point from host code into Lua should be wrapped in lua_pcall.

FAQ

How do I share state between two scripts?
They share the globals table of one lua_State, so a plain assignment is enough. Use two states only for true isolation, and remember that values cannot be moved between states without being re-created from their C representation.
Should I embed Lua or LuaJIT?
Standard Lua 5.4 for portability and simple 64-bit integers, LuaJIT when raw execution speed on x86 dominates and you can accept its older 5.1 dialect and its own FFI. Pick one for the whole project: the C API differs.

Tables, metatables and object orientation Syntax and the compiled toolchain

Last refreshed 2026-09-18.