LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Buf: The Modern Protobuf Toolchain

protobufgrpcapibufgotypescriptdeveloper-tooling

Protobuf is one of the best API description formats available — strongly typed, language-agnostic, compact, and fast. But the tooling around it has always been painful. protoc requires manually managing a binary, wiring together plugins with obscure flags, installing language runtimes for each plugin, and running fragile shell scripts to generate code. There’s no linting, no breaking change detection, no registry for sharing schemas, and no way to know if the change you just made silently broke a consumer.

Buf fixes all of this. It’s a CLI tool and schema registry that brings the same quality of tooling to Protobuf that Cargo, npm, and Go modules brought to their respective ecosystems. This guide covers the full workflow: linting, breaking change detection, code generation with remote plugins, the Buf Schema Registry, connect-go for modern gRPC servers, and CI/CD integration.


What’s Wrong with protoc

A typical protoc invocation looks like this:

1
2
3
4
5
6
7
8
9
protoc \
  --proto_path=. \
  --proto_path=$GOPATH/src \
  --proto_path=$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
  --go_out=paths=source_relative:./gen \
  --go-grpc_out=paths=source_relative:./gen \
  --grpc-gateway_out=paths=source_relative:./gen \
  --openapiv2_out=./gen \
  $(find . -name '*.proto' -not -path './vendor/*')

Problems with this approach:

  • Plugin management is manual — you need protoc-gen-go, protoc-gen-go-grpc, protoc-gen-grpc-gateway, and more installed at the right versions, in $PATH
  • No linting — nothing stops you from naming a field MyField instead of my_field, or using int32 for a user ID that should be a string
  • No breaking change detection — renaming a field or changing its type silently breaks wire compatibility for existing clients
  • No registry — sharing schemas across teams means copying .proto files or vendoring entire repos
  • Slow — protoc invocations are not cached; every build re-compiles everything

Buf addresses each of these.


Installation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# macOS
brew install bufbuild/buf/buf

# Linux (direct binary)
curl -sSL https://github.com/bufbuild/buf/releases/latest/download/buf-Linux-x86_64 \
  -o /usr/local/bin/buf && chmod +x /usr/local/bin/buf

# Verify
buf --version

# As a Go tool (pinned version in go.mod)
go install github.com/bufbuild/buf/cmd/buf@latest

Project Structure

A typical Buf workspace:

my-api/
├── buf.yaml              # Module configuration
├── buf.gen.yaml          # Code generation configuration
├── buf.lock              # Dependency lockfile (auto-generated)
└── proto/
    └── acme/
        └── petstore/
            └── v1/
                ├── petstore.proto
                └── petstore_service.proto

buf.yaml

buf.yaml defines the module — the root of a set of Protobuf files that are versioned and published together.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
version: v2

# Module name (required for publishing to BSR)
name: buf.build/acme/petstore

# External dependencies from the Buf Schema Registry
deps:
  - buf.build/googleapis/googleapis          # google/api annotations
  - buf.build/grpc-ecosystem/grpc-gateway    # grpc-gateway annotations

# Linting configuration
lint:
  use:
    - DEFAULT                    # All DEFAULT category rules
  except:
    - FIELD_NOT_REQUIRED         # Disable specific rules
  ignore:
    - proto/acme/petstore/v1/legacy.proto  # Skip specific files

# Breaking change detection configuration
breaking:
  use:
    - FILE                       # FILE category (most common)
  ignore_unstable_packages: true # Ignore packages with alpha/beta/v0 suffixes

buf.gen.yaml

buf.gen.yaml configures code generation — which plugins to use, where to write output.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
version: v2

# Input (what to generate from)
inputs:
  - directory: proto

# Plugins to run
plugins:
  # Remote plugin from BSR (no local install needed)
  - remote: buf.build/protocolbuffers/go
    out: gen/go
    opt:
      - paths=source_relative

  - remote: buf.build/grpc/go
    out: gen/go
    opt:
      - paths=source_relative
      - require_unimplemented_servers=false

  # connect-go: modern gRPC/Connect server and client
  - remote: buf.build/connectrpc/go
    out: gen/go
    opt:
      - paths=source_relative

  # TypeScript with connect-es
  - remote: buf.build/bufbuild/es
    out: gen/ts
    opt:
      - target=ts

  - remote: buf.build/connectrpc/es
    out: gen/ts
    opt:
      - target=ts

