Your first model

You need Python 3.10+ and bytespec installed:

pip install bytespec

or

uv add bytespec

Run the blocks in order in a single file named user.py.

1. Describe the data

from bytespec import ProtoModel

class User(ProtoModel):
    name: str
    active: bool

The class describes two fields: a string called name and a boolean called active. Inheriting from ProtoModel adds writing and reading bytes. These types need no additional configuration.

2. Create a user

user = User(name="Anna", active=True)
print(user.name, user.active)
Anna True

These are ordinary Python object attributes. Both fields are required; pass their values by name.

3. Write bytes

encoded = user.encode()
print(type(encoded).__name__, len(encoded))
bytes 23

encode() returned 23 bytes. You can save them to a file or send them over a network. Look at every byte in the message:

print(encoded.hex(" "))
00 01 00 00 00 00 00 00 00 00 00 00 00 09 00 00 00 04 41 6e 6e 61 01
How do I read this line?

Each pair, such as 04 or 6e, denotes one byte in hexadecimal: digits 0–9 are followed by letters a–f. 04 is 4, 09 is 9, and 41 is 65. hex(" ") only displays the bytes with spaces; it does not change the data.

4. Read it back

decoded = User.decode(encoded)
print(decoded.name, decoded.active)
print(decoded == user, decoded is user)
Anna True
True False

The same values were restored, but this is a new object. User.decode() knows the field types and order from the User class. Field names are not written to the message: the receiver needs the same class with the same settings.

The basic cycle is complete: class → object → bytes → new object.

5. Connect code and bytes

The fields occupy the last 9 bytes. Before them, bytespec added 14 metadata bytes: the header. The diagram shows its three parts:

Constructor

2 bytes

Message identifier.

Flags

8 bytes

Presence of optional fields.

PayloadLength

4 bytes

Total field size in bytes.

Fields

9 bytes

name, then active.

Header: 2 + 8 + 4 = 14 bytes. Whole message: 14 + 9 = 23 bytes. This example needs no header configuration.

user = User(name="Anna", active=True)
encoded = user.encode()
assert User.decode(encoded) == user

Now we can skip the known 14-byte header:

print(encoded[14:].hex(" "))
00 00 00 04 41 6e 6e 61 01
① Name length · 00 00 00 04

The number 4: the next four bytes belong to the name string. This is a length prefix, a number before some data that gives its size.

② Name · 41 6e 6e 61

The letters A, n, n, a in UTF-8. Each takes one byte. Other letters may take more: for example, the Cyrillic letter “я” takes two.

③ Active · 01

True is written as 01 and False as 00. Exactly one byte is read after the name.

name contains four length bytes and four text bytes. active takes one more: 4 + 4 + 1 = 9. This is how bytespec finds the end of the string and the start of the next field.

What is in the 14-byte header?
  • 00 01 is the default __constructor__ = 1. When reading, bytespec checks it against the chosen class; it does not choose the class itself.

  • The next eight 00 bytes are Flags. There are no optional fields yet, so all bits are zero.

  • 00 00 00 09 is PayloadLength: nine field bytes after the header.

Payload means the content after the header: here, name and active, including the string prefix. The header is excluded from this length. Settings for an existing protocol are covered in Headers and framing.

Why is length 4 written as 00 00 00 04?

A string length takes four bytes by default. The most significant bytes come first; this byte order is called big-endian. With little-endian, the same number would look like 04 00 00 00. You only need to configure this when describing a format with a different byte order.

Terms are collected in Concepts. Next, Fields and default values adds a default value for active.