Using Zig to build Native Lua Scripts

I’ve been playing with Zig a lot lately. It’s one of my favorite pieces of tech I’ve found in the last few years. One of my favorite features is how easy it is to compile C libraries with it. Of course when I think “C libraries”, the first that comes to mind is Lua.

Lua is a really cool “embeddable” progrramming language. It’s made to be put “inside” larger projects primarily. Some examples of things that use Lua include

So of course, to run in so many places, Lua itself has been built from the ground up to be “embedded”. It is distributed as an archive C source files and documentation. This is great news for us with Zig, since Zig is a c compiler!

Getting Lua to compile inside a Zig project is really easy! easier than C/C++ in my opinion. I’ll glaze the details, plucking the important parts

First, in build.zig, we need to link libc, and add it’s source files. This looks like:

const exe = b.addExecutable("wrapper", "wrapper.zig");

const lua_c_files = [_][]const u8{

if(target.os_tag == {
    const c_flags = [_][]const u8{
    inline for (lua_c_files) |c_file| {
        exe.addCSourceFile("lua-5.3.4/src/" ++ c_file, &c_flags);
} else {
    const c_flags = [_][]const u8{
    inline for (lua_c_files) |c_file| {
        exe.addCSourceFile("lua-5.3.4/src/" ++ c_file, &c_flags);

That’s really it! It even adds support for Windows. All that’s left is to just use it. This works like any other C library with Zig. For this project, I decided I would make a single executable out of a Lua script. Here’s the source of the Lua script to give additional context:

print("press Y")

local input = "\0"

while(input ~= 'y' and input ~= 'Y') do
  input =


So all the Zig code needs to do is somehow “embed” that script, and execute it inside of the Lua VM. Lua offers a luac executable to compile a script file into a chunk of Lua bytecode that can then be executed. This isn’t strictly necessary, but i compiled the luac executable with Zig:

make -C lua-5.3.4/ generic CC="zig cc"

Next I compiled my script:

./lua-5.3.4/src/luac main.lua

Which outputs a luac.out file. This itself obviously isn’t an executable tho. Luckily, Zig has a built-in for us to use:

pub const LUA_BYTECODE = @embedFile("luac.out");

Finally, all that’s left is to execute the bytecode with lua_pcallk:

const lua = @cImport({

pub fn main() anyerror!void {
    var s = lua.luaL_newstate();
    const load_status = lua.luaL_loadbufferx(s, LUA_BYTECODE, LUA_BYTECODE.len, "main.lua", "bt");
    if (load_status != 0) {"Couldn't load lua bytecode: {s}", .{lua.lua_tolstring(s, -1, null)});
    const call_status = lua.lua_pcallk(s, 0, lua.LUA_MULTRET, 0, 0, null);
    if (call_status != 0) {"{s}", .{lua.lua_tolstring(s, -1, null)});

I tested this out on my arm Mac, an x86 Mac, my Windows PC, WSL, and on several Linux installations and it works great!

Compiling is done with:

zig build -Drelease-small -Dtarget=<target>

Where target can be one of:

  • x86_64-windows
  • x86_64-macos
  • aarch64-macos
  • aarch64-linux-musl
  • x86_64-linux-musl

I’m sure there are other targets that work, but those are the ones I tested.

All the source for this project is On Github I also uploaded precompiled binaries to Github as well: Here.