Linting

Run the linter:

1
2
3
4
5
6
7
buf lint

# Lint a specific directory
buf lint proto/

# Output as JSON (useful in CI)
buf lint --error-format=json

Example output:

proto/acme/petstore/v1/petstore.proto:15:3:Field name "petID" should be lower_snake_case, such as "pet_id".
proto/acme/petstore/v1/petstore.proto:22:1:Message name "getPetRequest" should be PascalCase, such as "GetPetRequest".

Rule categories

Buf organizes lint rules into categories. Each higher category is a superset of the lower:

Category Rules Use case
MINIMAL ~10 rules Absolute minimum; prevents wire incompatibility
BASIC ~30 rules Good defaults for new projects
DEFAULT ~50 rules Recommended; enforces Buf style guide

Key DEFAULT rules

Naming conventions:

  • FIELD_LOWER_SNAKE_CASEpet_id, not petID or PetId
  • MESSAGE_PASCAL_CASEGetPetRequest, not get_pet_request
  • ENUM_VALUE_PREFIX — enum values must be prefixed: PET_STATUS_AVAILABLE, not AVAILABLE
  • ENUM_VALUE_UPPER_SNAKE_CASEPET_STATUS_AVAILABLE, not Pet_Status_Available
  • SERVICE_SUFFIX — services must end in Service: PetStoreService, not PetStore
  • RPC_REQUEST_RESPONSE_UNIQUE — each RPC has its own request/response message type
  • RPC_REQUEST_STANDARD_NAME — request type is {RpcName}Request
  • RPC_RESPONSE_STANDARD_NAME — response type is {RpcName}Response

Design rules:

  • PACKAGE_VERSION_SUFFIX — stable packages must have a version suffix: acme.petstore.v1
  • PACKAGE_SAME_DIRECTORY — all files in a package live in the same directory
  • IMPORT_NO_WEAK — no weak imports
  • ENUM_NO_ALLOW_ALIAS — no allow_alias = true on enums

Why these matter: The ENUM_VALUE_PREFIX rule prevents the classic Protobuf footgun where two enums in the same package both have a value named UNKNOWN. Protobuf enums share a namespace within a package, so UNKNOWN = 0 in two different enums in the same package is a compilation error. Prefixing avoids this entirely.

Configuring rules

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# buf.yaml
lint:
  use:
    - DEFAULT
  except:
    # Allow repeated request/response types across RPCs (legacy APIs)
    - RPC_REQUEST_RESPONSE_UNIQUE
  ignore_only:
    # Allow non-standard naming in legacy files only
    FIELD_LOWER_SNAKE_CASE:
      - proto/acme/petstore/v1/legacy.proto

Breaking Change Detection

This is Buf’s most operationally valuable feature. Run it against the previous version of your API to find incompatibilities before they reach production.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Compare against a git tag
buf breaking --against ".git#tag=v1.2.0"

# Compare against the main branch
buf breaking --against ".git#branch=main"

# Compare against the BSR (if you've pushed your module)
buf breaking --against "buf.build/acme/petstore:main"

# Compare against a specific commit
buf breaking --against ".git#ref=abc1234"

What counts as breaking

The FILE breaking category (most commonly used) flags changes that break binary wire compatibility or generated code compatibility:

Always breaking:

  • Deleting a field
  • Changing a field’s number
  • Changing a field’s type (e.g., int32int64, stringbytes)
  • Changing a field from singular to repeated (or vice versa)
  • Deleting an enum value
  • Changing an enum value’s number
  • Deleting an RPC method
  • Changing an RPC’s request or response type
  • Changing an RPC from unary to streaming (or vice versa)

Not breaking (safe to do):

  • Adding a new field (with a new field number)
  • Adding a new enum value
  • Adding a new RPC method
  • Adding a new message type
  • Adding a new service
  • Changing a field’s json_name option (wire format is unchanged)
  • Adding or changing comments

Example output:

proto/acme/petstore/v1/petstore.proto:18:3:Field "1" with name "id" on message "Pet" changed type from "int64" to "string".
proto/acme/petstore/v1/petstore.proto:24:3:Field "3" with name "name" on message "Pet" was deleted.

The WIRE_JSON category

If your consumers use JSON serialization (grpc-gateway, Connect’s JSON mode), use WIRE_JSON instead of FILE — it additionally checks json_name option changes and other JSON-specific incompatibilities.

1
2
3
4
# buf.yaml
breaking:
  use:
    - WIRE_JSON

Code Generation

Remote plugins (no local installs needed)

The killer feature of buf generate with remote plugins: you don’t need protoc-gen-go, protoc-gen-go-grpc, or any other plugin binary installed locally. Buf runs them remotely on its servers and caches the results.

1
2
3
4
5
6
7
8
# Generate all code defined in buf.gen.yaml
buf generate

# Generate from a specific proto directory
buf generate proto/

# Generate from the BSR (someone else's schema)
buf generate buf.build/googleapis/googleapis --template buf.gen.yaml

Local plugins (for private or custom plugins)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# buf.gen.yaml
plugins:
  # Local binary (must be in PATH or specify full path)
  - local: protoc-gen-go
    out: gen/go
    opt:
      - paths=source_relative

  # Local binary with full path
  - local: /usr/local/bin/protoc-gen-mycompany
    out: gen/mycompany

Full Go example

proto/acme/petstore/v1/petstore.proto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
syntax = "proto3";

package acme.petstore.v1;

option go_package = "github.com/acme/petstore/gen/go/acme/petstore/v1;petstoreV1";

message Pet {
  string id = 1;
  string name = 2;
  string status = 3;
  repeated string photo_urls = 4;
}

message GetPetRequest {
  string pet_id = 1;
}

message GetPetResponse {
  Pet pet = 1;
}

message ListPetsRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message ListPetsResponse {
  repeated Pet pets = 1;
  string next_page_token = 2;
}

service PetStoreService {
  rpc GetPet(GetPetRequest) returns (GetPetResponse);
  rpc ListPets(ListPetsRequest) returns (ListPetsResponse);
}

Run buf generate to produce Go code. With connect-go in buf.gen.yaml, you get three output files per service: the message types (petstore.pb.go), the gRPC stubs (petstore_grpc.pb.go), and the Connect handlers (petstore_connect.pb.go).


connect-go: Modern gRPC for Go

connect-go is Buf’s replacement for grpc-go. It supports three protocols over the same handler:

Protocol Transport Clients
Connect HTTP/1.1 + HTTP/2 curl, browsers, any HTTP client
gRPC HTTP/2 All existing gRPC clients
gRPC-Web HTTP/1.1 Browsers via gRPC-Web proxy

The key advantage: Connect servers work behind any standard HTTP proxy (Nginx, Traefik, AWS ALB) without special configuration, because they use standard HTTP/1.1 alongside HTTP/2. You don’t need an HTTP/2 load balancer for your API.

Server implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
    "context"
    "fmt"
    "net/http"

    "connectrpc.com/connect"
    petstoreV1 "github.com/acme/petstore/gen/go/acme/petstore/v1"
    "github.com/acme/petstore/gen/go/acme/petstore/v1/petstoreV1connect"
)

// PetStoreServer implements the generated interface.
type PetStoreServer struct{}

func (s *PetStoreServer) GetPet(
    ctx context.Context,
    req *connect.Request[petstoreV1.GetPetRequest],
) (*connect.Response[petstoreV1.GetPetResponse], error) {
    // Access request headers
    fmt.Println("Authorization:", req.Header().Get("Authorization"))

    pet := &petstoreV1.Pet{
        Id:     req.Msg.PetId,
        Name:   "Fido",
        Status: "available",
    }

    res := connect.NewResponse(&petstoreV1.GetPetResponse{Pet: pet})
    // Set response headers
    res.Header().Set("Grpc-Status-Details-Bin", "")
    return res, nil
}

