Lupyd.
Lupyd Systems & Architecture

Protobuf Has No UUID Type: bytes vs string vs Two fixed64s

Protobuf has no UUID type and probably never will. We measured the options with protoc: bytes and two fixed64 fields take 18 bytes, a string takes 38. Here is how byte order, JSON mapping, map keys, validation and language bindings decide which one to use.

Published: Nov 4, 2026
•
11 min read
Protobuf Has No UUID Type: bytes vs string vs Two fixed64s

Protobuf has no UUID type, and the maintainers have said one is unlikely. You have three practical choices. A bytes field costs 18 bytes on the wire, two fixed64 fields also cost 18, and a hyphenated string costs 38. Use bytes for binary APIs, string when the ID shows up in JSON, URLs or map keys, and two fixed64 fields only if you have a reason to avoid a byte buffer and you write the byte order down.

The numbers in this post were measured with protoc --encode (libprotoc 36.1), not estimated. The JSON, map key and validation details come from the protobuf docs and the protovalidate source. At the end we show what Lupyd's own schemas use, which is not quite any of the textbook answers.

Why Protobuf Has No UUID Type

The well-known types cover timestamps, durations, wrappers, Any, Struct and a few others. There is no google.protobuf.Uuid. People have asked for one since at least 2016: GitHub issue #2224, "Provide support for UUID type (a.k.a. GUID)", was opened in October 2016. A maintainer replied in 2018 that it was "not on our roadmap".

In December 2025 the issue was closed. The maintainer explained that a new UUID type was "extremely unlikely" even on multi-year timelines. Even adding a new well-known type is hard, because many protobuf implementations are not written by Google and many projects run old versions. A real 128-bit integer would need a change to the binary wire format, which has never happened. The same comment said bytes map keys were "more realistic" and might be considered for 2026. In January 2026 the request was folded into a broader tracking issue, #25159, "New field types in IDL", which is still open. In that thread a maintainer suggested message UUID with two fixed64 fields as a reasonable representation, and noted that a future Edition could add a uuid type that reuses the bytes wire type. No such Edition exists today.

Google's own APIs went the other way. AIP-148 defines a string uid field for system-assigned identifiers, and AIP-202 adds a UUID4 format annotation that "must only be used on a field of type string". Back in 2017, a protobuf maintainer on the mailing list put it simply: string or bytes, and definitely bytes if you use the binary form, because string must be valid UTF-8.

Four Ways to Encode a UUID

A UUID is 16 bytes. RFC 9562 Section 4 says each field is encoded most significant byte first ("network byte order"), and that UUIDs may be represented as binary data, integers or the hex-and-dash string. Those three representations map onto protobuf like this:

The candidates
syntax = "proto3";

// 1. Text: the familiar 8-4-4-4-12 form
message AsString { string id = 1; }

// 2. The 16 raw bytes, in RFC 9562 (big-endian) order
message AsBytes { bytes id = 1; }

// 3. Two 64-bit halves, inlined in the parent message
message AsFixed { fixed64 hi = 1; fixed64 lo = 2; }

// 4. The same halves wrapped in a reusable message
message Uuid { fixed64 high64 = 1; fixed64 low64 = 2; }
message AsWrapper { Uuid id = 1; }

Wire Size, Measured

The encoding spec gives the arithmetic. Every field starts with a varint tag, (field_number << 3) | wire_type, which fits in one byte for field numbers 1 to 15. string, bytes and embedded messages use the LEN wire type: tag, a varint length, then the payload. fixed64 uses the I64 wire type: tag, then exactly 8 bytes. Here is one UUIDv7 encoded each way:

protoc --encode output
# UUIDv7 01a1a5c9-5e80-7d41-9a6e-3f0b5c7d2e18, encoded with protoc 36.1 --encode

AsString   0a 24 30 31 61 31 61 35 63 39 2d 35 65 38 30 2d   38 bytes
           37 64 34 31 2d 39 61 36 65 2d 33 66 30 62 35 63
           37 64 32 65 31 38

AsBytes    0a 10 01 a1 a5 c9 5e 80 7d 41 9a 6e 3f 0b 5c 7d   18 bytes
           2e 18

AsFixed    09 41 7d 80 5e c9 a5 a1 01                        18 bytes
           11 18 2e 7d 5c 0b 3f 6e 9a

AsWrapper  0a 12 09 41 7d 80 5e c9 a5 a1 01                  20 bytes
           11 18 2e 7d 5c 0b 3f 6e 9a
Bytes on the wire for one UUID in field 1
Encoding Bytes Breakdown
string, hyphenated (36 chars) 38 1 tag + 1 length + 36 ASCII bytes
string, no hyphens (32 hex) 34 1 tag + 1 length + 32 ASCII bytes
bytes 18 1 tag + 1 length + 16 bytes
Two fixed64 fields (hi, lo) 18 2 x (1 tag + 8 bytes)
Wrapper message with two fixed64 20 1 tag + 1 length + 18-byte submessage
bytes at field number 16 or higher 19 2-byte tag + 1 length + 16 bytes

Source: measured with protoc 36.1 --encode; arithmetic from the protobuf encoding guide.

For a single ID the difference between bytes and two fixed64 fields is zero. Lists are where the options separate. We encoded 1,000 UUIDs in one repeated field:

1,000 UUIDs in one repeated field
Encoding Total bytes Per ID
repeated string 38,000 38
repeated Uuid (wrapper) 20,000 20
repeated bytes 18,000 18
packed repeated fixed64 (hi, lo, hi, lo...) 16,003 about 16

Source: measured with protoc 36.1 --encode.

Packed fixed64 is the smallest because the whole list shares one tag and one length (3 bytes of overhead for 16,000 bytes of payload). The cost is that the schema no longer says which values pair up, so every reader has to know the convention. A wrapper message pays two extra bytes per ID for its own tag and length. Compression narrows the gap for string, but 36 hex characters still carry only 16 bytes of information.

Byte Order and UUIDv7 Sorting

UUIDv7 puts a 48-bit big-endian Unix timestamp in milliseconds in the first six bytes, then the version, random bits, the variant and more random bits. Section 6.11 explains the point: v6 and v7 are designed so that database indexes can sort them "as opaque raw bytes", and time-ordered IDs land next to each other in an index, which the RFC says can make a difference of "one order of magnitude or more" compared with random inserts. Section 6.13 says that where feasible, UUIDs SHOULD be stored in databases as the 128-bit binary value.

That only works if every hop keeps the bytes in RFC order:

  • bytes carries the 16 bytes exactly as you put them in. If you copy them from a UUID library's as_bytes() or [16]byte, they are already in RFC order and drop straight into a Postgres uuid or BYTEA column.
  • Two fixed64 fields are integers, and the wire stores them little-endian. Look at the hex dump above: the high half 01 a1 a5 c9 5e 80 7d 41 goes out as 41 7d 80 5e c9 a5 a1 01. That is fine as long as both sides build the integer the same way: hi from bytes 0 to 7 read big-endian, lo from bytes 8 to 15. Comparing (hi, lo) as unsigned integers then gives the same order as comparing the bytes. Build hi with a little-endian read on one platform and you get a valid-looking but different UUID, with no error. Issue #2224 includes exactly this complaint about endianness when a UUID is split into two integers.
  • string sorts correctly as text, which RFC 9562 also intends, but only if every producer uses the same case. The RFC allows upper, lower or mixed case hex, so normalise to lowercase.

One more trap with integers: if you store the halves in two signed BIGINT columns, values with the top bit set sort as negative. For a random v4 UUID that is half of all IDs. For UUIDv7 the top bit of hi is the top bit of the timestamp, so it stays zero for a very long time, but the low half is random.

What Happens in Proto3 JSON

If any client talks to you in JSON, through grpc-gateway, Connect, or plain toJson for logging, the proto3 JSON mapping decides what the ID looks like. bytes become standard base64 with padding (parsers accept URL-safe base64 too). fixed64, int64 and uint64 become decimal strings. The same UUID three ways:

Proto3 JSON output
// The same UUID in proto3 JSON (canonical mapping)

// string
{ "id": "01a1a5c9-5e80-7d41-9a6e-3f0b5c7d2e18" }

// bytes: standard base64 with padding
{ "id": "AaGlyV6AfUGabj8LXH0uGA==" }

// two fixed64 halves: 64-bit integers become decimal strings
{ "id": { "high64": "117557349580832065", "low64": "11127901047307972120" } }

None of the binary forms produce something a person can paste into a database query or match against a log line. That is the real reason to choose string, and it is the reason the maintainer gave in issue #25159: using string mainly makes "the JSON representation match how people propagate uuids". If your API is binary-only, as ours is (see Protobuf Everywhere), this matters much less.

