luaguides

Extending Lua with C: how to write native modules

Introduction

Extending Lua with C opens up native performance, direct hardware access, and the ability to wrap existing C libraries for use in Lua scripts. The Lua C API lets you write extension modules in C that Lua loads through require(). These modules behave exactly like Lua libraries: they expose functions, types, and constants, but the implementation runs at native code speed. This is distinct from embedding Lua in a C host program, where the C program controls the Lua interpreter. This article covers only extending Lua with C.

Common reasons to write C extensions:

  • Performance: hot paths that Lua cannot optimise sufficiently
  • Binding: wrapping an existing C library for use from Lua
  • Capability: providing features that require OS or hardware access

This article assumes you are comfortable reading and writing C code and that you know Lua at an intermediate level. You do not need to know the Lua C API.

The lua_CFunction signature

Every C function exposed to Lua must have this exact signature:

int my_function(lua_State *L) {
    /* read arguments from stack, push results, then: */
    return n;  /* n = number of values returned to Lua */
}

The function receives a single lua_State* pointer. It reads arguments from the stack (position 1 is the first argument), performs its work, pushes return values onto the stack, and returns the count of those values. Returning 0 is valid; the function then returns nil.

The return value is not the function’s exit code. It is a contract with Lua’s call mechanism: “I am returning n values on the stack.” Lua takes those n values from the top of the stack and uses them as the function’s return values.

The Lua stack

All communication between C and Lua happens through a single stack. C code never has direct pointers into Lua’s memory; you push values onto the stack to return them, and you read from the stack to receive arguments.

Stack indices

The stack is indexed from 1 at the bottom and from -1 at the top:

lua_pushnumber(L, 10);   /* stack: [10]          index 1  */
lua_pushnumber(L, 20);   /* stack: [10, 20]      index 2  = -1 */
lua_pushnumber(L, 30);   /* stack: [10, 20, 30]  index 3  = -1, 2 = -2 */

Positive index 1 always refers to the first argument. Negative index -1 always refers to the top (most recently pushed). Index 0 is invalid. You can convert between the two schemes: lua_gettop(L) returns the current stack height, so the element at index i is also at lua_gettop(L) + i - 1.

Reading arguments

When Lua calls a C function, it places the function’s arguments onto the stack before transferring control. Argument 1 sits at index 1, argument 2 at index 2, and so on, the same positive-index scheme introduced above. Your function reads these arguments using the luaL_check* family of functions, which pull values from a given stack index and raise a type error if the value does not match the expected type. Always validate the arguments you receive:

static int l_add(lua_State *L) {
    int a = luaL_checkinteger(L, 1);  /* error if arg 1 is not an integer */
    int b = luaL_checkinteger(L, 2);  /* error if arg 2 is not an integer */
    lua_pushinteger(L, a + b);
    return 1;
}

luaL_checkinteger raises a formatted error and never returns if the argument is missing or has the wrong type. luaL_optinteger returns a default value if the argument is nil or absent. Use lua_gettop(L) to determine how many arguments were actually passed:

static int l_hypot(lua_State *L) {
    int n = lua_gettop(L);
    if (n < 2) {
        return luaL_error(L, "expected 2 arguments, got %d", n);
    }
    double a = luaL_checknumber(L, 1);
    double b = luaL_checknumber(L, 2);
    lua_pushnumber(L, sqrt(a * a + b * b));
    return 1;
}

Pushing return values

Once your function has finished its work, it must push its results onto the stack and then return an integer that tells Lua how many values to collect. Push the values in the exact order you want the caller to receive them: the first value pushed becomes the first return value in Lua, the second becomes the second, and so on. The integer you return must match the number of items you pushed:

static int l_divmod(lua_State *L) {
    int a = luaL_checkinteger(L, 1);
    int b = luaL_checkinteger(L, 2);
    if (b == 0) {
        return luaL_error(L, "division by zero");
    }
    lua_pushinteger(L, a / b);   /* quotient  */
    lua_pushinteger(L, a % b);   /* remainder */
    return 2;                     /* two return values */
}

Module registration with luaL_newlib

A C extension module is a shared object (.so on Linux, .dll on Windows) that Lua loads via require("modulename"). Lua calls a specially named C function, luaopen_<modulename>, to initialise the module. That function must return a table containing the module’s public API.

The standard modern pattern uses luaL_newlib, which creates a table and registers all functions in one call:

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

static int l_add(lua_State *L) {
    int a = luaL_checkinteger(L, 1);
    int b = luaL_checkinteger(L, 2);
    lua_pushinteger(L, a + b);
    return 1;
}

