Numbers and other types

Our user already has a name. Let us add an identifier:

from bytespec import ProtoModel, field
from bytespec.types import UInt32

class User(ProtoModel):
    id: UInt32
    name: str
    active: bool = field(default=True)

user = User(id=42, name="Anna")
decoded = User.decode(user.encode())
print(decoded.id)  # 42

UInt32 means an unsigned 32-bit integer: 4 bytes. The value 42 remains an ordinary Python int; the annotation selects its representation. A plain int uses signed VarInt (ZigZag), while a fixed size must be chosen explicitly. For float, you must specify Float32 or Float64.

Choosing an integer type

Annotation

Size

Values

UInt8, UInt16, UInt32, UInt64

1, 2, 4, or 8 bytes

From 0 to 2**N - 1, where N is the number of bits

Int8, Int16, Int32, Int64

1, 2, 4, or 8 bytes

From -2**(N-1) to 2**(N-1) - 1

VarUInt

1–10 bytes

The UInt64 range; small values take less space

VarInt

1–10 bytes

The Int64 range; supports negative values

For example, UInt8 accepts numbers from 0 to 255. An out-of-range value is rejected by encode(), not when the instance is created. All these annotations are imported from bytespec.types.

Small variable-length integers

int and VarInt use the same format; VarUInt is suitable for nonnegative values. Let us inspect the field bytes:

from bytespec.types import VarUInt

class Counters(ProtoModel):
    delta: int
    total: VarUInt

counters = Counters(delta=-2, total=300)
assert Counters.decode(counters.encode()) == counters
print(counters.encode()[14:].hex(" "))
03 ac 02

03 is the ZigZag representation of -2, and ac 02 is the unsigned varint representation of 300. The [14:] slice skips the standard header, as in the first model. All varints are limited to 64-bit ranges, even for a plain Python int.

Floating-point numbers

from bytespec.types import Float32

class Point(ProtoModel):
    x: Float32
    y: Float32

point = Point.decode(Point(x=1.25, y=3.5).encode())
print(point.x, point.y)  # 1.25 3.5

Float32 takes 4 bytes, and Float64 takes 8. Encoding a Python float as Float32 rounds it to a 32-bit representation, so use a tolerance when comparing arbitrary floating-point values.

Strings and bytes

We have already used str and bool in the first model. For binary data, use bytes:

class Packet(ProtoModel):
    data: bytes

packet = Packet.decode(Packet(data=b"ABC").encode())
print(packet.data)  # b'ABC'

Strings use UTF-8 by default. String and bytes lengths are written automatically; ordinary usage needs no configuration. We will change these settings later in Strings, lengths, and field settings.

UUID and datetime

Standard library types can be used directly in annotations:

from datetime import datetime, timezone
from uuid import UUID

class Message(ProtoModel):
    id: UUID
    created_at: datetime

message = Message(
    id=UUID("12345678-1234-5678-1234-567812345678"),
    created_at=datetime(2026, 9, 7, 12, 0, tzinfo=timezone.utc),
)
decoded = Message.decode(message.encode())
print(decoded.id)                      # 12345678-1234-5678-1234-567812345678
print(decoded.created_at.isoformat())  # 2026-09-07T12:00:00+00:00

A UUID takes exactly 16 bytes. A datetime is written as an ISO string, preserving its UTC offset. The time zone name is not transmitted; a datetime without tzinfo remains without it after decoding. Separate datetime.date and datetime.time values are not supported automatically: use a custom codec if your format requires them. DatetimeCodec restores a datetime, not those types.

Named values

A string enum is a good fit for a message status:

from enum import Enum

class Status(str, Enum):
    READY = "ready"
    BUSY = "busy"

class Message(ProtoModel):
    status: Status

message = Message(status=Status.READY)
decoded = Message.decode(message.encode())
print(decoded.status.value)  # ready

The string value "ready" is written, and decoding restores Status.READY. An unknown value raises DecodeError. Numeric IntEnum types are also supported automatically:

from enum import IntEnum

class Command(IntEnum):
    READ = 1
    WRITE = 2

class Request(ProtoModel):
    command: Command

request = Request(command=Command.WRITE)
restored = Request.decode(request.encode())
assert restored.command is Command.WRITE
print(request.encode()[14:].hex(" "))
04

By default, the numeric .value is encoded with VarInt, so the value 2 takes the byte 04, not 02. Decoding restores the enum member. For a fixed unsigned byte, use EnumCodec(Command, UInt8Codec()); see Compose a codec from existing parts. A plain Enum that does not inherit from str or int requires an explicit codec.

We have covered individual values. Next: Optional fields, which explains how to represent a value that may be absent. The full table of supported annotations is available in Supported annotations.