Skip to content
gohacks.dev
APIs

Buf Linting and Breaking Change Detection in CI Pipelines

Run buf lint and buf breaking on protobuf schemas in Go and CI, with real findings for a messy file and for deleted rpcs, renames and type changes.

9 min read
Summarize in AI
Flat illustration of shipping containers stacked on a ship.

Schemas drift. Someone renames a field to make it clearer, deletes an rpc nobody seems to call, changes an int32 to an int64 because the counter grew. Each change looks harmless in review and breaks a client that nobody remembered. The compatibility rules tell you what is safe, and rules that depend on reviewers remembering them eventually fail.

Tested with Go 1.25.6, buf v1.66.0 built from go.mod, macOS on Apple Silicon (arm64)

Buf is the tool most protobuf projects use to make the rules mechanical. It does two jobs relevant here: lint, which checks that .proto files follow style and design conventions, and breaking change detection, which compares the current schema with an earlier one and reports changes that would break consumers. This article runs both on small schemas, shows the real output, and sets up a CI job. It builds on code generation and the gRPC tutorial. It ran with buf v1.66.0 on Go 1.25.6.

Getting buf, reproducibly

Buf is a Go program, so the pin-it-in-go.mod approach used for protoc plugins works for it too. A tools file imports it, go build produces a local binary, and everyone, including CI, gets the same version:

tools.goGo
//go:build tools

package main

import _ "github.com/bufbuild/buf/cmd/buf"
main.goGo
package main

func main() {}

One detail from doing this: buf's dependency tree reaches a package of grpc-gateway, and the newest release of that module asks for Go 1.26, so the lab pins grpc-gateway/[email protected], the last release that builds on Go 1.25. Without the pin, go mod tidy stops with a toolchain error. The build of the buf binary took about 18 seconds on this machine, and the binary is about 76 MB. You can also install buf with a package manager or download a release binary; building from go.mod is for the case where you want the version in the same file as your other tools.

The schemas

A buf.yaml at the root of each module says where the protos are and which rules to use. The lab writes the same one in each directory:

YAML
version: v2
modules:
  - path: proto
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE

STANDARD is the default lint rule set. FILE is the breaking-change category that treats changes per file: it flags anything that would break generated code or the wire format for a consumer who has the old file. The stricter-sounding WIRE category checks only the wire format, and WIRE_JSON the wire and JSON. Choose by who consumes you: FILE for libraries that others compile against, WIRE_JSON for services whose clients only exchange messages.

The baseline schema, old, is a small shop service:

old/proto/shop/v1/shop.protoprotobuf
syntax = "proto3";

package shop.v1;

option go_package = "example.test/lab/shop/v1;shopv1";

enum Status {
  STATUS_UNSPECIFIED = 0;
  STATUS_OPEN = 1;
  STATUS_SHIPPED = 2;
}

message Order {
  string id = 1;
  string customer = 2;
  int32 quantity = 3;
  Status status = 4;
}

message GetOrderRequest {
  string id = 1;
}

message GetOrderResponse {
  Order order = 1;
}

message CancelOrderRequest {
  string id = 1;
}

message CancelOrderResponse {}

service ShopService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
  rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse);
}

A clean version adds a field and an enum value:

clean/proto/shop/v1/shop.protoprotobuf
syntax = "proto3";

package shop.v1;

option go_package = "example.test/lab/shop/v1;shopv1";

enum Status {
  STATUS_UNSPECIFIED = 0;
  STATUS_OPEN = 1;
  STATUS_SHIPPED = 2;
  STATUS_CANCELLED = 3;
}

message Order {
  string id = 1;
  string customer = 2;
  int32 quantity = 3;
  Status status = 4;
  string note = 5;
}

message GetOrderRequest {
  string id = 1;
}

message GetOrderResponse {
  Order order = 1;
}

message CancelOrderRequest {
  string id = 1;
}

message CancelOrderResponse {}

service ShopService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
  rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse);
}

The new version does what well-meaning engineers do: renames a field, widens an integer, drops an enum value, drops an rpc and its messages, and adds a note field.

