02 - Proto3 Syntax

📋 Jump to Takeaways

Proto3 is the current version of the Protocol Buffers language. It's simpler than proto2 — it removed footguns like required fields and custom default values in favor of saner defaults. Every field is optional by default, there's no required keyword, and zero values are not serialized.

Scalar Types

Protobuf has its own type system. Here are the ones you'll use most:

message Example {
  int32 count = 1;        // Go: int32
  int64 id = 2;           // Go: int64
  float score = 3;        // Go: float32
  double price = 4;       // Go: float64
  bool active = 5;        // Go: bool
  string name = 6;        // Go: string
  bytes data = 7;         // Go: []byte
}

The = 1, = 2, etc. are field numbers, not values or defaults. They're unique identifiers used in the binary encoding. The field name is for humans; the number is what actually goes on the wire. Once a field number is in use, never change it — that's how existing clients decode your data. Names can be renamed freely without breaking compatibility.

Use int64 for IDs and timestamps. Use string for text. Use bytes for raw binary data. Avoid int32 for IDs because you'll run out of range faster than you think.

There's also uint32, uint64, sint32, sint64, fixed32, fixed64. The sint types are more efficient for negative numbers. The fixed types are still integers (uint32/uint64 in Go) but use a fixed-width encoding — always 4 or 8 bytes on the wire regardless of value. That's more efficient than varint when values are consistently large (hashes, random IDs).

Enums

Enums let you define a fixed set of values. The first value must be zero and acts as the default.

enum LinkStatus {
  LINK_STATUS_UNSPECIFIED = 0;
  LINK_STATUS_ACTIVE = 1;
  LINK_STATUS_DISABLED = 2;
  LINK_STATUS_EXPIRED = 3;
}

message Link {
  int64 id = 1;
  string url = 2;
  string short_code = 3;
  LinkStatus status = 4;
}

Unlike message field numbers (which are just wire identifiers), enum numbers are the actual integer values stored and compared at runtime.

Always name the zero value _UNSPECIFIED. This is a protobuf convention. If a field isn't set, it defaults to zero, and you want that to mean "not set" rather than a valid business value.

Prefix enum values with the enum name (LINK_STATUS_). Protobuf enums are scoped to the package, not the enum type, so ACTIVE alone could collide with another enum in the same package.

Repeated Fields

repeated is protobuf's way of saying "list" or "array":

message LinkList {
  repeated Link links = 1;
}

message Link {
  int64 id = 1;
  string url = 2;
  repeated string tags = 3;  // ["go", "tutorial", "grpc"]
}

In Go, repeated string tags becomes []string. repeated Link links becomes []*Link. Repeated fields can be empty but never null.

Maps

Maps are key-value pairs. Keys must be scalar types (no floats, no bytes). Values can be any type except another map.

message LinkMetadata {
  map<string, string> headers = 1;
  map<string, int64> click_counts_by_country = 2;
}

In Go, map<string, string> becomes map[string]string. Maps are unordered, just like Go maps.

Nested Messages

You can define messages inside other messages:

message CreateLinkResponse {
  Link link = 1;
  Stats stats = 2;

  message Stats {
    int64 total_links = 1;
    int64 active_links = 2;
  }
}

The nested Stats type is accessed as CreateLinkResponse_Stats in Go. Use nesting when a type only makes sense in the context of its parent. If multiple messages need the same type, define it at the top level instead:

// Top level — reusable by any message
message Stats {
  int64 total_links = 1;
  int64 active_links = 2;
}

message CreateLinkResponse {
  Link link = 1;
  Stats stats = 2;
}

message GetStatsResponse {
  Stats stats = 1; // same type, reused
}

Oneof

oneof means exactly one of the fields can be set. It's like a union type — a single value that could be one of several possible types (similar to union in C, enum in Rust, or A | B | C in TypeScript).

message LinkTarget {
  oneof destination {
    string url = 1;
    string file_path = 2;
    string deep_link = 3;
  }
}

In Go, this generates an interface (isLinkTarget_Destination) and a concrete struct for each option (LinkTarget_Url, LinkTarget_FilePath, LinkTarget_DeepLink). You set the field by assigning one of these structs. Only one can have a value at a time — setting one clears the others.

target := &pb.LinkTarget{
    Destination: &pb.LinkTarget_Url{Url: "https://example.com"},
}

// Check which option is set using a type switch:
switch d := target.Destination.(type) {
case *pb.LinkTarget_Url:
    fmt.Println("URL:", d.Url)
case *pb.LinkTarget_FilePath:
    fmt.Println("File:", d.FilePath)
case *pb.LinkTarget_DeepLink:
    fmt.Println("Deep link:", d.DeepLink)
}

Timestamps and Well-Known Types

Protobuf ships with common types you can import:

import "google/protobuf/timestamp.proto";

message Link {
  int64 id = 1;
  string url = 2;
  google.protobuf.Timestamp created_at = 3;
  google.protobuf.Timestamp expires_at = 4;
}

In Go, google.protobuf.Timestamp maps to *timestamppb.Timestamp. Convert to and from time.Time:

import "google.golang.org/protobuf/types/known/timestamppb"

// time.Time -> protobuf
ts := timestamppb.Now()
ts = timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))

// protobuf -> time.Time
t := ts.AsTime()

Other useful well-known types: Duration, Struct (for arbitrary JSON), Empty (for RPCs with no request/response body).

Default Values

In proto3, every field has a default zero value. Unset fields are not serialized.

  • Numbers: 0
  • Booleans: false
  • Strings: ""
  • Bytes: empty bytes
  • Enums: first value (0)
  • Messages: nil

This means you can't distinguish between "field was set to zero" and "field was not set." If you need that distinction, use wrapper types like google.protobuf.Int64Value or the optional keyword.

Wrapper types like Int64Value are messages containing a single scalar field. Because they're messages, they become pointers in Go and can be nil — giving you three states: not set (nil), set to zero, or set to something else. However, wrappers are the older approach. The optional keyword (reintroduced in proto3) does the same thing more cleanly and is the modern way to handle field presence.

message Link {
  int64 id = 1;
  optional int64 max_clicks = 2;  // can distinguish nil from 0
}

Key Takeaways

  • Proto3 is the current version, simpler than proto2. All fields have default values and are omitted from the wire when unset. Use optional to distinguish "not sent" from "sent with default value"
  • Use int64 for IDs, string for text, bytes for binary data
  • Enum zero value should always be _UNSPECIFIED
  • repeated is a list, map is key-value pairs
  • oneof means exactly one field can be set
  • Import google/protobuf/timestamp.proto for time handling
  • Zero values are not serialized, use optional if you need to distinguish "not set" from "zero"

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy