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:
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:
# 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 | 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:
| 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:
bytescarries the 16 bytes exactly as you put them in. If you copy them from a UUID library'sas_bytes()or[16]byte, they are already in RFC order and drop straight into a PostgresuuidorBYTEAcolumn.- Two
fixed64fields are integers, and the wire stores them little-endian. Look at the hex dump above: the high half01 a1 a5 c9 5e 80 7d 41goes out as41 7d 80 5e c9 a5 a1 01. That is fine as long as both sides build the integer the same way:hifrom bytes 0 to 7 read big-endian,lofrom bytes 8 to 15. Comparing(hi, lo)as unsigned integers then gives the same order as comparing the bytes. Buildhiwith 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. stringsorts 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:
// 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
fixed64fields 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
Uuidwrapper 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:
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: 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
} -- 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: 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
| 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.
Sources & References:
- RFC 9562: Universally Unique IDentifiers (UUIDs), Sections 4, 5.7, 6.11 and 6.13
- Protocol Buffers: Encoding
- Protocol Buffers: Language Guide (proto3), scalar types and maps
- Protocol Buffers: ProtoJSON format
- Protocol Buffers: Well-Known Types
- protocolbuffers/protobuf issue #2224: Provide support for UUID type (a.k.a. GUID)
- protocolbuffers/protobuf issue #25159: New field types in IDL
- Protocol Buffers mailing list: Guid conventions (2017)
- AIP-148: Standard fields
- AIP-202: Fields (format annotations)
- protovalidate: buf/validate/validate.proto
- prost-build Config documentation
- uuid crate: Uuid
- github.com/google/uuid
- protobuf-es and ts-proto
- Firefly source code (GitHub)
Last updated: 4 November 2026.
- ✓ 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.
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.