func (s *PetStoreServer) ListPets(
    ctx context.Context,
    req *connect.Request[petstoreV1.ListPetsRequest],
) (*connect.Response[petstoreV1.ListPetsResponse], error) {
    return connect.NewResponse(&petstoreV1.ListPetsResponse{
        Pets: []*petstoreV1.Pet{
            {Id: "1", Name: "Fido", Status: "available"},
            {Id: "2", Name: "Whiskers", Status: "pending"},
        },
    }), nil
}

func main() {
    mux := http.NewServeMux()

    // Register the handler — returns path and handler
    path, handler := petstoreV1connect.NewPetStoreServiceHandler(&PetStoreServer{})
    mux.Handle(path, handler)

    // Standard net/http server — works with any middleware
    if err := http.ListenAndServe(":8080", mux); err != nil {
        panic(err)
    }
}

Client implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
    "context"
    "fmt"
    "net/http"

    "connectrpc.com/connect"
    petstoreV1 "github.com/acme/petstore/gen/go/acme/petstore/v1"
    "github.com/acme/petstore/gen/go/acme/petstore/v1/petstoreV1connect"
)

func main() {
    client := petstoreV1connect.NewPetStoreServiceClient(
        http.DefaultClient,
        "http://localhost:8080",
        // Use gRPC protocol instead of Connect:
        // connect.WithGRPC(),
    )

    res, err := client.GetPet(context.Background(),
        connect.NewRequest(&petstoreV1.GetPetRequest{PetId: "1"}),
    )
    if err != nil {
        panic(err)
    }
    fmt.Printf("Pet: %s (%s)\n", res.Msg.Pet.Name, res.Msg.Pet.Status)
}

Middleware and interceptors

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Add interceptors for logging, auth, metrics
path, handler := petstoreV1connect.NewPetStoreServiceHandler(
    &PetStoreServer{},
    connect.WithInterceptors(
        authInterceptor(),
        loggingInterceptor(),
        metricsInterceptor(),
    ),
)

func authInterceptor() connect.Interceptor {
    return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
        return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
            token := req.Header().Get("Authorization")
            if token == "" {
                return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("missing token"))
            }
            return next(ctx, req)
        }
    })
}

connect-es: TypeScript Clients

With buf.gen.yaml configured for connect-es, buf generate produces TypeScript clients that work in browsers and Node.js.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Generated client usage (React/browser example)
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { PetStoreService } from "./gen/ts/acme/petstore/v1/petstore_connect";

const transport = createConnectTransport({
  baseUrl: "https://api.example.com",
});

const client = createClient(PetStoreService, transport);

// Unary call
const response = await client.getPet({ petId: "1" });
console.log(response.pet?.name);

// Works with both Connect and gRPC-Web transports:
// import { createGrpcWebTransport } from "@connectrpc/connect-web";

For Node.js servers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { createServer } from "@connectrpc/connect-node";
import { PetStoreService } from "./gen/ts/acme/petstore/v1/petstore_connect";

const server = createServer({
  routes: (router) =>
    router.service(PetStoreService, {
      async getPet(req) {
        return { pet: { id: req.petId, name: "Fido", status: "available" } };
      },
      async listPets(req) {
        return { pets: [], nextPageToken: "" };
      },
    }),
});

server.listen({ port: 8080 });

The Buf Schema Registry (BSR)

The BSR is a hosted registry for Protobuf modules — think npm or Docker Hub, but for schemas. Free for public modules.

Pushing a module

1
2
3
4
5
6
7
8
# Login (creates ~/.config/buf/credentials.json)
buf registry login

# Push current module to BSR
buf push

# Push with a label (like a git tag)
buf push --label v1.2.0

Your module is now at buf.build/acme/petstore and anyone can depend on it.

Depending on BSR modules

Add dependencies to buf.yaml and run buf dep update to generate the lockfile:

