luaguides

string.packsize

string.packsize(fmt)

What string.packsize does

string.packsize(fmt) returns the number of bytes that string.pack would write for the same format string. It does not allocate a buffer or evaluate any values; it walks fmt, sums the fixed sizes, and includes any padding. The result is a Lua integer you can pass straight into string.unpack as a length or use as a buffer size.

A quick sanity check on a 64-bit Lua 5.4 build:

print(string.packsize("i4"))     --> 4
print(string.packsize("bjd"))    --> 17

The first format is a 4-byte int and packs to 4 bytes. The second combines a signed byte, a native lua_Integer, and a native double for 1 + 8 + 8 = 17 bytes.

The function is part of the pack family introduced in Lua 5.3. It is available in Lua 5.3, 5.4, and the 5.5 work versions. Lua 5.1 and 5.2 do not have it, and LuaJIT only added it in 2.1.0. Calling it on an older build raises “attempt to call a nil value” because the global is missing.

One caveat that comes up often: native-sized options such as h, H, l, L, j, J, T, f, d, and n resolve against the size of short, long, lua_Integer, and so on in the Lua binary you are running. The l option is 4 bytes on a 32-bit long build and 8 bytes on a 64-bit long build, and T (size_t) tracks pointer width the same way. If you need a fixed width on the wire, use the explicit-width options i1i8 and I1I8 instead.

The usual job for packsize is sizing a buffer before filling it. The count is exact, so it always matches what string.pack will produce — no off-by-one guessing:

-- preallocate a buffer that matches a known binary layout
local fmt  = "<i4I2c1f"   -- 4 + 2 + 1 + 4 = 11 bytes
local n    = string.packsize(fmt)
local buf  = string.rep("\0", n)
print(n, #buf)             --> 11   11

Parameters

ParameterTypeDefaultDescription
fmtstring(required)Format string using the syntax described in §6.4.2 of the Lua reference manual. Must not contain the variable-length options s or z.

Return value

A Lua integer: the number of bytes string.pack(fmt, ...) would produce. The guarantee is exact; padding bytes written by alignment flags are included in the count.

Format options and their sizes

Every option below is a fixed number of bytes except s and z, which are forbidden in packsize. The “Size on 64-bit Lua” column assumes a standard LP64 build with 8-byte lua_Integer and 8-byte double. Native-sized rows will differ on a 32-bit build.

OptionMeaningSize on 64-bit Lua
xone NUL pad byte1
X<op>empty item that aligns like the following option op (consumes op)0
< > =set little, big, or native endian0
!nset max alignment to n (default 1)0 plus padding
b / Bsigned / unsigned char1
h / Hsigned / unsigned short (native)2
l / Lsigned / unsigned long (native)8
j / Jlua_Integer / lua_Unsigned8 (4 if LUA_32BITS)
Tsize_t8
i[n] / I[n]signed / unsigned int of n bytesn in {1, 2, 4, 8}
ffloat (native)4
ddouble (native)8
nlua_Number8 (4 if LUA_32BITS)
cnfixed-size string of n bytesn
slength-prefixed stringforbidden
zzero-terminated stringforbidden

Every format string behaves as if it started with !1=, which gives a max alignment of 1 and native endianness. Bumping it to !4 or !8 inserts NUL padding so the next field lands on a 4- or 8-byte boundary, and that padding is counted.

Worked examples

The counts below were checked against a stock lua -v reporting Lua 5.4.x. They assume a standard 64-bit build where lua_Integer and double are both 8 bytes.

-- one signed byte, one lua_Integer, one double: 1 + 8 + 8 = 17
print(string.packsize("bjd"))          --> 17

The first example combines a signed byte, a native lua_Integer, and a native double into one format string. On a standard 64-bit Lua build, that comes to 1 + 8 + 8 = 17 bytes total. The result does not depend on which values you would later pass to string.pack, because packsize only inspects the layout, not the data.

-- explicit widths, big-endian: i2 + I4 + c3 = 2 + 4 + 3
print(string.packsize(">i2I4c3"))      --> 9

Using big-endian with explicit widths is a common pattern for cross-platform binary data. The format here asks for a 2-byte signed int, a 4-byte unsigned int, and a 3-byte fixed string, with no alignment padding requested. The total is just the sum of the field widths: 2 + 4 + 3 = 9 bytes.

-- 'x' is a NUL pad byte; 'X' is a no-data alignment marker
print(string.packsize("xbB"))          --> 3    (1 pad + 1 + 1)
print(string.packsize("c1!4jB"))       --> 13   (1 + 3 pad + 8 + 1)
print(string.packsize("c1!4XjB"))      --> 5    (1 + 3 pad + 0 + 1, Xj is 0 bytes)

The x option writes one NUL pad byte and adds 1 to the count. The X<op> option is different: it is an empty item that aligns according to the next option op and consumes op from the format. X itself adds 0 bytes — the only padding in the second and third lines comes from the alignment rule on j after !4. The third line is 2 bytes shorter than the second because Xj skips the data slot that j would have occupied, and only the alignment of j matters.

-- alignment costs bytes when you ask for it
print(string.packsize("c1j"))          --> 9
print(string.packsize("c1!8j"))        --> 16

Alignment is where the byte count can jump around. The format c1j uses the default max alignment of 1, so the 1-byte char sits flush against the lua_Integer for a total of 9 bytes. Asking for alignment of 8 with c1!8j pads 7 NUL bytes after the char so the integer lands on an 8-byte boundary, bringing the total to 16. (c alone is not a valid option — c needs an explicit size — so the format is c1 for a single byte.)

-- endianness flags do not change the size on their own
print(string.packsize("<i4"), string.packsize(">i4"))  --> 4   4

Endianness flags < and > change the byte order of subsequent numeric options, but they take zero bytes themselves. A 4-byte int is still a 4-byte int no matter which endianness you set, and packsize returns the same number for both. This makes endianness safe to swap in your format strings without re-sizing your buffer.

-- fixed-size string: 10 bytes, no error
print(string.packsize("c10"))          --> 10

The c<n> form is the fixed-size string option. It always counts as exactly n bytes, and string.pack will truncate or NUL-pad the value to fit. This is the right tool when you have a known-width field like a 4-character type code or a 32-byte hash. Reach for it instead of s whenever you know the length up front.

-- forbidden: 's' or 'z' anywhere in the format raises
assert(pcall(string.packsize, "sB") == false)

Variable-length options are not allowed, and the error fires the moment packsize sees an s or z anywhere in the format string. You cannot sneak it past with a different position. Even one occurrence in a long format makes the whole call fail. If you need a string in your layout, switch to the fixed-width c<n> option instead.

-- empty format: 0 bytes
print(string.packsize(""))             --> 0

An empty format string is a valid edge case. It produces no bytes and no error, so packsize returns 0 and the corresponding string.pack returns the empty string. This is occasionally handy as a default value when constructing a format dynamically.

A more realistic layout: a wire-format header that mixes explicit widths and a pad byte.

-- header: id(4) + flags(2) + type(1) + reserved(1) + length(4) = 12
print(string.packsize(">i4I2Bxi4"))   --> 12

Reading the format left to right: a 4-byte signed int, a 2-byte unsigned int, a 1-byte unsigned char, a 1-byte NUL pad, and a 4-byte signed int. With no alignment flag, every field sits flush against the previous one, so the count is just the sum of the field widths: 4 + 2 + 1 + 1 + 4 = 12 bytes. This is the kind of layout you would use for a fixed-size message header that gets read by string.unpack on the other end.

When an alignment flag is in play, the manual count has to include the pad bytes the alignment forces.

-- b!4jB: 1 byte + 3 pad bytes to align j to 4, then 8-byte j, then 1 byte
print(string.packsize("b!4jB"))       --> 13

The !4 lifts the max alignment to 4 partway through the format. The 8-byte j has to start on a 4-byte boundary, so three NUL pad bytes are inserted between b and j. The total is 1 + 3 + 8 + 1 = 13, and packsize includes those three pad bytes in the count. Cross-check by adding the field widths (1 + 8 + 1 = 10) and the alignment slack (3) and you get the same 13.

Common pitfalls

s and z are rejected. The format string cannot contain the variable-length s (length-prefixed) or z (zero-terminated) options. packsize raises an error like bad argument #1 to 'string.packsize' (variable-length format). The check happens up front, but the variable length is the reason no fixed size exists.

Native sizes are not portable. The l option is 4 bytes on a 32-bit long build and 8 bytes on a 64-bit long build, and T (size_t) tracks pointer width the same way. If the format is read by another binary, prefer explicit-width options: i1i8, I1I8, f, d, cn.

Alignment padding is counted. Adding !4 or !8 inserts NUL padding, and packsize includes it. That is useful when sizing buffers, and surprising if you assumed a “minimum” footprint.

Empty format returns 0. string.packsize("") is 0, and string.pack("") is the empty string. No error.

The result is a Lua integer. Feed it straight into string.pack, string.unpack, or string.byte without any conversion.

Available since Lua 5.3. Code targeting Lua 5.1, 5.2, or pre-2.1 LuaJIT will hit “attempt to call a nil value” because string.packsize was not in the global table.

See also