static int l_sub(lua_State *L) {
    int a = luaL_checkinteger(L, 1);
    int b = luaL_checkinteger(L, 2);
    lua_pushinteger(L, a - b);
    return 1;
}

/* Array of module functions — MUST end with {NULL, NULL} */
static const luaL_Reg mylib[] = {
    {"add", l_add},
    {"sub", l_sub},
    {NULL, NULL}
};

/* Entry point: luaopen_<modulename> */
int luaopen_mylib(lua_State *L) {
    luaL_newlib(L, mylib);
    return 1;
}

The luaL_Reg array maps Lua-facing names ("add", "sub") to C function pointers. The sentinel {NULL, NULL} marks the end of the array. Omitting it causes the linker to read garbage past the end of the array.

luaL_newlib (Lua 5.2+) combines lua_createtable(L, 0, n) with luaL_setfuncs(L, mylib, 0). In Lua 5.1, use luaL_register(L, "mylib", mylib), though luaL_register is deprecated in 5.2 and removed in 5.4.

Once you have written your module source file, compile it into a shared object that Lua can load dynamically at runtime. The build command differs slightly between platforms, but the core flags are the same: produce position-independent code and link against the Lua library. After compiling, you can load the module from any Lua script using require():

gcc -shared -fPIC -o mylib.so mylib.c $(pkg-config --cflags --libs lua5.4)

Once the shared object is built, the Lua side is straightforward: call require() with the module name and the returned table provides access to every function registered in the luaL_Reg array. The module behaves exactly like any native Lua library:

local mylib = require("mylib")
print(mylib.add(3, 4))   --> 7
print(mylib.sub(10, 3))  --> 7

The luaopen_ convention

When you call require("mylib"), Lua’s loader looks for a file named mylib.so (or mylib.dll) in the directories listed in package.cpath. When found, it calls luaopen_mylib(L). The function name is derived directly from the filename. There is no configuration step, no registry, no metadata. If your file is vec3.so, the function must be named luaopen_vec3. This is the contract.

Lua then uses the table returned by luaopen_mylib as the value of the require expression:

local v = require("vec3")  -- calls luaopen_vec3(L), result stored in package.loaded.vec3

Stack balance

The single most important rule in C extension writing: before your function returns, the stack must contain exactly the values you are returning.

Lua’s caller expects arguments at positions 1, 2, and so on. If your function leaves extra values on the stack, those become part of the return value unexpectedly. If it removes values that were there on entry, the caller will read garbage. Use lua_settop(L, idx) to reset the stack to a known state:

static int l_example(lua_State *L) {
    int n = lua_gettop(L);  /* save stack height */
    /* ... do work, push intermediate values ... */
    lua_settop(L, n);       /* discard everything above original top */
    lua_pushinteger(L, 42);
    return 1;                /* stack: [original args..., 42] */
}

Forgetting to return the correct count, or forgetting to return at all, is the most common source of stack leaks and incorrect return values. The C compiler does not warn you; the bug only appears at runtime from Lua’s perspective.

Custom types with userdata

You can expose C struct types to Lua using userdata. A userdata is a block of raw memory that Lua manages; it is garbage collected like any other Lua value, but its contents are entirely under your control.

Creating userdata

#include <lua.h>
#include <lauxlib.h>
#include <stdlib.h>

typedef struct {
    double x, y, z;
} Vec3;

static int l_vec3_new(lua_State *L) {
    double x = luaL_optnumber(L, 1, 0.0);
    double y = luaL_optnumber(L, 2, 0.0);
    double z = luaL_optnumber(L, 3, 0.0);
    Vec3 *v = (Vec3 *)lua_newuserdata(L, sizeof(Vec3));
    v->x = x; v->y = y; v->z = z;
    return 1;  /* userdata is now on the stack as the return value */
}

Attaching a metatable

Creating a userdata gives you an untyped block of memory on the stack; Lua knows nothing about what it contains or how to interact with it. To make the userdata behave like a structured object with methods and type identity, you assign a metatable to it. The metatable provides the __index field that maps method lookups to your C function implementations, turning a raw pointer into a callable Lua value. Without this step, any operation beyond passing the userdata around will fail with an error:

static int l_vec3_dot(lua_State *L) {
    Vec3 *a = (Vec3 *)luaL_checkudata(L, 1, "Vec3");
    Vec3 *b = (Vec3 *)luaL_checkudata(L, 2, "Vec3");
    lua_pushnumber(L, a->x * b->x + a->y * b->y + a->z * b->z);
    return 1;
}

