string.pack
string.pack(fmt, v1, v2, ...) string.pack is Lua 5.4’s binary serializer. It takes a format string and a list of values, and returns a string containing those values laid out as bytes according to the format. The companion string.unpack reads such a string back into values; string.packsize reports the size in bytes that a given format would produce.
Together they cover the standard way to read and write binary protocols, file headers, and C-compatible structs from pure Lua. If you have ever reached for a struct-pack library in another language, this is Lua’s built-in equivalent.
string.pack is a function, not a string method. The string metatable does not expose a pack key, so the method form errors:
-- Fails: attempt to index a string value.
-- ("I4"):pack(42)
-- Works:
string.pack("I4", 42)
If you are used to s:format(...) for string.format, note that s:pack(...) does not exist. The function form is the only one.
Whatever you intended to put after the colon becomes the second argument to string.pack, with the receiver string itself treated as the format. The resulting error is misleading if you are migrating code that uses the string.format style.
Synopsis
string.pack(fmt: string, v1, v2, ...) -> string
Returns one binary string. Errors if fmt contains an unknown directive, if an argument is the wrong type for its slot, or if the value does not fit in the target size.
Parameters
| # | Name | Type | Description |
|---|---|---|---|
| 1 | fmt | string | Format string. A sequence of single-character options, most of which take one value from the variadic arguments. |
| 2..N | v1, v2, ... | varies | Values to serialize. One per value-consuming option in fmt, in order. Types must match the option. |
fmt is implicitly prefixed with !1=, meaning native byte order and 1-byte alignment. To get C-style struct alignment, start the format with !n= (for example !4= for 4-byte alignment). Endianness set with <, >, or = is sticky: it applies to every subsequent integer option until you change it again.
-- 4-byte unsigned + native int, big-endian, 4-byte aligned.
local buf = string.pack(">I4i4", 0x1000, -1) -- 8 bytes
Return value
A single string containing the packed bytes. The string is 8-bit clean: it may contain NUL bytes and any other byte value, so treat it as opaque binary data, not as text. For a fixed format, the length of the result is string.packsize(fmt).
Format string options
Every option is a single character. Configuration options consume no argument; every other option consumes one value from the variadic list. Sizes below assume a standard 64-bit Lua 5.4 build (32-bit int, 64-bit lua_Integer, 64-bit double).
Configuration (no argument consumed)
| Option | Effect |
|---|---|
< | Set little-endian byte order for all subsequent integer options. |
> | Set big-endian byte order. |
= | Set native byte order. |
!n | Set maximum alignment to n bytes, where n is 1..16. !1 disables alignment. |
X op | Pad (or skip, on unpack) until the next field is aligned to the alignment of op. For example, Xi aligns to a native int. |
' ' (space) | Ignored. Useful for readability inside long formats. |
These prefixes are usually combined at the start of a format string to fix the layout for the rest of the call:
-- Big-endian, 4-byte alignment: a typical wire-protocol header.
local fmt = "!4>I4i4"
Integers
| Option | Size | Description |
|---|---|---|
b | 8 bits | Signed byte (two’s complement). |
B | 8 bits | Unsigned byte. |
h | 16 bits (native) | Signed short. |
H | 16 bits (native) | Unsigned short. |
l | 32 bits (native) | Signed long. |
L | 32 bits (native) | Unsigned long. |
j | lua_Integer (8 bytes on 64-bit) | A signed Lua integer. |
J | lua_Unsigned | An unsigned Lua integer. |
T | size_t (native, 8 bytes on 64-bit) | A native size_t. |
i[n] | n bytes (1..16) | Signed int, exactly n bytes. Default n is native int size (4). |
I[n] | n bytes (1..16) | Unsigned int, exactly n bytes. |
i and I mean “native int”, not “Lua integer”. On a standard 64-bit build, i is 4 bytes while j is 8 bytes. If you want the full 64-bit Lua integer range, write j or i8, not i. This trips up a lot of people.
Here is the size difference in one line, with the byte counts you would see on a standard 64-bit build:
print(string.packsize("i")) -- 4
print(string.packsize("I4")) -- 4
print(string.packsize("j")) -- 8
print(string.packsize("i8")) -- 8
print(string.packsize("T")) -- 8
Use i only when the receiving side is C code that declared a plain int; use j for any value that might exceed 2^31 - 1.
Floats
| Option | Size | Description |
|---|---|---|
f | 32 bits (native) | IEEE-754 single precision. |
d | 64 bits (native) | IEEE-754 double precision. |
n | lua_Number | A Lua number, written using the configured numeric type regardless of native float width. |
A float directive reads and writes a Lua number using the C type the option names. f is a C float (32 bits), d is a C double (64 bits), and n is whatever the build configured for lua_Number:
-- 32-bit single, then 64-bit double. 4 + 8 = 12 bytes.
local buf = string.pack(">fd", 1.5, math.pi)
print(#buf) --> 12
Strings
| Option | Description |
|---|---|
c n | A fixed-size string of exactly n bytes. Shorter strings are right-padded with NULs; longer strings raise an error. |
z | A zero-terminated string. The NUL is added on pack and consumed on unpack. |
s[n] | A length-prefixed string. The length is encoded as an unsigned integer of n bytes (1..16, default = size_t). On pack, the value can be a string OR a number; a number is taken as a length and writes that many NUL bytes after the prefix. |
These three options cover the bulk of wire-format work. c n is for fixed-width tags, z is for C-string-shaped data, and s[n] is the safest default for variable payloads:
-- Magic 4-byte code, then a length-prefixed payload. 14 bytes total.
local buf = string.pack(">c4s4", "PNG", "header")
Padding
| Option | Description |
|---|---|
x | One byte of padding. Zero on pack, ignored on unpack. |
X op | Alignment pad. As above, sized to op. |
Alignment and byte order
Two rules cover most of the confusion.
- Byte order is sticky.
<,>, and=set the byte order for every subsequent integer option until you change it again. The manual warns against naively mixing endianness within one format:string.packdoes not emulate mixed-endian hardware. - Default alignment is none. Because the implicit prefix is
!1=, the first field is never aligned, even if it is a 4-byte int. To get C-struct-style natural alignment, start the format with!n=.
Two more rules that bite less often:
candzare never aligned. If you need them aligned, insertxorX opexplicitly.s[n]aligns to the alignment of its length-integer.
Here is the alignment difference in one call:
-- First field, no padding either way.
string.pack("i4", 0x11223344) -- 4 bytes
-- 1-byte header + 3 pad + i4 = 8 bytes, with 4-byte alignment.
string.pack("!4=Bi4", 0xFF, 0x11223344) -- 8 bytes
The second format reproduces how a typical C compiler lays out { uint8_t tag; int32_t payload; }: one byte of tag, three bytes of pad, then the 32-bit value at a 4-byte offset.
Examples
The following examples build on each other. The first two show the basic round trip and the most common wire-format shape. The later examples cover alignment, integer-size traps, and how to read a struct from a buffer you already have.
Pack and unpack a small header
A 32-bit unsigned length followed by three floats. Big-endian, no alignment, sixteen bytes total.
local fmt = ">I4fff"
local packet = string.pack(fmt, 3, 1.0, 2.5, -3.75)
print(#packet) -- 16
local n, x, y, z = string.unpack(fmt, packet)
assert(n == 3 and x == 1.0 and y == 2.5 and z == -3.75)
The same format goes through string.unpack and gives the values back in the same order. The second return value is the byte offset just past the consumed data, which is what you use to chain reads across concatenated fields or stream-style parsers. Comparing it against #data tells you whether a later field ran off the end of the buffer, so the unpack is safe to call on a slice of a larger packet.
Length-prefixed string for a wire format
local s = string.pack(">s4", "hello")
-- 9 bytes: 00 00 00 05 68 65 6c 6c 6f
print(#s) -- 9
local str, next_pos = string.unpack(">s4", s)
print(str, next_pos) -- hello 10
s4 means “length as a 4-byte big-endian unsigned int, then the bytes”. This is the standard “Pascal-style” string layout in binary protocols, and it is what most file formats use for variable-length payloads. Use a length prefix when the consumer does not have a NUL terminator to scan for, or when the payload may itself contain NULs.
Fixed-size field with NUL padding
local s = string.pack("c4", "OK")
print(#s) --> 4
print(s:byte(1, 2)) --> 79 75 ('O', 'K')
-- Packing a 5-byte string into c4 raises an error.
-- string.pack("c4", "abcde")
-- error: string longer than given size
c n is the right tool for fixed-width codes like country codes, magic numbers, or 4-byte type tags. Pair it with B (unsigned byte) and Xi (alignment pad) when you are reproducing a C struct byte-for-byte, or with a longer format string when the fixed field sits next to variable-length data.
Why i is not Lua’s integer
print(string.packsize("i")) --> 4 (native int)
print(string.packsize("j")) --> 8 (lua_Integer on 64-bit)
print(string.packsize("i8")) --> 8 (explicit 8-byte int)
i matches the C int type, not Lua’s integer. On a 64-bit build it is 4 bytes, and any value above 2^31 - 1 will overflow. Use j (or i8) for the full 64-bit range. This is the single most common string.pack surprise.
Unsigned wraparound for negative values
The two’s-complement representation of -1 is 0xFFFF, and the unsigned option reads it back as 65535. Same convention as C’s (uint16_t)-1.
-- Unsigned options treat Lua integers as unsigned.
print(string.pack("I2", -1):byte(1, 2)) --> 255 255
Packsize for pre-allocated buffers
When you build a packet by hand, string.packsize tells you exactly how many bytes a format will produce. Comparing the produced length against string.packsize is a cheap sanity check that no alignment surprise slipped in:
local fmt = "!4=Bi4" -- 1 (B) + 3 (pad) + 4 (i4) = 8
print(string.packsize(fmt)) -- 8
local buf = string.pack(fmt, 0xFF, 0x11223344)
assert(#buf == string.packsize(fmt))
The same function tells you, on the unpack side, that the buffer you read from disk or the network is at least that long before it is safe to call string.unpack on it.
Common pitfalls
-
Method form does not exist.
s:pack(...)errors. Usestring.pack(fmt, ...). -
iis the Cint, notlua_Integer. On a 64-bit build,iis 4 bytes and overflows past2^31 - 1. Usejori8for the full Lua integer range. -
Default alignment is none. The implicit
!1=prefix means a format starting withi4does not get a 4-byte pad. Prefix with!4=(or whatever alignment you need) for C-struct-style layout. -
Overflow raises, it does not wrap.
string.pack("b", 200)errors. Lua arithmetic wraps modulo2^n;string.packandstring.unpackdo not. They strictly check for fit.-- 200 does not fit in a signed byte. string.pack("b", 200) -- error: value does not fit -
s[n]accepts a number for “length”.string.pack("s4", 5)writes a length of 5 followed by 5 NUL bytes, with no string. Easy to trigger by accident when iterating over heterogeneous data. -
candzare never aligned. InsertxorX opexplicitly if you need padding before a fixed-size or zero-terminated string. -
Byte order is sticky. A format like
<I4>I4packs the first int little-endian and the second big-endian. The manual warns that this only works because the underlying operations are simple byte-swap-style writes; the functions do not handle arbitrary mixed-endian layouts. -
zstrings cannot contain NUL bytes. Azvalue with an embedded\0is truncated at the first NUL on unpack. Usec n(fixed) ors n(length-prefixed) when NULs are possible. -
The optional
non!n,i[n],I[n],s[n]is 1..16. Larger values raise an error. -
Type mismatch raises, does not coerce.
string.pack("i4", "oops")errors rather than trying to calltonumberfor you. If you accept untrusted input, wrap the call inpcalland report the error yourself:local ok, err = pcall(string.pack, "i4", "oops") if not ok then print("bad input:", err) -- bad input: ...format expects... end
For the full list of format-string options and their byte-level semantics, see §6.4.2 of the Lua 5.4 Reference Manual.
See also
- string.format: text formatting, the printf-style counterpart to
string.pack. - string.byte: read packed bytes back out as integers.
- string.char: build a one-byte string from an integer code.
- string.sub: slice into a packed buffer.
- string.len: length of a packed string.
- Working with binary data in Lua: broader guide on building and parsing binary buffers.
- Lua FFI guide: when
string.packis not enough and you need the FFI. - Interop with C: using
string.packto call C APIs. - Lua serialization patterns: higher-level data formats built on top of binary packing.