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:
2 bytes
Message identifier.
8 bytes
Presence of optional fields.
4 bytes
Total field size in bytes.
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 01is the default__constructor__ = 1. When reading, bytespec checks it against the chosen class; it does not choose the class itself.The next eight
00bytes areFlags. There are no optional fields yet, so all bits are zero.00 00 00 09isPayloadLength: 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.