A pure Lua implementation of Protocol Buffers encoding and decoding with zero external dependencies. This library provides a lightweight, portable implementation that runs on Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT.
- Zero Dependencies: Pure Lua implementation, no C extensions or external libraries required
- Portable: Runs on any Lua interpreter (5.1+)
- Schema-based: Uses Lua tables as proto schemas for type-safe encoding/decoding
- Schema Generator: Includes tool to generate Lua schemas from
.protofiles - Complete Types: Supports all standard protobuf wire types (varint, fixed32, fixed64, length-delimited, floats, doubles, zigzag encoding)
- Well-tested: Comprehensive self-tests with embedded test vectors
Download a pre-built single-file module from the Releases page:
protobuf.lua- the canonical core build. Requiresbitn(lua-bitn) on the Lua path. Use this when composing with libraries that already providebitn.protobuf-portable.lua- portable build with every dependency bundled in (zero external dependencies). Use this for a single drop-in file.
Clone this repository:
git clone https://github.com/finitelabs/lua-protobuf.git
cd lua-protobufAdd the src and vendor directories to your Lua path, or copy the files to
your project.
- Define your
.protofile (or use existing proto definitions) - Generate a Lua schema using
gen_lua_proto_schema - Use the schema with
encode()anddecode()functions
Use the included gen_lua_proto_schema tool to convert .proto files into Lua schemas.
The schema generator requires Python 3.8+ and protoc. Install all dependencies with:
# macOS - installs protoc, Python venv, and dependencies
make install-deps
# Ubuntu/Debian - install protoc manually, then run make
sudo apt-get install protobuf-compiler
make setup-schema-generatorThe tool accepts local file paths and/or URLs as input:
# Generate schema from a local proto file
make gen-schema PROTO=input.proto OUTPUT=output_schema.lua
# Generate from multiple proto files
make gen-schema PROTO="api.proto api_options.proto" OUTPUT=output_schema.lua
# Generate from a remote proto file (URLs supported)
make gen-schema PROTO="https://example.com/api.proto" OUTPUT=output_schema.lua
# Mix local files and URLs
make gen-schema PROTO="local.proto https://example.com/remote.proto" OUTPUT=output_schema.luaA generated schema is marked "Do not edit manually", and tools/proto-provenance
is what asserts it. stamp writes a header recording what produced the schema;
check verifies the schema still matches that header.
tools/proto-provenance stamp src/proto_schema.lua vendor/protobuf.lua esphome=2026.8.2
tools/proto-provenance check src/proto_schema.lua vendor/protobuf.lua esphome=2026.8.2-- Generated Lua schema from protobuf descriptor set
-- Do not edit manually
--
-- generator: v0.6.9
-- esphome: 2026.8.2
-- body-sha256: 7b363c22ecbeb0c31b59eb6af83e322f4fd7a8dea7a2daca122d70131dcc50dc
-- provenance-boundary: every line below is covered by body-sha256check compares three things:
- header
generatoragainstVERSIONin the vendored runtime - every caller-supplied
key=valueagainst the header line of that name - header
body-sha256against a fresh hash of everything below the boundary
Comparison 1 is the matched-pair invariant: the generator that emits the schema and the runtime that decodes against it ship from one release, and how a field is represented is co-designed with how it is read back. It is not a staleness check.
Comparison 2's fields are opaque. The tool records and compares whatever the
caller passes without interpreting it, so a consumer pins its own upstream
versions through it. A field recorded in the header but not supplied on the
command line is an error rather than a pass, so dropping one from a Makefile
cannot silently retire the comparison. generator and body-sha256 are reserved.
stamp runs during generation, where the toolchain is present anyway. check
regenerates nothing and reads only files already in the tree: no protoc, Python,
stylua or network. A consumer gets the tool by checking out this repo at the
release tag its vendored protobuf.lua came from, which needs no toolchain, and
runs it against its own tree.
It does not defend against a forged header, where the body is edited and the hash recomputed. The threat is accidental drift.
local protobuf = require("protobuf")
local schema = require("my_proto_schema")
-- Encode a message
local message = {
id = 123,
name = "example",
active = true
}
local encoded = protobuf.encode(schema, schema.Message.MyMessage, message)
-- Decode a message
local decoded = protobuf.decode(schema, schema.Message.MyMessage, encoded)
print(decoded.name) -- "example"| Proto Type | Wire Type | Notes |
|---|---|---|
| int32, int64, uint32, uint64 | varint | 64-bit uses {high, low} pairs on Lua 5.1 |
| sint32, sint64 | varint | ZigZag encoded |
| fixed32, sfixed32 | 32-bit | Little-endian |
| fixed64, sfixed64 | 64-bit | Little-endian |
| float | 32-bit | IEEE 754, including subnormals |
| double | 64-bit | IEEE 754, including subnormals |
| bool | varint | |
| string, bytes | length-delimited | |
| enum | varint | |
| message | length-delimited | Nested messages |
NaN, both infinities, negative zero and subnormals all round-trip. Encoding a
float narrows the double it is given by rounding to nearest with ties to even,
which is what the hardware does, so a value too small for the subnormal range
flushes to zero and one too large for the format becomes infinity.
On Lua 5.1 and LuaJIT (without 64-bit integer support), 64-bit values are
represented as {high, low} pairs where:
highis the upper 32 bitslowis the lower 32 bits
Example: 0x123456789ABCDEF0 is represented as {0x12345678, 0x9ABCDEF0}
Helper functions are provided:
-- Convert number to {high, low}
local int64 = protobuf.int64_from_number(1234567890123)
-- Convert {high, low} to number (may lose precision)
local num = protobuf.int64_to_number({0x00000001, 0xFFFFFFFF})
-- Convert to hex string
local hex = protobuf.int64_to_hex({0x12345678, 0x9ABCDEF0})# Install all development dependencies (stylua, luacheck, lua-language-server, amalg, protoc, Python venv)
make install-depsmake test # Run all tests
make test-protobuf # Run specific module tests
make test-matrix # Run tests across all Lua versions
make test-matrix-protobuf # Run specific module across all Lua versions
# Or use scripts directly with custom Lua binary
LUA_BINARY=lua5.1 ./run_tests.shmake check # Run format check, lint, check-provenance, check-types, and typecheck
make format # Format code with stylua
make format-check # Check formatting without modifying
make lint # Run luacheck
make check-provenance # Run the proto-provenance positive controls
make check-types # Verify types.lua matches empty.proto
make typecheck # Check annotations with lua-language-servermake build # Build single-file distributions (build/protobuf.lua [core], build/protobuf-portable.lua)
make clean # Remove generated filesmake help # Show all available targets- Pure Lua performance is slower than native protobuf libraries
- No constant-time guarantees
- Packed repeated fields not yet supported
- Unknown fields are discarded during decode
GNU Affero General Public License v3.0 - see LICENSE file for details
Contributions are welcome! Please ensure all tests pass and add new tests for any new functionality.