int luaopen_vec3(lua_State *L) {
    luaL_newlib(L, vec3_funcs);      /* register module functions */

    /* Create and fill the metatable for Vec3 userdata */
    luaL_newmetatable(L, "Vec3");    /* register "Vec3" metatable */
    lua_pushstring(L, "__index");
    lua_pushvalue(L, -2);            /* metatable points to itself for __index */
    lua_settable(L, -3);             /* metatable.__index = metatable */
    /* add method table as a field of the metatable so methods are accessible */
    lua_pushvalue(L, LUA_GLOBALSINDEX); /* not in 5.2+ — use upvalues instead */
    /* (simplified: expose methods directly via module table instead) */

    return 1;
}

A cleaner pattern for Lua 5.2+ is to put the methods in the module table and set __index on the metatable to point to the module table itself, so both v:dot(w) and vec3.dot(v, w) work. This dual-style access (colon syntax via the metatable and dot syntax via the module) is the convention followed by most Lua libraries.

Once your module is registered and its methods are in place, any function that receives a userdata argument must verify that the caller passed the correct type. Lua does not enforce type safety on its own; it is the C code’s responsibility to check every incoming userdata before dereferencing its pointer.

Checking userdata type

Use luaL_checkudata to verify that a stack value is userdata of the expected type. If the value is not userdata or has the wrong metatable, it raises a formatted error:

Vec3 *v = (Vec3 *)luaL_checkudata(L, 1, "Vec3");

luaL_newudata is an alias for lua_newuserdata. Both allocate a userdata block associated with the current state. Understanding the distinction between Lua-owned and C-owned memory becomes critical once you start passing pointers between the two worlds. Lua manages its own garbage collection, but it has no awareness of memory you allocate with malloc on the C side. The next section covers this ownership boundary in detail.

Memory ownership

Do not free Lua-owned memory

Never call free() or realloc() on a pointer that Lua returned to you. lua_tostring(), lua_touserdata(), and lua_newuserdata() all return pointers into memory that Lua owns. The first two return pointers to internal buffers. These are invalidated as soon as the value is removed from the stack. lua_newuserdata returns a pointer to the userdata block itself; you may write into the block but you must not free the block.

When you need to keep string data beyond the lifetime of a single C function call, you must make your own copy. The Lua string pointer becomes invalid the moment the corresponding value is popped from the stack, so any attempt to dereference it later results in undefined behaviour. Copying the string with strdup transfers ownership to your C code, and you are then responsible for freeing it:

const char *s = luaL_checkstring(L, 1);
char *copy = strdup(s);    /* you now own this memory */
 /* ... use copy ... */
free(copy);                /* free it when done */

lua_close and memory cleanup

When a Lua state is shut down with lua_close(L), the garbage collector runs all pending finalizers (including any __gc metamethods attached to userdata) and then frees every allocation associated with that state. All userdata blocks, internal string buffers, and table storage are released automatically. However, Lua only tracks memory it allocated itself. If your C code has allocated buffers with malloc that outlast the Lua state, those buffers remain allocated after lua_close returns. Always free C-side allocations before destroying the Lua state:

/* C code that allocates a buffer */
char *buffer = malloc(BUFSIZ);

/* ... use buffer from Lua via a userdata wrapping a pointer ... */

/* cleanup */
free(buffer);      /* free C memory first */
lua_close(L);      /* then close Lua state */

Building the extension

pkg-config

The simplest way to compile a Lua C extension is with pkg-config, a tool that queries the system for the correct compiler and linker flags needed to build against the Lua library. This avoids hard-coding include paths and library names in your build scripts. Start by checking which versions of Lua are installed on your system:

pkg-config --list-all | grep lua

Compile a single file into a shared object that Lua can load at runtime. The flags -shared and -fPIC are required on Linux, while macOS needs -dynamiclib instead. The pkg-config command fills in the correct include and library paths for your Lua installation automatically:

gcc -shared -fPIC -o mylib.so mylib.c $(pkg-config --cflags --libs lua5.4)

The -shared flag tells the compiler to produce a shared object suitable for dynamic loading. -fPIC generates position-independent code, which is required on most Unix systems for shared libraries. On macOS, use -dynamiclib instead of -shared. The pkg-config invocation automatically resolves the correct include and library paths for your installed Lua version, so you do not need to hard-code system-specific directories.

Many systems also have LuaJIT installed alongside the standard Lua interpreter. LuaJIT exposes a compatible C API but uses a different package name in pkg-config, so the build command changes slightly. You can check whether LuaJIT development headers are available by querying pkg-config for the luajit package, though Debian and Ubuntu ship LuaJIT under the lua5.1 name for historical compatibility reasons:

gcc -shared -fPIC -o mylib.so mylib.c $(pkg-config --cflags --libs lua5.1)

If pkg-config cannot locate Lua on your system (which can happen when Lua was installed from source into a non-standard prefix or when the .pc file is missing from the package manager’s search path), you can bypass it entirely by setting the compiler and linker flags yourself. Specify the include directory with -I and the library directory with -L, then link the Lua library with -l:

export LUA_CFLAGS="-I/usr/include/lua5.4"
export LUA_LIBS="-L/usr/local/lib -llua5.4"
gcc -shared -fPIC -o mylib.so mylib.c $LUA_CFLAGS $LUA_LIBS

CMake for larger projects

When your extension grows beyond a single source file, manually specifying compiler flags for every translation unit becomes tedious. CMake handles include path discovery, library linking, and output naming automatically through its find_package and pkg_check_modules commands. This approach scales cleanly to multi-file projects and integrates naturally with CI pipelines:

cmake_minimum_required(VERSION 3.10)
project(myluaext LANGUAGES C)

find_package(PkgConfig REQUIRED)
pkg_check_modules(LUA REQUIRED lua5.4)

add_library(mylib MODULE mylib.c)
target_include_directories(mylib PRIVATE ${LUA_INCLUDE_DIRS})
target_link_libraries(mylib PRIVATE ${LUA_LIBRARIES})
set_target_properties(mylib PROPERTIES PREFIX "")   # so output is mylib.so, not libmylib.so

Run CMake from a dedicated build directory to keep generated artefacts separate from source files. The cmake .. command configures the project using the parent directory’s CMakeLists.txt, and make compiles the shared object. After the build completes, you will find the shared object file inside the build directory, ready to be moved to a location on your Lua module search path:

mkdir -p build && cd build
cmake ..
make

Where Lua finds your module

Once you have compiled the shared object, Lua needs to locate it when require() is called. The search path for C modules is stored in package.cpath, a semicolon-separated string of directory patterns that works like the system PATH variable. Each pattern contains a ? placeholder that Lua replaces with the module name at load time. You can inspect the current search path directly from a Lua prompt:

print(package.cpath)

Place your compiled shared object in one of the directories returned by the command above, or prepend your own development directory to the search path. Modifying package.cpath at the top of your script is a common pattern during development, since it avoids copying files into system directories that may require elevated permissions:

package.cpath = "./?.so;" .. package.cpath
local mylib = require("mylib")

The ? character in each path pattern is a placeholder that Lua replaces with the module name at load time. On Linux systems, the default search paths typically include the current directory and the Lua versioned library directories. If your module does not load, double-check both the file name and the directory. A mismatch in either will cause require() to fail silently.

Now that you understand how to build and deploy a C module, the next section covers the essential header files every extension source file must include:

Header files

Include these three headers in every C extension source file, in this order:

#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
  • lua.h: state management, stack operations, VM core
  • lauxlib.h: helper functions (luaL_checkinteger, luaL_newlib, luaL_newmetatable, etc.)
  • lualib.h: declarations for the luaopen_* functions of the standard libraries

Version differences

The Lua C API has changed between major versions:

VersionKey Change
Lua 5.1luaL_register(L, name, funcs) is the module registration function. luaL_Reg arrays include the module name.
Lua 5.2luaL_register removed. Use luaL_newlib + luaL_setfuncs. luaL_Reg no longer includes the module name. lua_newstate takes an allocator; state starts with no libraries.
Lua 5.3lua_Integer is now int64_t by default (configurable at build time). lua_pushinteger pushes a lua_Integer, not a long.
Lua 5.4State creation unchanged from 5.3. luaL_checkstack has a new flags argument (pass 0). The auxiliary library is largely stable.

Use the LUA_VERSION_NUM macro for compile-time version checks:

#if LUA_VERSION_NUM == 503
    /* Lua 5.3-specific code */
#elif LUA_VERSION_NUM == 504
    /* Lua 5.4-specific code */
#endif

Common pitfalls

Forgetting the return count. If you push two values and return 1, Lua receives only the first. If you return 0 by mistake, the function returns nil.

Leaving the stack dirty on error. If your function calls luaL_error (which never returns), any values you pushed before the error remain on the stack. This is usually harmless because the error unwinds the call stack, but if you allocate resources during the function, clean them up before raising an error.

Dangling pointers from lua_tostring. The pointer returned by lua_tostring points into Lua’s internal string buffer. It is valid only until the string is garbage collected, which typically happens when it is popped from the stack. Store a copy with strdup if you need it to outlive the stack operation.

Mismatched metatable names. When you register a metatable with luaL_newmetatable(L, "Vec3"), you must use exactly the same string when checking with luaL_checkudata(L, idx, "Vec3"). Typos produce silent type check failures.

Calling convention on Windows. The lua_CFunction type uses the C calling convention. If you declare a function as __stdcall by accident, Lua will crash when it tries to call it. Ensure your extension module is compiled with a compatible calling convention.

Module file name vs function name. If your source file is vec3.c and you compile to vec3.so, the entry point must be luaopen_vec3. If you rename the compiled file to math3.so, Lua looks for luaopen_math3 and will fail to load the module.

A complete worked example

The sections above covered individual pieces of the C extension puzzle: headers, the stack, module registration, userdata, and metatables. The following example brings all of these concepts together in one file: a complete vec3 module that exposes a three-dimensional vector type with constructor, dot product, and cross product operations. Study it as a template for your own extensions.

File vec3.c:

#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <math.h>
#include <stdlib.h>

typedef struct {
    double x, y, z;
} Vec3;

static int l_vec3_new(lua_State *L) {
    double x = luaL_optnumber(L, 1, 0.0);
    double y = luaL_optnumber(L, 2, 0.0);
    double z = luaL_optnumber(L, 3, 0.0);
    Vec3 *v = (Vec3 *)lua_newuserdata(L, sizeof(Vec3));
    v->x = x; v->y = y; v->z = z;
    luaL_getmetatable(L, "Vec3");
    lua_setmetatable(L, -2);
    return 1;
}

static int l_vec3_dot(lua_State *L) {
    Vec3 *a = (Vec3 *)luaL_checkudata(L, 1, "Vec3");
    Vec3 *b = (Vec3 *)luaL_checkudata(L, 2, "Vec3");
    lua_pushnumber(L, a->x * b->x + a->y * b->y + a->z * b->z);
    return 1;
}

static int l_vec3_cross(lua_State *L) {
    Vec3 *a = (Vec3 *)luaL_checkudata(L, 1, "Vec3");
    Vec3 *b = (Vec3 *)luaL_checkudata(L, 2, "Vec3");
    Vec3 *r = (Vec3 *)lua_newuserdata(L, sizeof(Vec3));
    r->x = a->y * b->z - a->z * b->y;
    r->y = a->z * b->x - a->x * b->z;
    r->z = a->x * b->y - a->y * b->x;
    luaL_getmetatable(L, "Vec3");
    lua_setmetatable(L, -2);
    return 1;
}

static const luaL_Reg vec3_funcs[] = {
    {"new",   l_vec3_new},
    {"dot",   l_vec3_dot},
    {"cross", l_vec3_cross},
    {NULL, NULL}
};

int luaopen_vec3(lua_State *L) {
    luaL_newlib(L, vec3_funcs);

    /* Create metatable for Vec3 userdata objects */
    luaL_newmetatable(L, "Vec3");
    lua_pushstring(L, "__index");
    lua_pushvalue(L, -2);
    lua_settable(L, -3);     /* metatable.__index = metatable */

    return 1;
}

To compile this module, save the code to a file named vec3.c and run the build command below. The -lm flag links the maths library, which is needed for the dot product and cross product calculations that use floating-point arithmetic internally:

gcc -shared -fPIC -o vec3.so vec3.c -lm $(pkg-config --cflags --libs lua5.4)

Once the shared object is compiled and placed somewhere on package.cpath, loading and using it from Lua requires nothing more than a standard require() call. The module returns a table with all three vector operations available as regular functions, and each call to vec3.new() produces a full userdata object with the metatable attached:

local vec3 = require("vec3")

local a = vec3.new(1, 0, 0)
local b = vec3.new(0, 1, 0)

print(vec3.dot(a, b))            --> 0

local c = vec3.cross(a, b)
print(string.format("%.1f, %.1f, %.1f", c.x, c.y, c.z))

Further reading

This article covered the mechanics of writing and loading C extensions. The topics not addressed here (which go beyond a single tutorial) include: calling Lua functions from C (C as a Lua host), creating C closures with upvalues via lua_pushcclosure, the full userdata metatable API (__gc, __len, __pairs, etc.), managing multiple Lua states simultaneously, and coroutine integration with the C stack.

Consult the Lua 5.4 reference manual for the complete API specification, and the PiL book (particularly its C API chapters) for broader context on embedding and extending.

See also