Why bytespec?

A device sends a packet: a four-byte user_id and an optional UTF-8 name with a one-byte length prefix. The fields are preceded by a message identifier, flags, and the body length. Your application needs packet.user_id and packet.name, while the network requires exactly this byte format.

Describe it with a single model for both reading and writing:

from bytespec import Constructor, Flags, PayloadLength, ProtoModel, field
from bytespec.types import UInt32

class Packet(ProtoModel):
    __constructor__ = 0x1234
    __header__ = (Constructor(2), Flags(1), PayloadLength(2))

    user_id: UInt32
    name: str | None = field(flag=0, prefix_length=1)

packet = Packet(user_id=42, name="Anna")
wire = packet.encode()
assert Packet.decode(wire) == packet
print(wire.hex(" "))
12 34 01 00 09 00 00 00 2a 04 41 6e 6e 61
12 34       | 01    | 00 09          | 00 00 00 2a | 04 41 6e 6e 61
constructor | flags | payload length | user_id     | string length + UTF-8

The library set the name presence bit and calculated the body length: 9 bytes. UInt32 specified the integer size; prefix_length=1 specified the width of the string length prefix. In Python, the values remained ordinary int and str objects.

Keep the format, work with models

bytespec suits an existing or custom sequential binary format that you want to describe with typed Python models. You choose integer sizes, prefixes, byte order, and framing; the library advances offsets, reads nested models, and handles optional field flags. A custom codec completely replaces encoding and decoding for an individual field.

For example, remove the name; there is no need to update the mask or length manually:

absent = Packet(user_id=42).encode()
assert Packet.decode(absent).name is None
print(absent.hex(" "))
12 34 00 00 04 00 00 00 2a

The body now contains only user_id. This control over sequential fields is the niche bytespec fills. Arbitrary file seeks and complex bit layouts are outside its built-in model. Detailed settings are covered in Headers and framing, Strings, lengths, and field settings, and Codecs: custom formats and types.

Choosing a tool

Tool

When to use it

bytespec

You need typed Python models over an explicitly defined sequential binary format

msgspec

You need a fast typed serializer for JSON, MessagePack, YAML, or TOML

Construct

You need a flexible binary parser/builder DSL, including support for complex layouts

protobuf

You are choosing a protocol and need a mature schema/compiler ecosystem for communication across languages

struct

Your format consists of a few fixed-size scalar fields

msgspec provides a convenient typed API and high-performance serializers for established formats. It is not designed to describe arbitrary existing binary layouts. Protocol Buffers combines schemas, a compiler, and runtimes for different languages, but uses its own wire format rather than describing the byte layout of another protocol.

Construct is considerably more powerful than bytespec as a parser/builder DSL. It starts with a binary schema and parsing logic; bytespec starts with a typed Python model. A DSL is useful when a format requires conditional layouts, pointers, jumps, bit-level structures, or reads that depend on other parts of a file.

The same format with struct and Construct

Let us read the same wire using other tools. This compares how the task is expressed; no speed or size advantage is claimed here.

Reading manually with struct

import struct

def parse_packet(data):
    constructor, flags, size = struct.unpack_from(">HBH", data)
    offset = 5
    user_id, = struct.unpack_from(">I", data, offset)
    offset += 4
    name = None
    if flags & 1:
        length = data[offset]
        offset += 1
        name = data[offset:offset + length].decode("utf-8")
    return user_id, name

assert parse_packet(wire) == (42, "Anna")
assert parse_packet(absent) == (42, None)

For a few fixed-size numbers, struct is sufficient. Here, the variable-length string requires manual offset tracking, a flag check, and prefix reading. This is an educational decoder for a valid packet: a production parser would need bounds checks, constructor and body length validation, error handling, and separate encoding logic.

Describing the packet with Construct

The following block requires the construct package:

from construct import (
    Byte, Const, If, Int16ub, Int32ub, PascalString,
    Prefixed, Rebuild, Struct, this,
)

PacketSchema = Struct(
    "constructor" / Const(0x1234, Int16ub),
    "flags" / Rebuild(Byte, lambda ctx: int(ctx.body.name is not None)),
    "body" / Prefixed(Int16ub, Struct(
        "user_id" / Int32ub,
        "name" / If(this._.flags & 1, PascalString(Byte, "utf8")),
    )),
)

parsed = PacketSchema.parse(wire)
assert (parsed.body.user_id, parsed.body.name) == (42, "Anna")
assert PacketSchema.build(parsed) == wire
assert PacketSchema.parse(absent).body.name is None
assert PacketSchema.build(PacketSchema.parse(absent)) == absent

Construct describes this packet and calculates its length with Prefixed; If expresses the condition through the context. The difference from bytespec lies in the API and workflow: in our Python model, the logical type and wire settings sit together:

user_id: UInt32
name: str | None = field(flag=0, prefix_length=1)

Nested data can be described by another model, and the codec for an individual field can be replaced entirely: see Lists and nested models and Codecs: custom formats and types.

When you do not need bytespec

  • If you need a typed JSON/MessagePack serializer, start with msgspec.

  • If you control both sides of the protocol and want a mature cross-language schema ecosystem, consider protobuf.

  • If you need a highly flexible binary parser DSL with complex conditions, pointers, and bit fields, Construct is often a better fit.

  • If your format consists of a few fixed-size scalar fields, struct may be simpler.

Try it on one packet

One known binary packet and its expected hex representation are enough for an initial check. Describe the fields and framing, call decode(), then verify that encode() reproduces the original bytes:

known_packet = bytes.fromhex("12 34 01 00 09 00 00 00 2a 04 41 6e 6e 61")
restored = Packet.decode(known_packet)
assert restored.user_id == 42
assert restored.name == "Anna"
assert restored.encode() == known_packet

Your first model helps you declare your first model; Headers and framing explains how to match the framing bytes; Codecs: custom formats and types shows how to replace a field codec entirely when the built-in representation does not fit your format.