Buf CI: Linting and Breaking Change Detection for Protobuf
A detailed walkthrough of using Buf's linting and breaking change detection tools to enforce schema quality and backward compatibility in a Protocol Buffers registry.
Schema quality is not optional. A single misspelled field name, an inconsistent enum convention, or a silently wire-incompatible change can propagate through dozens of services before anyone notices. In a microservices architecture where Protocol Buffers define the contracts between teams, automated enforcement of schema standards is as essential as automated testing of application code.
Buf is the tool that makes this enforcement practical. It provides two capabilities that, together, form a comprehensive quality gate: linting, which checks that schemas conform to style and correctness rules, and breaking change detection, which verifies that modifications to existing schemas do not violate wire or JSON compatibility. The Nebula schema registry integrates both into its CI pipeline, blocking any pull request that introduces a lint violation or a breaking change.
This article covers the configuration, rule sets, and operational patterns that make Buf an effective guardian of schema quality.
Setting Up Buf Linting
Buf's linter operates on a set of configurable rules organized into categories. The configuration lives in buf.yaml:
version: v2
modules:
- path: proto
name: buf.build/klivvr/nebula
lint:
use:
- STANDARD
- COMMENTS
except:
- PACKAGE_NO_IMPORT_CYCLE
enum_zero_value_suffix: _UNSPECIFIED
rpc_allow_same_request_response: false
rpc_allow_google_protobuf_empty_requests: true
rpc_allow_google_protobuf_empty_responses: true
service_suffix: ServiceThe STANDARD category includes the rules that the wider protobuf community considers essential: package naming, message casing, enum value prefixes, field naming, and oneof naming. The COMMENTS category requires that every public message, field, enum, enum value, service, and RPC has a leading comment. This is a deliberately high bar that the Nebula team adopted after discovering that undocumented fields were routinely misinterpreted by consuming teams.
Running the linter is a single command:
$ buf lint
proto/nebula/payments/v1/payments.proto:14:3:
Field name "PaymentId" should be lower_snake_case.
proto/nebula/payments/v1/payments.proto:22:1:
Enum value name "PENDING" should be prefixed with "PAYMENT_STATUS_".
proto/nebula/payments/v1/payments.proto:35:1:
Service name "Payments" should have suffix "Service".Each violation includes the file path, line number, column number, and a human-readable explanation. The output is machine-parseable, making it easy to integrate with code review tools that annotate pull request diffs.
Understanding the Rule Categories
Buf organizes its rules into groups that can be enabled or disabled independently. Understanding what each group covers helps teams make informed decisions about which rules to adopt.
MINIMAL contains the absolute bare minimum: syntax validity and field number uniqueness. Every Buf configuration implicitly includes these.
BASIC adds naming conventions for packages, messages, fields, enums, and services. It enforces lower_snake_case for fields, PascalCase for messages and services, and UPPER_SNAKE_CASE for enum values.
STANDARD includes everything in BASIC plus additional structural rules: each directory should contain files from only one package, import paths should match file paths, and package names should match the directory structure. These rules prevent the kind of organizational drift that makes large registries difficult to navigate.
COMMENTS requires documentation comments on all public API elements. This is the rule set that generates the most initial resistance and the most long-term gratitude.
// PaymentService handles the creation, retrieval, and lifecycle
// management of payments within the Nebula platform.
service PaymentService {
// CreatePayment initiates a new payment transaction.
// The request must include a unique idempotency key.
rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse);
// GetPayment retrieves a payment by its unique identifier.
rpc GetPayment(GetPaymentRequest) returns (Payment);
// ListPayments returns a paginated list of payments
// for the specified account.
rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse);
}Without the COMMENTS rules, these doc comments tend to exist for new services and vanish for older ones. The linter makes the standard universal.
Breaking Change Detection
While linting catches style and structural issues, breaking change detection catches compatibility issues. Buf compares the current schema against a reference point (typically the main branch or a published version on the BSR) and reports any change that would break existing clients.
$ buf breaking --against '.git#branch=main'
proto/nebula/payments/v1/payments.proto:12:3:
Field "1" on message "CreatePaymentRequest" changed type from "string" to "bytes".
proto/nebula/payments/v1/payments.proto:18:1:
Previously present field "3" on message "Payment" was deleted without reservation.
proto/nebula/identity/v1/identity.proto:8:5:
Enum value "2" on enum "AccountStatus" was deleted.Buf supports multiple breaking change categories that can be combined:
breaking:
use:
- WIRE
- WIRE_JSONWIRE detects changes that break the binary wire format: field type changes, field number reuse, enum value number changes, and field removals without reservation. This is the minimum viable set for any protobuf registry.
WIRE_JSON extends wire compatibility to cover JSON serialization. Since protobuf messages can be serialized to JSON (using field names rather than numbers), renaming a field is a breaking change in JSON but not in binary. Enabling this category is important for registries whose consumers include REST/JSON gateways.
FILE is the strictest category. It treats any observable change to the generated API surface as breaking, including field renames, comment changes, and option changes. Most teams find this too restrictive for regular use but enable it selectively for stable, widely-consumed APIs.
Configuring Exception Policies
Not every breaking change detection failure warrants blocking a merge. Sometimes a genuinely breaking change is intentional and has been coordinated across all affected teams. Buf provides mechanisms for handling these cases without disabling the entire check.
The first mechanism is buf breaking with a specific --against reference. Instead of comparing against the main branch, you can compare against a specific tagged release:
# Compare against the last published stable version
buf breaking --against 'buf.build/klivvr/nebula:v1.4.0'The second mechanism is inline ignore comments. These are deliberately verbose to discourage casual use:
// buf:lint:ignore FIELD_LOWER_SNAKE_CASE
// Reason: This field name matches the external API's legacy naming convention.
string PaymentID = 1;The Nebula CI pipeline requires that any buf:lint:ignore comment includes a Reason: line. Pull requests that add ignore comments without justification are flagged for additional review.
The third mechanism is the except list in buf.yaml. This disables a rule globally and should be used only for rules that the team has collectively decided are not applicable. In the Nebula configuration, PACKAGE_NO_IMPORT_CYCLE is excepted because the registry's shared type package is intentionally imported by multiple domain packages.
Integrating into GitHub Actions
The Nebula CI pipeline runs Buf checks on every pull request that touches proto files:
name: Proto Quality Gate
on:
pull_request:
paths: ['proto/**', 'buf.yaml', 'buf.gen.yaml']
jobs:
buf-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1
with:
version: '1.47.2'
- uses: bufbuild/buf-lint-action@v1
with:
input: proto
buf-breaking:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1
with:
version: '1.47.2'
- uses: bufbuild/buf-breaking-action@v1
with:
input: proto
against: 'https://github.com/klivvr/nebula.git#branch=main,subdir=proto'
buf-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1
with:
version: '1.47.2'
- name: Check formatting
run: buf format --diff --exit-code proto/Three separate jobs run in parallel: linting, breaking change detection, and format checking. The format check ensures consistent whitespace and ordering, eliminating style debates in code reviews.
The buf-setup-action pins a specific Buf version. This is critical: different Buf versions may add or modify rules, and an unpinned version could cause previously passing builds to fail after an unrelated Buf release.
Developer Workflow: Local Feedback
CI enforcement is necessary but not sufficient. Developers need fast local feedback to avoid the edit-push-wait-fix cycle.
The recommended setup includes a pre-commit hook:
#!/usr/bin/env bash
# .git/hooks/pre-commit
PROTO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.proto$')
if [ -n "$PROTO_FILES" ]; then
echo "Running buf lint on staged proto files..."
buf lint --path $PROTO_FILES || exit 1
echo "Running buf format check..."
buf format --diff --exit-code $PROTO_FILES || {
echo "Proto files are not formatted. Run 'buf format -w' to fix."
exit 1
}
fiEditor integration further shortens the feedback loop. The Buf VS Code extension provides real-time lint warnings, go-to-definition for imported types, and auto-formatting on save. JetBrains IDEs support similar features through the Protocol Buffers plugin combined with external tool configurations for Buf.
Measuring Schema Health Over Time
Lint and breaking change checks produce binary pass/fail results. For long-term schema health, the Nebula team also tracks quantitative metrics:
The total number of lint suppressions across the registry, plotted over time. An increasing trend suggests that the rules are too strict or that teams are not taking them seriously. A flat or decreasing trend suggests healthy compliance.
The ratio of deprecated fields to active fields. A high ratio indicates that the field lifecycle process is working (fields are being deprecated rather than abruptly removed) but that the reservation phase may be lagging.
The count of breaking change exceptions approved per quarter. This metric tracks how often the team resorts to intentional breaking changes, which is a proxy for how well the initial schema design anticipated future needs.
These metrics are published to the team's internal dashboard and reviewed in monthly engineering retrospectives.
Conclusion
Buf's linting and breaking change detection transform schema quality from an aspiration into an automated guarantee. By configuring comprehensive rule sets, integrating checks into CI, providing fast local feedback, and tracking health metrics over time, the Nebula schema registry maintains a level of consistency and compatibility that would be impossible to achieve through code review alone. The tooling does not replace human judgment; it amplifies it, ensuring that every schema change receives the scrutiny it deserves.
Related Articles
Building a Schema Registry: Patterns and Best Practices
A comprehensive guide to building and operating a Protocol Buffers schema registry, covering architecture patterns, governance models, tooling integration, and the operational practices that keep a registry healthy as it scales.
Using Protocol Buffers Across a Microservices Architecture
A business and architecture-focused guide to adopting Protocol Buffers as the standard contract language across a microservices ecosystem, covering shared types, dependency management, team workflows, and the role of a centralized schema registry.
API Versioning Strategies with Protocol Buffers
A business-oriented guide to API versioning with Protocol Buffers, covering when and how to version, migration strategies, multi-version support, and the organizational processes that make versioning sustainable.