new/proto/shop/v1/shop.protoprotobuf
syntax = "proto3";

package shop.v1;

option go_package = "example.test/lab/shop/v1;shopv1";

enum Status {
  STATUS_UNSPECIFIED = 0;
  STATUS_OPEN = 1;
}

message Order {
  string id = 1;
  string customer_name = 2;
  int64 quantity = 3;
  Status status = 4;
  string note = 5;
}

message GetOrderRequest {
  string id = 1;
}

message GetOrderResponse {
  Order order = 1;
}

service ShopService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
}

And a messy schema breaks every style rule at once:

messy/proto/shop/shop.protoprotobuf
syntax = "proto3";

package shop;

enum status {
  OPEN = 0;
  SHIPPED = 1;
}

message order {
  string ID = 1;
  status Status = 2;
}

service Shop {
  rpc get_order(order) returns (order);
}

Running it

Shell
for d in old new clean messy; do
  printf 'version: v2\nmodules:\n  - path: proto\nlint:\n  use:\n    - STANDARD\nbreaking:\n  use:\n    - FILE\n' > $d/buf.yaml
done
mkdir -p bin
go build -o bin/buf github.com/bufbuild/buf/cmd/buf
./bin/buf --version
echo "--- lint a tidy schema"
(cd old && ../bin/buf lint && echo "no lint findings")
echo "--- lint a messy schema"
(cd messy && ../bin/buf lint; echo "exit code: $?")
echo "--- breaking: an additive change (new field, new enum value) against the baseline"
(cd clean && ../bin/buf breaking --against ../old && echo "no breaking changes")
echo "--- breaking: the risky change against the baseline"
(cd new && ../bin/buf breaking --against ../old; echo "exit code: $?")
Text
1.66.0
--- lint a tidy schema
no lint findings
--- lint a messy schema
proto/shop/shop.proto:3:1:Package name "shop" should be suffixed with a correctly formed version, such as "shop.v1".
proto/shop/shop.proto:5:6:Enum name "status" should be PascalCase, such as "Status".
proto/shop/shop.proto:6:3:Enum value name "OPEN" should be prefixed with "STATUS_".
proto/shop/shop.proto:6:3:Enum zero value name "OPEN" should be suffixed with "_UNSPECIFIED".
proto/shop/shop.proto:7:3:Enum value name "SHIPPED" should be prefixed with "STATUS_".
proto/shop/shop.proto:10:9:Message name "order" should be PascalCase, such as "Order".
proto/shop/shop.proto:11:10:Field name "ID" should be lower_snake_case, such as "id".
proto/shop/shop.proto:12:10:Field name "Status" should be lower_snake_case, such as "status".
proto/shop/shop.proto:15:9:Service name "Shop" should be suffixed with "Service".
proto/shop/shop.proto:16:3:RPC "get_order" has the same type "shop.order" for the request and response.
proto/shop/shop.proto:16:7:RPC name "get_order" should be PascalCase, such as "GetOrder".
proto/shop/shop.proto:16:17:RPC request type "order" should be named "GetOrderRequest" or "ShopGetOrderRequest".
proto/shop/shop.proto:16:33:RPC response type "order" should be named "GetOrderResponse" or "ShopGetOrderResponse".
exit code: 100
--- breaking: an additive change (new field, new enum value) against the baseline
no breaking changes
--- breaking: the risky change against the baseline
proto/shop/v1/shop.proto:1:1:Previously present message "CancelOrderRequest" was deleted from file.
proto/shop/v1/shop.proto:1:1:Previously present message "CancelOrderResponse" was deleted from file.
proto/shop/v1/shop.proto:7:1:Previously present enum value "2" on enum "Status" was deleted.
proto/shop/v1/shop.proto:14:3:Field "2" with name "customer_name" on message "Order" changed option "json_name" from "customer" to "customerName".
proto/shop/v1/shop.proto:14:10:Field "2" on message "Order" changed name from "customer" to "customer_name".
proto/shop/v1/shop.proto:15:3:Field "3" with name "quantity" on message "Order" changed type from "int32" to "int64".
proto/shop/v1/shop.proto:28:1:Previously present RPC "CancelOrder" on service "ShopService" was deleted.
exit code: 100

Lint

On the tidy schema, buf lint prints nothing and exits 0: the "no lint findings" line is the script's. On the messy schema it prints thirteen findings and exits with code 100, each as file:line:column:message:

  • the package has no version suffix (shop should be shop.v1), which matters because the package is part of every method path and is how you version breaking changes;
  • the enum is not PascalCase, its values are not prefixed with the enum name (STATUS_OPEN), and the zero value is not suffixed _UNSPECIFIED, a convention that makes the default value mean "not set";
  • the message and field names have the wrong case;
  • the service name should end in Service;
  • the rpc has the same type for request and response, is not PascalCase, and its message names should be GetOrderRequest and GetOrderResponse.

The last group is the one that pays off most. Giving every rpc its own request and response messages is what lets you add fields to one call without touching another, and lint enforces it.

You will disagree with a few rules. Buf lets you exclude them (except: in the config) or ignore a path (ignore_only:); do that deliberately and in the config, with a comment, not by silencing the tool.

Breaking changes

The second tool is buf breaking --against <previous>. The previous version can be a directory, a git ref (.git#branch=main), a tag or a registry. The lab uses a directory for simplicity; in CI it is the main branch.

The additive clean schema produced no output and exit code 0. A new field and a new enum value are compatible, as the earlier articles argued.

The new schema produced seven findings and exit code 100:

Text
Previously present message "CancelOrderRequest" was deleted from file.
Previously present message "CancelOrderResponse" was deleted from file.
Previously present enum value "2" on enum "Status" was deleted.
Field "2" ... changed option "json_name" from "customer" to "customerName".
Field "2" on message "Order" changed name from "customer" to "customer_name".
Field "3" with name "quantity" on message "Order" changed type from "int32" to "int64".
Previously present RPC "CancelOrder" on service "ShopService" was deleted.

Each line carries the file, line and column of the new schema where the problem lies. Read them as a review: the deleted rpc will fail every client that calls it with Unimplemented; the rename breaks JSON and generated code even though the bytes are the same; the deleted enum value is allowed on the wire but breaks code that names it; the type change is wire-compatible for values that fit and breaks generated code.

Importantly, FILE flagged the rename and the type widening that the wire-only view would call safe. That is the point of choosing the category to match your consumers.

Putting it in CI

A pipeline job needs three things: buf at the pinned version, the proposed schema, and the baseline to compare against. In GitHub Actions or any CI, the steps look like this:

YAML
- uses: actions/checkout@v4
  with:
    fetch-depth: 0            # buf breaking needs the base branch's history
- uses: actions/setup-go@v5
  with:
    go-version-file: go.mod
- run: go build -o bin/buf github.com/bufbuild/buf/cmd/buf
- run: ./bin/buf lint
- run: ./bin/buf breaking --against '.git#branch=origin/main'

Make both steps required checks on pull requests. Three habits keep the signal useful.

Fail on breaking changes, but give an escape hatch. Sometimes a break is intended: a new major version. The escape hatch is a new package (shop.v2), which buf breaking treats as a new file and does not compare against v1. The break is then declared in the schema, visible in review, and not a label someone adds to bypass the check.

Lint new code strictly and old code gradually. On a legacy schema, buf lint may report hundreds of findings. Use ignore_only or except to accept the existing ones and fail only on new violations, then burn the list down.

Run generation checks in the same job, so the generated code in the repository matches the schema (code generation). Lint, breaking and "generated code is current" together are the full schema gate.

Beyond the two commands

buf format rewrites .proto files into a canonical layout, ending style arguments. buf generate runs plugins from a buf.gen.yaml, replacing a hand-written protoc script. buf build produces a descriptor image. buf curl calls a gRPC or Connect service from the command line. The Buf Schema Registry hosts modules and shares dependencies such as the Google API protos, so you stop copying them into repositories. None of those is required to benefit from lint and breaking checks. The tool and rule reference is at buf.build/docs, and the lint rules with their rationale are listed in the lint rules documentation.

Also inDevOps
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.