38.2 Generating Go Code with protoc and protoc-gen-go
Right, let’s get our hands dirty. You’ve written your first .proto file, feeling pretty good about yourself. Now what? You can’t just import that thing into your Go code. The Go compiler would look at your beautifully defined message and have an absolute fit. It doesn’t speak Protobuf natively; it speaks Go. Our job is to translate.
This is where the magic (or, more accurately, the very deliberate and predictable engineering) of the protocol buffer compiler, protoc, comes in. protoc’s job is to take your .proto file and generate code in a target language. But here’s the catch: protoc itself is language-agnostic. Out of the box, it knows how to parse .proto files, but it doesn’t know how to generate Go code. For that, it needs a plugin.
This is the part everyone messes up once. Don’t worry, we all did.
The Installation Tango
You need two things:
protocitself: The main compiler. You can grab the pre-compiled binary for your OS from the Google protobuf GitHub releases. Unzip it and make sure thebin/protocbinary is somewhere in your system PATH. Check it works:protoc --version # Should print something like libprotoc 3.21.12- The Go plugin: This is the piece that teaches
protochow to speak Go. The installation has changed, and if you’re following an old tutorial, you’ll get the wrong thing. Do not usegithub.com/golang/protobuf/protoc-gen-go. That’s the old API. You want the new one. Install it like any other Go tool:This will place thego install google.golang.org/protobuf/cmd/protoc-gen-go@latestprotoc-gen-gobinary in your$GOBIN(which should also be in your PATH).
The naming is a bit clever. When you run protoc, it will look for a binary on your PATH named protoc-gen-<LANGUAGE>. So when you tell it --go_out=..., it literally goes and executes the protoc-gen-go binary as a subprocess, feeds it the parsed Protobuf descriptor, and expects the plugin to write the generated Go code to stdout, which protoc then captures and writes to files.
Your First Real Code Generation
Let’s say you have a project in ~/myproject and your proto files are in a directory within it. A sane structure is crucial. Here’s a typical setup:
~/myproject/
├── gen/ # Generated code goes here. We commit this.
│ └── go/ # For Go-specific generated code
├── proto/ # Our .proto files live here
│ └── example/
│ └── v1/
│ └── user.proto
└── go.mod
Your user.proto might look like this:
syntax = "proto3";
package example.v1;
option go_package = "github.com/yourusername/myproject/gen/go/example/v1";
message User {
string uuid = 1;
string full_name = 2;
int32 birth_year = 3;
string email = 4;
}
The most important line here for Go is option go_package. This isn’t just a good idea; it’s the law. This directive tells the Go plugin exactly what the full import path of the generated Go package should be. This is how it avoids import chaos. Now, from your project root (~/myproject), run the incantation:
protoc \
--proto_path=./proto \ # Where to look for .proto files and imports
--go_out=./gen/go \ # Where to write the generated Go code
--go_opt=paths=source_relative \ # Crucial flag! Makes file structure mirror proto/
./proto/example/v1/user.proto # The specific files to compile
Let’s unpack those flags:
--proto_path(or-I): Tellsprotocwhere to look for your.protofiles and any they might import. You can have multiple of these.--go_out: The output directory for the Go code.--go_opt=paths=source_relative: This is the secret sauce. It tells the plugin to mirror the directory structure relative to the--proto_pathin the output directory. Without this, it would try to create a directory structure based on thego_packageoption (e.g.,github.com/yourusername/...), which is almost never what you want inside your own project.
After running this, you should find a new file: ~/myproject/gen/go/example/v1/user.pb.go. Go ahead, open it. It’s a thing of beauty—a monstrous, generated beauty full of getters, setters, and all the serialization logic you never want to write by hand.
What Actually Got Generated?
The generated user.pb.go file contains a few key things:
- A Go struct
Userthat corresponds to yourmessage User. - Methods on that struct to satisfy various interfaces, the most important being
proto.Message. - Functions to marshal (
proto.Marshal) and unmarshal (proto.Unmarshal) the struct to and from binary Protobuf format. - A whole bunch of other boilerplate you can mostly ignore.
You can now use this in your Go code like any other struct:
package main
import (
"fmt"
"log"
"google.golang.org/protobuf/proto"
userv1 "github.com/yourusername/myproject/gen/go/example/v1" // Import the generated package
)
func main() {
// Create a new user from the generated struct.
u := &userv1.User{
Uuid: "1234-abcd",
FullName: "Jane Doe",
BirthYear: 1985,
Email: "jane@example.com",
}
// Serialize it to binary protobuf format.
data, err := proto.Marshal(u)
if err != nil {
log.Fatal("marshaling error: ", err)
}
// Deserialize it back.
newUser := &userv1.User{}
err = proto.Unmarshal(data, newUser)
if err != nil {
log.Fatal("unmarshaling error: ", err)
}
fmt.Printf("Original: %s\n", u.GetFullName()) // Note: you can use u.FullName or u.GetFullName()
fmt.Printf("New: %s\n", newUser.GetFullName())
}
Common Pitfalls and How to Avoid Them
- Wrong Plugin Version: The
google.golang.org/protobuf(aka APIv2) andgithub.com/golang/protobuf(APIv1) packages are not compatible. Yourprotoc-gen-gomust match the library you import in your Go code. Always usegoogle.golang.org/protobuf. The error you get if you mix them is wonderfully cryptic. - Missing
go_packageOption:protocwill fail. This is mandatory. Be specific and use the full import path. - Ignoring
paths=source_relative: This causes your generated files to end up in a bizarregithub.com/...directory structure inside your--go_outpath. Usingpaths=source_relativeis almost always the correct choice for a monorepo-style project. - Not Committing Generated Code: This is a religious debate, but I’m firmly on the side of “yes, commit your generated code.” It ensures everyone on the team is using the same version, simplifies builds (no need to install
protoceverywhere, like in CI), and makes your codebase self-contained. Just re-generate it when you change the.protofiles.