Calling into C from a scripting language used to mean writing glue code, compiling a module, matching build flags, and rebuilding every time a header changed. The FFI built into LuaJIT throws that entire workflow out the window. You declare the C signature you need in Lua, load the shared library, and call the function as if it had always been a Lua function.
This tutorial covers the full path from your first declaration to bindings you would actually ship: how the FFI represents C data, how to declare types and functions correctly, how to handle memory without leaking, how pointers and strings really behave, how to hand Lua functions back to C as callbacks, and the patterns that keep the JIT compiler producing fast machine code. The following sections cover:
- What the FFI does under the hood and why it is faster than a traditional binding
- Setup requirements and the first working call
- Type and function declarations with
ffi.cdef - Loading shared libraries and calling into them
- Structs, unions, and arrays
- Memory ownership, garbage collection, and manual free
- Pointers, casting, and string conversion
- Callbacks from C back into Lua
- Performance habits that keep the JIT happy
- Common pitfalls and how to sidestep them
What the FFI Actually Does
The FFI is a foreign function interface built directly into the LuaJIT runtime. Instead of translating your declarations into a C wrapper that a compiler then builds, it parses C declarations at runtime and generates machine code for each call on the spot.
That means three big wins. There is no compilation step in your build chain. There is no wrapper layer eating a function call of overhead. And the JIT compiler can inline the call into your Lua loops, so a hot path that crosses into C can still run at native speed.
The tradeoff is honesty about types. The FFI trusts your declarations completely. If you describe a struct wrong, nothing warns you — you just get garbage. Precision matters more here than in almost any other Lua code you will write.
Setup Requirements and the First Call
You need a LuaJIT runtime, which the FFI ships with — no extra installation required. A 64-bit build is strongly recommended, since most modern C libraries are 64-bit and pointer handling is cleaner. You also need the shared library you intend to call, available somewhere the loader can find it.
Every binding starts the same way:
local ffi = require("ffi")
The ffi table gives you the declaration parser, the type constructors, memory allocation, and access to the default process-wide symbol namespace. Check ffi.os, ffi.arch, and ffi.abi("64bit") when you need platform-specific layouts or pointer sizes.
Declaring Types and Functions with ffi.cdef
Declarations go inside ffi.cdef as a string. It accepts a substantial subset of C syntax: typedefs, structs, unions, enums, function prototypes, and fixed-size arrays. It does not require headers to be installed, and it will not follow includes.
ffi.cdef[[
typedef struct { double x, y; } point_t;
double point_length(const point_t *p);
int point_add(point_t *out, const point_t *a, const point_t *b);
]]
Copy declarations straight from the real header rather than retyping them from memory. Sizes, signedness, and field order all have to match the compiled library exactly. When in doubt, verify with ffi.sizeof and ffi.offsetof against values printed by a tiny C program. If the numbers disagree, your declaration is wrong.
Loading Libraries and Calling In
Use ffi.load to open a shared object and get a namespace of its exported symbols. Names given to ffi.load resolve using standard loader rules, so file extensions and search paths differ across platforms.
local lib = ffi.load("geometry")
local a = ffi.new("point_t", {1, 2})
local b = ffi.new("point_t", {3, 4})
local out = ffi.new("point_t")
lib.point_add(out, a, b)
print(out.x, out.y)
If a symbol lives in the process itself rather than a separate library, it is reachable through ffi.C — useful for standard runtime functions such as strlen or memcpy. Symbol lookup happens on first call, so a typo surfaces as an error at call time, not at declaration time.
Structs, Unions, and Arrays
Aggregate types are created with ffi.new, which accepts an optional initializer. Scalars can be assigned directly; arrays and nested structs take a table or use explicit indexing.
local buf = ffi.new("uint8_t[1024]")
buf[0] = 42
local len = ffi.sizeof(buf)
Reading and writing fields from Lua is fast, but each access is still an operation the JIT must compile. For hot loops, hoist the struct pointer into a local and reuse it rather than reallocating inside the loop body.
Memory Ownership and Freeing
Memory created with ffi.new is managed by the garbage collector as cdata, which is what you want in almost every case. Memory returned by C — a pointer from an allocator or a buffer owned by the library — is not yours, and the runtime has no idea what to do with it.
Two mechanisms handle that. Use ffi.gc to attach a finalizer to a cdata object so a matching free runs when Lua collects it:
local p = ffi.gc(ffi.C.malloc(256), ffi.C.free)
Or manage the lifetime explicitly and call the free function yourself. Never let a raw pointer outlive the structure it points into, and never free memory you did not allocate. Double frees and dangling pointers crash the interpreter rather than raising a Lua error.
Pointers, Casting, and Strings
Pointer arithmetic works with standard indexing: p[0], p[1], and so on, sized according to the declared element type. Use ffi.cast to reinterpret a pointer as a different type, and ffi.NULL for null comparisons.
Strings are where people get hurt. Passing a Lua string to a const char * parameter is allowed, but the pointer only stays valid for the duration of the call. If C stores it, you have a dangling reference. Convert the other way with ffi.string(ptr) for null-terminated data, or ffi.string(ptr, len) for a known length. Copy into a cdata buffer with ffi.copy when you need something durable.
Callbacks From C Into Lua
You can pass a Lua function anywhere C expects a function pointer by casting it. The runtime creates a callback that stays alive as long as the cdata reference does — so keep that reference somewhere, typically in a table on the Lua side. If it is collected while C still holds the pointer, the next call crashes.
Keep callback bodies small. They are invoked through a trampoline, so heavy work belongs back in ordinary Lua code rather than inside the callback itself.
Keeping the JIT Happy
FFI calls are only fast when the compiler can predict them. A few habits preserve that:
- Cache types with
ffi.typeofand reuse them instead of reparsing strings. - Avoid
ffi.newin hot loops; allocate buffers once and reuse them. - Keep call sites monomorphic — one function, one set of argument types.
- Avoid stringifying cdata in loops;
tostringon a cdata value is slow. - Attach behaviour with
ffi.metatypeso structs feel like objects without extra dispatch cost.
Pitfalls That Bite Everyone
Most FFI bugs trace back to a handful of causes. Struct layout mismatches from padding or field order. Wrong integer width, especially with 64-bit values, which become cdata objects rather than Lua numbers and behave differently in comparisons and arithmetic. Confusion between C booleans and Lua booleans. Assuming structs returned by value work everywhere — support varies by architecture, so verify before relying on it. And trying to call variadic C functions directly, which the FFI does not support; write a fixed-argument wrapper in C instead.
When a call misbehaves, check the error number immediately after it returns, before any other call runs and overwrites it. That single habit solves a surprising share of debugging sessions.
Start Small, Then Go Deep
Begin with one function and one struct. Get the declaration right, confirm ffi.sizeof matches reality, make the call, then print the result. Once that cycle feels routine, layer in pointers, buffers, and callbacks — and you have a complete binding without a single line of compiled glue code.
The FFI rewards precision and punishes guessing, but the payoff is enormous: native-speed access to the entire C ecosystem from comfortable Lua. Keep experimenting, keep measuring, and keep an eye on TechBlazing for more deep dives into the tools that make modern development faster.