buf: Protobuf Schema Management, Linting, and Breaking Change Detection

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.proto

With buf:

buf generate  # That's it

Installation

# 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 --version

Setting 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 level

Place 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.proto

Linting Protobuf Schemas

Run the linter:

buf lint

Example 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 protos

Breaking 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/ts

Generate:

buf generate

Generated files appear in gen/:

gen/
  user/
    v1/
      user.pb.go
      user_grpc.pb.go

Managing 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 annotations

Update dependencies:

buf dep update

This 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
          fi

Blocking 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:

  1. Use a new package version: user.v2 instead of user.v1 (both can coexist)
  2. Override with a comment: buf:breaking:ignore_unstable_packages on the specific file
# Allow breaking changes in alpha/beta packages
breaking:
  use:
    - FILE
  ignore_unstable_packages: true  # Won't break on user.v1alpha1 changes

Writing 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.

Read more

Start now free