1
2
3
4
5
# buf.yaml
deps:
  - buf.build/googleapis/googleapis
  - buf.build/grpc-ecosystem/grpc-gateway
  - buf.build/acme/shared-types        # Internal shared schemas
1
2
buf dep update    # Resolves and locks versions → buf.lock
buf dep prune     # Remove unused dependencies

Remote plugins on the BSR

Instead of installing protoc plugins locally, reference them by name in buf.gen.yaml:

1
2
3
4
5
6
7
8
plugins:
  # Latest stable version
  - remote: buf.build/protocolbuffers/go
    out: gen/go

  # Pin to a specific version
  - remote: buf.build/protocolbuffers/go:v1.34.0
    out: gen/go

The BSR maintains plugins for all major languages. Your team members get identical output without installing anything — Buf handles plugin versioning and execution remotely.

Generated SDKs

The BSR can generate and host language-specific SDKs directly. Consumers can install your Go or TypeScript SDK as a regular package dependency — without running buf generate at all.

For Go:

1
2
3
# BSR-generated Go SDK — install directly with go get
go get buf.build/gen/go/acme/petstore/protocolbuffers/go
go get buf.build/gen/go/acme/petstore/connectrpc/go

For npm:

1
2
npm install @buf/acme_petstore.bufbuild_es
npm install @buf/acme_petstore.connectrpc_es

This eliminates the “check in generated code or generate on the fly?” debate entirely — the registry serves pre-generated, versioned SDKs.


buf curl: Command-Line RPC Calls

buf curl makes gRPC and Connect calls from the terminal, similar to curl for REST:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Unary call to a Connect server
buf curl \
  --data '{"pet_id": "1"}' \
  https://api.example.com/acme.petstore.v1.PetStoreService/GetPet

# Use gRPC protocol instead
buf curl \
  --protocol grpc \
  --data '{"pet_id": "1"}' \
  https://api.example.com/acme.petstore.v1.PetStoreService/GetPet

# With custom headers
buf curl \
  --header "Authorization: Bearer $TOKEN" \
  --data '{"pet_id": "1"}' \
  https://api.example.com/acme.petstore.v1.PetStoreService/GetPet

# Against a local server (no TLS)
buf curl \
  --http2-prior-knowledge \
  --data '{"page_size": 10}' \
  http://localhost:8080/acme.petstore.v1.PetStoreService/ListPets

# List all available methods (uses server reflection)
buf curl \
  --list-methods \
  https://api.example.com

# Use a local proto file instead of reflection
buf curl \
  --schema proto/ \
  --data '{"pet_id": "1"}' \
  http://localhost:8080/acme.petstore.v1.PetStoreService/GetPet

buf curl automatically uses server reflection if available, so you don’t need to specify the schema for servers that expose it.


CI/CD Integration

GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# .github/workflows/buf.yaml
name: Buf

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bufbuild/buf-action@v1
        with:
          lint: true

  breaking:
    name: Breaking Change Detection
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bufbuild/buf-action@v1
        with:
          # Compare against the target branch of the PR
          breaking: true
          breaking_against: "https://github.com/${{ github.repository }}.git#branch=${{ github.base_ref }}"

  push:
    name: Push to BSR
    runs-on: ubuntu-latest
    # Only push on merges to main
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - uses: bufbuild/buf-action@v1
        with:
          push: true
        env:
          BUF_TOKEN: ${{ secrets.BUF_TOKEN }}

The bufbuild/buf-action action handles installation automatically. Set BUF_TOKEN in your repository secrets (generate at buf.build → Settings → Tokens).

Complete workflow with generate check

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
name: Buf

on: [pull_request, push]

jobs:
  buf:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: bufbuild/buf-action@v1
        with:
          lint: true
          format: true
          breaking: true
          breaking_against: ".git#branch=main,subdir=proto"
          push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
        env:
          BUF_TOKEN: ${{ secrets.BUF_TOKEN }}

      # Verify generated code is up to date
      - name: Verify generated code
        run: |
          buf generate
          git diff --exit-code gen/

