Working With Endianness in Zig
The following Zig program demonstrates how to work with packed structs, byte-level serialization, and the new I/O system introduced in Zig 0.16. It defines a small Person structure, serializes it to bytes in little-endian format, and then deserializes it back, showcasing precise control over memory layout and data representation.
Endianness refers to the order in which bytes are arranged when storing multi-byte data types (such as u16, u32, or u64) in memory or when writing them to files and networks. There are two main types: Little-endian (least significant byte stored first) and Big-endian (most significant byte stored first). For example, the number 0x1234 in little-endian would be stored as bytes [0x34, 0x12], while in big-endian it would be [0x12, 0x34]. Endianness is crucial when dealing with binary file formats, network protocols, or cross-platform data exchange, as mismatched endianness can lead to completely incorrect values.
Packed structures, on the other hand, are a way to control the exact memory layout of a struct. In Zig, a packed struct removes padding bytes that the compiler would normally insert for memory alignment. This guarantees that the struct occupies exactly the size specified (e.g., packed struct(u16) uses precisely 2 bytes). Packed structs are extremely useful for binary serialization, hardware interaction, and when creating compact data formats because they allow developers to have precise, predictable control over every byte.
When used together — as seen in the example with packed struct(u16) and explicit little-endian serialization — they give developers powerful, low-level control over how data is represented in memory and on disk, which is one of Zig’s greatest strengths for systems programming.
const std = @import("std");
const Io = std.Io;
const Person = packed struct(u16) {
age: u8,
value: u8,
};
pub fn main(_: std.process.Init.Minimal) !void {
const gpa = std.heap.smp_allocator;
const p: Person = .{ .age = 20, .value = 100 };
var writer_state: Io.Writer.Allocating = .init(gpa);
const w = &writer_state.writer;
try w.writeInt(u16, @bitCast(p), .little);
std.debug.print("Written: {any}\n", .{writer_state.written()});
var reader: Io.Reader = .fixed(writer_state.written());
const readP: Person = @bitCast(try reader.takeInt(u16, .little));
std.debug.print("Read: {any}\n", .{readP});
}
This program starts by declaring a packed struct(u16) named Person, which packs two u8 fields (age and value) into a single 16-bit integer. This guarantees a compact memory layout with no padding. An instance is then created with age = 20 and value = 100.
Using Zig 0.16 new I/O abstractions, the code initializes an allocating writer and uses writeInt with @bitCast to convert the struct directly into a u16 value, written in little-endian byte order. After writing, it prints the raw bytes that were produced.
The program then creates a fixed reader from those same bytes and reads them back using takeInt (also in little-endian), before casting the result back into the Person struct. Finally, it prints the deserialized structure to verify the round-trip was successful.
This example beautifully illustrates several powerful features of modern Zig: packed structs for precise binary layouts, @bitCast for type-safe reinterpretation of memory, explicit endianness control, and the clean new std.Io Writer/Reader API. Such techniques are especially useful when working with binary file formats, network protocols, or embedded systems where memory layout matters.
Save the code as endianness.zig and try it:
$ zig version
0.16.0
$ zig run endianness.zig
Written: { 20, 100 }
Read: .{ .age = 20, .value = 100 }
Happy coding in Zig!