buf: Protobuf Schema Management, Linting, and Breaking Change Detection
Managing Protobuf schemas across multiple services is painful without tooling. Proto files drift from naming conventions, breaking changes slip in without warning, and protoc invocations become unmaintainable shell scripts. buf is the modern solution: a CLI that handles linting, breaking change detection, and code generation with a single buf.yaml.
This guide covers the complete buf workflow: setting up buf.yaml, integrating linting and breaking change detection into CI, and using the Buf Schema Registry for dependency management.
What buf Replaces
Without buf:
# The typical protoc mess
protoc \
--proto_path=. \
--proto_path=vendor/google/api \
--go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
proto/user/v1/user.proto \
proto/order/v1/order.protoWith buf:
buf generate # That's itInstallation
# macOS
brew install bufbuild/buf/buf
# Linux
curl -sSL https://github.com/bufbuild/buf/releases/download/v1.29.0/buf-Linux-x86_64 -o buf
chmod +x buf && sudo mv buf /usr/local/bin/
# Verify
buf --versionSetting Up buf.yaml
buf.yaml is the root configuration file:
# buf.yaml
version: v2
modules:
- path: proto
lint:
use:
- STANDARD # Enables all standard lint rules
except:
- PACKAGE_VERSION_SUFFIX # Allow packages without version suffix
breaking:
use:
- FILE # Check for breaking changes at file levelPlace it at the root of your repo, with proto files in proto/:
├── buf.yaml
├── buf.gen.yaml
└── proto/
├── user/
│ └── v1/
│ └── user.proto
└── order/
└── v1/
└── order.protoLinting Protobuf Schemas
Run the linter:
buf lintExample violations:
proto/user/v1/user.proto:5:1:Package name "user" should be suffixed with a correctly formed version, such as "user.v1" (buf:lint:ID:PACKAGE_VERSION_SUFFIX)
proto/user/v1/user.proto:12:3:Field name "UserName" should be lower_snake_case, such as "user_name" (buf:lint:ID:FIELD_LOWER_SNAKE_CASE)Fix them:
// proto/user/v1/user.proto
syntax = "proto3";
package user.v1;
option go_package = "github.com/myorg/myrepo/gen/user/v1;userv1";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}
message GetUserRequest {
string user_id = 1; // snake_case, not userId or UserID
}
message GetUserResponse {
User user = 1;
}
message User {
string id = 1;
string email = 2;
string display_name = 3;
int64 created_at = 4;
}Configuring Lint Rules
buf has three built-in rule sets:
lint:
use:
- STANDARD # All standard rules (recommended)
# - BASIC # Fewer rules, more permissive
# - MINIMAL # Only critical rules
except:
- PACKAGE_DIRECTORY_MATCH # If you have non-standard directory structure
ignore:
- proto/legacy/ # Ignore legacy protos you can't touch
ignore_only:
FIELD_LOWER_SNAKE_CASE:
- proto/third_party/ # Ignore specific rule for third-party protosBreaking Change Detection
This is buf's killer feature: detecting proto changes that would break existing clients.
What Counts as Breaking
| Change | Breaking? |
|---|---|
| Remove a field | ✅ Yes |
| Change a field type | ✅ Yes |
| Rename a field | ✅ Yes (field names used in JSON encoding) |
| Remove a message | ✅ Yes |
| Remove a service/method | ✅ Yes |
| Add a new field | ❌ No |
| Add a new enum value | ❌ No |
| Add a new service method | ❌ No |
| Change field number | ✅ Yes |
Running Breaking Change Detection
Compare against the previous git commit:
buf breaking --against '.git#branch=main'Compare against a specific tag:
buf breaking --against '.git#tag=v1.2.0'Compare against a remote branch:
buf breaking --against 'https://github.com/myorg/myrepo.git#branch=main'Example Output
proto/user/v1/user.proto:15:3:Field "1" with name "id" on message "User" changed option "json_name" from "id" to "userId".
proto/user/v1/user.proto:5:1:Service "UserService" had method "GetUser" deleted.buf.gen.yaml: Code Generation
Replace your protoc scripts with buf.gen.yaml:
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt:
- paths=source_relative
- remote: buf.build/grpc/go
out: gen
opt:
- paths=source_relative
- require_unimplemented_servers=false
# Add more: TypeScript, Python, Java, etc.
- remote: buf.build/community/nicholasgasior-godo
out: gen/tsGenerate:
buf generateGenerated files appear in gen/:
gen/
user/
v1/
user.pb.go
user_grpc.pb.goManaging Dependencies with the Buf Schema Registry
Instead of vendoring proto files, use the Buf Schema Registry (BSR):
# buf.yaml
version: v2
deps:
- buf.build/googleapis/googleapis # Google APIs (google.api.http, etc.)
- buf.build/grpc-ecosystem/grpc-gateway # gRPC-gateway annotationsUpdate dependencies:
buf dep updateThis downloads proto files and pins them in buf.lock — similar to go.sum or package-lock.json.
CI Integration
GitHub Actions Workflow
# .github/workflows/proto-checks.yml
name: Protobuf Schema Checks
on: [push, pull_request]
jobs:
lint:
name: Proto Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1
with:
version: "1.29.0"
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Lint proto files
run: buf lint
- name: Check for breaking changes
run: buf breaking --against '.git#branch=main'
if: github.event_name == 'pull_request'
generate:
name: Proto Generate Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1
with: { version: "1.29.0" }
- uses: actions/setup-go@v5
with: { go-version: "1.21" }
- name: Generate code
run: buf generate
- name: Check generated code is up to date
run: |
if [ -n "$(git status --porcelain gen/)" ]; then
echo "Generated code is out of date. Run 'buf generate' and commit."
git diff gen/
exit 1
fiBlocking PRs That Introduce Breaking Changes
The buf breaking step fails with a non-zero exit code when breaking changes are detected. This blocks the PR merge.
For intentional breaking changes (major version bumps), you have two options:
- Use a new package version:
user.v2instead ofuser.v1(both can coexist) - Override with a comment:
buf:breaking:ignore_unstable_packageson the specific file
# Allow breaking changes in alpha/beta packages
breaking:
use:
- FILE
ignore_unstable_packages: true # Won't break on user.v1alpha1 changesWriting Proto Schema Tests
For testing proto message construction and serialization in your application code:
// user_test.go
package user_test
import (
"testing"
"google.golang.org/protobuf/proto"
pb "myrepo/gen/user/v1"
)
func TestUserProtoRoundTrip(t *testing.T) {
original := &pb.User{
Id: "user-123",
Email: "alice@example.com",
DisplayName: "Alice",
CreatedAt: 1704067200,
}
// Serialize
bytes, err := proto.Marshal(original)
if err != nil {
t.Fatal(err)
}
// Deserialize
decoded := &pb.User{}
if err := proto.Unmarshal(bytes, decoded); err != nil {
t.Fatal(err)
}
// Verify round-trip
if !proto.Equal(original, decoded) {
t.Errorf("round-trip failed:\noriginal: %v\ndecoded: %v", original, decoded)
}
}
func TestUserProtoJSONSerialization(t *testing.T) {
user := &pb.User{Id: "user-123", Email: "alice@example.com"}
// JSON serialization (used in gRPC-gateway, REST APIs)
jsonBytes, err := protojson.Marshal(user)
if err != nil {
t.Fatal(err)
}
jsonStr := string(jsonBytes)
assert.Contains(t, jsonStr, `"id":"user-123"`) // field_name → camelCase in JSON
assert.Contains(t, jsonStr, `"email":"alice@example.com"`)
}Schema Evolution Testing
Test that your protos handle forward compatibility correctly — older clients reading messages from newer servers:
func TestForwardCompatibility(t *testing.T) {
// Simulate: new server adds a field that old client doesn't know about
newMessage := &pb.UserV2{ // Hypothetical newer version with extra fields
Id: "user-123",
Email: "alice@example.com",
PhoneNumber: "+1-555-0100", // New field old client doesn't know
}
bytes, _ := proto.Marshal(newMessage)
// Old client deserializes — unknown fields should be preserved, not dropped
oldMessage := &pb.User{}
err := proto.Unmarshal(bytes, oldMessage)
assert.NoError(t, err) // Should not fail
assert.Equal(t, "user-123", oldMessage.Id)
assert.Equal(t, "alice@example.com", oldMessage.Email)
// Unknown field (phone_number) is preserved in proto's unknown fields
}Conclusion
buf replaces the fragmented protoc toolchain with a single, opinionated CLI that makes Protobuf schemas first-class citizens of your CI pipeline. Lint rules enforce consistency across teams. Breaking change detection catches wire-incompatible changes before they reach clients. And the Buf Schema Registry replaces manual proto vendoring with a dependency management system that actually works. Add buf lint and buf breaking to every PR and you'll never ship a broken proto schema silently again.