Map Keys and Field Presence

The proto3 language guide says a map's key_type "can be any integral or string type (so, any scalar type except for floating point types and bytes)", and that enums and messages are not valid keys either. So map<bytes, Order> and a map keyed by a Uuid message do not compile. map<string, Order> does. With binary IDs, the workaround is a repeated entry message that you turn into a map in application code.

Presence behaves differently too, because proto3 fields without optional are not written when they hold the default value:

  • The nil UUID (all zeros) as two fixed64 fields encodes to 0 bytes. A reader cannot tell "nil UUID" from "not set".
  • As bytes, the nil UUID is 16 zero bytes, which is not empty, so it is written (18 bytes). Only an empty buffer means "not set", and an empty buffer is not a valid UUID, so the distinction is clean.
  • A Uuid wrapper message has explicit presence. An unset field is absent; a nil UUID is a present but empty submessage (2 bytes).

Rust, Go and TypeScript

The generated types decide how much conversion code you write. prost generates Vec<u8> for bytes by default, or bytes::Bytes if you configure it, and u64 for fixed64. The uuid crate converts both ways:

// prost: bytes -> Vec<u8> (or bytes::Bytes), fixed64 -> u64, string -> String
// uuid crate with the "v7" feature
use uuid::Uuid;

let id = Uuid::now_v7();

// bytes field
let wire: Vec<u8> = id.as_bytes().to_vec();
let back = Uuid::from_slice(&wire)?;        // errors unless exactly 16 bytes

// two fixed64 fields
let (hi, lo) = id.as_u64_pair();            // most significant half first
let back = Uuid::from_u64_pair(hi, lo);

// string field
let text = id.to_string();                  // lowercase, hyphenated
let back = Uuid::parse_str(&text)?;

In Go, bytes is []byte, fixed64 is uint64 and string is string. github.com/google/uuid defines UUID as a 16-byte array, so slicing it gives you the field value directly:

// protoc-gen-go: bytes -> []byte, fixed64 -> uint64, string -> string
import (
	"encoding/binary"
	"github.com/google/uuid"
)

u, err := uuid.NewV7()
msg := &pb.AsBytes{Id: u[:]}                 // uuid.UUID is a [16]byte
back, err := uuid.FromBytes(msg.GetId())    // errors unless 16 bytes

hi := binary.BigEndian.Uint64(u[:8])        // keep RFC 9562 order
lo := binary.BigEndian.Uint64(u[8:])

TypeScript is where fixed64 hurts. protobuf-es maps fixed64 to bigint and bytes to Uint8Array. ts-proto defaults to forceLong=number and throws a runtime error when a 64-bit value does not fit in a JavaScript number. The high half of a current UUIDv7 is around 1.2 x 1017, far above 253, so with that default every UUID split into halves throws. Set forceLong=bigint (or jstype = JS_STRING with protobuf-es if you want strings). For bytes you need a small hex helper:

// protobuf-es: bytes -> Uint8Array, fixed64 -> bigint
function uuidToBytes(s: string): Uint8Array {
  const hex = s.replace(/-/g, "");
  return Uint8Array.from(hex.match(/../g)!, (h) => parseInt(h, 16));
}

function bytesToUuid(b: Uint8Array): string {
  const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
  return [h.slice(0, 8), h.slice(8, 12), h.slice(12, 16),
          h.slice(16, 20), h.slice(20)].join("-");
}

// Two halves: DataView reads big-endian unless told otherwise
const b = uuidToBytes("01a1a5c9-5e80-7d41-9a6e-3f0b5c7d2e18");
const view = new DataView(b.buffer, b.byteOffset, 16);
const hi = view.getBigUint64(0);   // 117557349580832065n
const lo = view.getBigUint64(8);

In a browser, every one of these conversions runs on every message. Our Rust and WebAssembly web client avoids most of this: Firefly's messages are decoded by the Rust engine (generated with pb-rs for quick-protobuf), not by a TypeScript protobuf library.

Validating UUID Fields with protovalidate

None of these field types guarantees a valid UUID. A string can hold anything, a bytes field can be 15 bytes long. protovalidate (the buf.validate rules) has three relevant rules:

buf.validate UUID rules
import "buf/validate/validate.proto";

message CreateOrder {
  // 8-4-4-4-12 hex; version and variant bits are not checked
  string order_id = 1 [(buf.validate.field).string.uuid = true];

  // 32 hex digits, no hyphens
  string trace_id = 2 [(buf.validate.field).string.tuuid = true];

  // exactly 16 bytes (protovalidate v1.1.0 and later)
  bytes customer_id = 3 [(buf.validate.field).bytes.uuid = true];
}

string.uuid checks the 8-4-4-4-12 hex shape and accepts either case. string.tuuid checks 32 hex digits with no hyphens. bytes.uuid checks that the value is exactly 16 bytes; it first appears in the v1.1.0 release. All three check shape or length only. The source says the version and variant bits are deliberately not checked, so future UUID versions are not rejected, and each rule has a companion check that rejects an empty value. If you need "must be v7", check the version nibble in your own code. There is also a string.ulid rule if you use ULIDs.

What Lupyd Actually Uses

We have three different answers in our own code, for different reasons.

Firefly: no UUIDs at all

Firefly, our messaging engine, identifies messages, groups and meeting sessions with 64-bit integers. Message IDs are fixed64, generated by Postgres as a microsecond timestamp:

Firefly message schema
// firefly: crates/protos/protobufs/message.proto (trimmed)
message UserMessage {
  fixed64 id = 1;
  uint64 toId = 2;
  uint64 fromId = 3;
  bytes text = 4;
  // ...
}

message GroupMeetingSession {
  fixed64 session_id = 1;           // UUIDv7 timestamp-based ID
  uint64 group_id = 2;
  // ...
  fixed64 created_at = 6;           // Microsecond timestamp
}
Where the IDs come from
-- firefly-mls: initdb.sql (trimmed)
CREATE OR REPLACE FUNCTION now_us() RETURNS bigint AS $$
  SELECT (EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint;
$$ LANGUAGE sql STABLE;

CREATE TABLE IF NOT EXISTS group_messages (
    id BIGINT NOT NULL DEFAULT now_us(),
    group_id BIGINT NOT NULL REFERENCES groups (id) ON DELETE CASCADE,
    -- ...
    PRIMARY KEY (group_id, id)
);

CREATE TABLE IF NOT EXISTS group_meeting_sessions (
    session_id BIGINT NOT NULL DEFAULT now_us() PRIMARY KEY,
    -- ...

The comment on session_id says "UUIDv7", but the column default is now_us(): a plain microsecond timestamp, 8 bytes, time-ordered like a UUIDv7 but not one. Group messages are unique per (group_id, id) because of the primary key, not because of randomness. This is half the size of any UUID encoding, it sorts by time as a plain integer, and it costs the same 8 bytes as a uint64 varint would for a value this size (a current microsecond timestamp needs 51 bits, so 8 varint bytes) while skipping the varint decode. The trade-off is that IDs are assigned by the server, so a client cannot mint one offline. The database design behind this is covered in Postgres with QUIC.

lupyd-protos: bytes on the wire, a wrapper on the shelf

Our older lupyd-protos schemas, used for posts, define both styles:

lupyd-protos
// lupyd-protos: uuid.proto
message Uuid {
  fixed64 high64 = 1;
  fixed64 low64 = 2;
}

// lupyd-protos: post.proto (trimmed)
message FullPost {
  bytes id = 1; // required
  // ...
  bytes replying_to = 6;
}

message PostIds { repeated bytes ids = 1; }

The Uuid message with two fixed64 fields exists, but no other message in the repository imports it. Post IDs actually travel as bytes. The server generates them as ULIDs (48-bit millisecond timestamp first, so they sort by time like UUIDv7), stores the 16 bytes in a Postgres uuid column, and reads them back with Uuid::from_slice, which rejects anything that is not 16 bytes. Where an ID has to be text, such as the data payload of a push notification, the server formats it as a 26-character ULID string, and the notification schema's LikeType and CommentType declare postId as string. The TypeScript bindings are built with ts-proto and forceLong=bigint, so the Uuid halves come out as bigint and do not hit the 253 problem.

In hindsight, the unused Uuid message is the kind of thing a schema accumulates when nobody picks a convention early. Pick one, write it in a comment next to the field, and enforce it with a validation rule.

Which Type to Use

UUID field type by use case
Use case Recommended type Why
Public HTTP/JSON APIs, IDs in URLs, logs and support tickets string + buf.validate string.uuid JSON shows the familiar form, it works as a map key, and Google's AIP-148 standardises on it. You pay 20 extra bytes per ID.
Internal binary RPC and protobuf over plain HTTP bytes + buf.validate bytes.uuid 18 bytes, same layout as RFC 9562 and as Postgres uuid or BYTEA(16), and byte order is never ambiguous.
Large lists of IDs (sync batches, bulk fetch) repeated bytes, or packed repeated fixed64 Packed fixed64 drops to about 16 bytes per ID, but you must pair halves yourself. repeated bytes is simpler at 18.
IDs used as map keys string bytes and message types cannot be map keys. The alternative is a repeated entry message you index yourself.
Hot paths where you never want an allocation Two fixed64 (or a Uuid wrapper) Two plain integers, no heap buffer. Document the byte order and remember that 64-bit integers become strings in JSON.
You do not actually need 128 bits fixed64 timestamp or uint64 Half the size of any UUID encoding. Uniqueness has to come from your database or a sequence, not from randomness.

Whatever you pick, keep field numbers for IDs at 15 or below so the tag stays one byte, keep RFC 9562 byte order everywhere, and do not mix representations for the same ID across services. Changing a field from string to bytes later is a breaking change in practice: both use the LEN wire type, so a reader on the old schema either rejects the raw bytes as invalid UTF-8 or reads 16 binary bytes where it expected 36 characters.

Frequently Asked Questions

Does Protobuf have a UUID type?

No. There is no UUID scalar and no UUID well-known type. The request was open on GitHub from 2016 until December 2025, when a maintainer closed it as extremely unlikely to happen. The common choices are string, bytes, or two fixed64 fields.

Should I store a UUID as bytes or string in Protobuf?

Use bytes for binary APIs: it takes 18 bytes on the wire against 38 for a hyphenated string. Use string if the ID appears in JSON, URLs or logs, or if you need it as a map key, because bytes become base64 in JSON and cannot be map keys.

Is two fixed64 fields smaller than bytes?

Not for a single UUID: both are 18 bytes. A wrapper message around the two fields is 20. Only a packed repeated fixed64 list is smaller, at about 16 bytes per UUID, and then every reader has to know how the halves pair up and which byte order they use.

Can a bytes field be a Protobuf map key?

No. Map keys can be integral or string types only; floating point types, bytes, enums and messages are not allowed. Use a string key, or a repeated entry message that you index in application code.

How do I validate a UUID field in Protobuf?

With protovalidate: string.uuid checks the hyphenated hex form, string.tuuid checks 32 hex digits without hyphens, and bytes.uuid (v1.1.0 and later) checks for exactly 16 bytes. None of them check the version or variant bits.

Final Takeaway

Protobuf will not give you a UUID type any time soon, so the choice is yours to make and document. bytes is the smallest single-field encoding that keeps RFC 9562 byte order and sorts correctly for UUIDv7. string costs 20 more bytes and buys readable JSON and map keys. Two fixed64 fields are the same size as bytes but add a byte-order convention and a bigint problem in JavaScript. And if you control ID assignment, ask whether you need 128 bits at all: Firefly's messages get by with a 64-bit timestamp.

Firefly is open source at github.com/lupyd/firefly. To see the protocol in use, Download Lupyd.

Last updated: 4 November 2026.

Key Takeaways
  • ✓ Protobuf has no UUID scalar or well-known type; the GitHub request open since 2016 was closed in December 2025 as extremely unlikely, and Google's AIP-148 uses a string field instead.
  • ✓ Measured with protoc, one UUID costs 18 bytes as bytes, 18 as two fixed64 fields, 20 as a wrapper message and 38 as a hyphenated string.
  • ✓ Keep RFC 9562 big-endian byte order everywhere so UUIDv7 sorts by time; fixed64 halves are little-endian on the wire, so both sides must build them the same way.
  • ✓ bytes become base64 and fixed64 becomes a decimal string in proto3 JSON, and bytes and messages cannot be map keys, which is the main reason to choose string.
  • ✓ protovalidate has string.uuid, string.tuuid and bytes.uuid rules; Lupyd's Firefly avoids UUIDs entirely and uses 64-bit microsecond timestamps as message IDs.
Try Lupyd

The workspace where nothing leaks

End-to-end encrypted channels and chat for your team, built on the Signal Protocol and MLS. Available on macOS, Windows, Linux and Android.

Open Source

Explore Lupyd on GitHub

Inspect our open-source repositories, cryptography libraries, and client tools on GitHub.

Explore Lupyd on GitHub