The last step ensures developers have committed up-to-date generated code — if they forgot to run buf generate after editing a .proto file, CI fails.


Migrating from protoc

Replace this:

1
2
3
4
5
6
7
# Old: protoc invocation
protoc \
  --proto_path=. \
  --proto_path=$GOPATH/src \
  --go_out=paths=source_relative:./gen/go \
  --go-grpc_out=paths=source_relative:./gen/go \
  $(find proto/ -name '*.proto')

With this:

buf.yaml:

1
version: v2

buf.gen.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
version: v2
inputs:
  - directory: proto
plugins:
  - remote: buf.build/protocolbuffers/go
    out: gen/go
    opt:
      - paths=source_relative
  - remote: buf.build/grpc/go
    out: gen/go
    opt:
      - paths=source_relative

Then:

1
buf generate

That’s it. No protoc binary, no plugin binaries, no $PROTO_PATH juggling.

Migrating existing imports

If your .proto files use import "google/api/annotations.proto", you need to tell Buf where to find it. Replace the local copy with a BSR dependency:

1
2
3
# buf.yaml
deps:
  - buf.build/googleapis/googleapis
1
buf dep update

Now remove your vendored google/ proto directory. Buf resolves the dependency from the BSR.


Workspaces (Monorepos)

For monorepos with multiple Protobuf modules:

monorepo/
├── buf.yaml                  # Workspace root
└── proto/
    ├── users/
    │   ├── buf.yaml          # Module: buf.build/acme/users
    │   └── acme/users/v1/
    └── orders/
        ├── buf.yaml          # Module: buf.build/acme/orders (depends on users)
        └── acme/orders/v1/

Root buf.yaml:

1
2
3
4
5
version: v2
# Workspace: references all local modules
modules:
  - path: proto/users
  - path: proto/orders

Each sub-module has its own buf.yaml with its name and deps. When the orders module depends on the users module, during local development Buf resolves it from the local workspace rather than the BSR — no need to push intermediate versions just to test locally.


Formatting

Buf includes an opinionated formatter:

1
2
3
4
5
6
7
8
# Check formatting (exit code 1 if any file is unformatted)
buf format --diff

# Write formatted output in place
buf format --write

# Format a specific file
buf format proto/acme/petstore/v1/petstore.proto --write

Add to CI alongside lint:

1
buf format --exit-code

Putting It All Together

A complete Makefile (or Taskfile) for a Buf-managed project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.PHONY: proto lint breaking generate

proto: lint generate

lint:
	buf lint
	buf format --exit-code

breaking:
	buf breaking --against ".git#branch=main"

generate:
	buf generate

push:
	buf push --label $(shell git describe --tags --always)

deps:
	buf dep update

The developer workflow becomes:

  1. Edit .proto files
  2. buf lint — fix any style violations
  3. buf breaking --against ".git#branch=main" — verify no compatibility breaks
  4. buf generate — regenerate client/server code
  5. Implement changes in Go/TypeScript against the new generated code
  6. Open PR → CI runs lint, breaking detection, and generate check automatically
  7. Merge → CI pushes new module version to BSR

Summary

Buf replaces a fragile ecosystem of manually-managed binaries with a single tool that handles the entire Protobuf workflow:

Problem Buf solution
Managing protoc and plugin binaries Remote plugins via BSR
No linting for .proto files buf lint with configurable rule categories
No breaking change detection buf breaking with FILE/WIRE_JSON categories
Sharing schemas across teams Buf Schema Registry with versioned modules
Generated SDKs for consumers BSR-hosted generated SDKs (go get / npm install)
Complex protoc invocations Simple buf generate with buf.gen.yaml
HTTP/2-only gRPC servers connect-go: HTTP/1.1 + HTTP/2 + gRPC from one handler
Testing gRPC APIs from the CLI buf curl

For new projects, start with buf lint, buf generate, and the BSR from day one. For existing projects, migration is straightforward — add buf.yaml, replace your protoc invocations with buf generate, and wire up CI. The breaking change detector alone pays for the migration cost the first time it catches a field rename before it ships.

Comments