For a long time, Protobuf had a notoriously bad developer experience: protoc flags you couldn’t find documentation for, .proto files copied between repositories by hand, a separate plugin install story for every language, and an RPC-based API you couldn’t curl. Buf has spent the last few years improving all of this, and schema-first development with Protobuf is now the best way to build a modern API.
Breaking change detection
In many codebases, whether an API change is safe lives in somebody’s head. Reviewers catch what they remember to look for, and the rest turns up when QA happens to test the feature that broke, or, worse, when errors spike in production. Hand-rolled JSON APIs can have this problem whenever the serializer derives field names directly from code, because then the wire format is a side effect of your variable names. Rename Username to Handle on a Go struct and the JSON key renames with it. Go tells you which of your own call sites need updating. Nothing tells you that you just broke your API clients.
Many default JSON serializers derive names from symbols the same way, which turns the safest refactor an IDE offers into a breaking API change.
With a Protobuf-driven API, your .proto files are the contract. Every change to the API happens there first. This extends the guarantees of static typing from inside your program to the boundaries between programs. A compiler catches a local rename because the type is declared, and a schema check catches a broken API because the contract is declared.
buf breaking compares two versions of a schema and reports changes that would break compatibility. What counts as a break depends on how clients use the schema. For this rename, the important differences look like this:
There are three answers to the “does this change break the API” question. A client that only speaks binary Protobuf is fine, because the wire format identifies fields by number and the number didn’t change. A client reading JSON is broken, because the JSON key is derived from the field name. Source code using the generated API is broken too, because the accessor it used to call is gone. FILE is the default, so a rename fails the check unless you say you only care about the wire.
$ buf breaking --against '.git#branch=main'
acme/user/v1/user.proto:6:3:Field "1" with name "handle" on message "User" changed option "json_name" from "username" to "handle".
acme/user/v1/user.proto:6:10:Field "1" on message "User" changed name from "username" to "handle".One rename produces two findings: the field name changed, which affects generated APIs and JSON compatibility, and its derived json_name changed with it. Under WIRE the same command exits clean, because neither reaches the binary format.
The command above compares against your own main branch, giving you a compatibility check before merge without relying on reviewers or downstream test suites.
Linting
Style drifts as more people and more files get added to a project. Field names wander between casings, enum values carry a type prefix in one file and none in the next, a service called Teams ends up next to a message called Team, which is just kind-of confusing. None of it is obviously broken, which is exactly why it accumulates over time.
buf lint enforces a standard set of naming and layout rules across every file in a module. Here is a file that trips two of them:
syntax = "proto3";
package acme.team.v1;
message Team {
string teamName = 1;
}
service Teams {
rpc Get(GetRequest) returns (GetResponse);
}$ buf lint
acme/team/v1/team.proto:6:10:Field name "teamName" should be lower_snake_case, such as "team_name".
acme/team/v1/team.proto:9:9:Service name "Teams" should be suffixed with "Service".The STANDARD category is on by default, so there’s nothing to configure. Every violation prints a file, a line, and a column, and for the naming rules, the name it wanted instead. The lint rules reference documents every rule and the category it belongs to.
Formatting
Formatting is the same problem, and it lands in every code review. When some people run a Protobuf formatter and some don’t, half of every diff is whitespace and the real change is buried somewhere in it.
buf format rewrites every .proto file into one canonical style, with no style configuration at all: no line length, no indent width, no brace style.
message Team
{
string name=1;
repeated string member_ids = 2;
}
enum Status{STATUS_UNSPECIFIED=0;STATUS_ACTIVE=1;}message Team {
string name = 1;
repeated string member_ids = 2;
}
enum Status {
STATUS_UNSPECIFIED = 0;
STATUS_ACTIVE = 1;
}buf format -d --exit-codeRun this in CI and the whitespace diffs stop.
Remote code generation
Plugins can be written in many different programming languages, so each one comes with its ecosystem’s installation story: protoc-gen-go from go install, protoc-gen-es from npm, the Swift plugin from Homebrew or Mint. Generating for three languages means three toolchains to keep working, in every environment that runs the generator.
buf generate declares the whole generation step in a config file, where every plugin, option, and output directory has a well-defined place. Remote plugins remove the need to install plugin binaries at all: swap local: for remote: and the plugin runs on the Buf Schema Registry instead of on a laptop.
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.12
out: internal/gen
opt: paths=source_relative
- remote: buf.build/bufbuild/es:v2.14.0
out: web/src/gen
- remote: buf.build/apple/swift:v1.38.1
out: ios/Sources/GenThis avoids needing go install, npm, and Homebrew just to regenerate code, and every developer and CI job will run the same upstream plugin version with the same options.
Schema Registry
The Buf Schema Registry (BSR) is a package registry for Protobuf. buf push publishes your schema, and everyone else depends on it by name instead of keeping their own copy of the .proto files.
Any schema on the BSR can be installed as a generated SDK, in every language the BSR supports, with no buf.gen.yaml, no plugins, and nothing to generate locally:
Point your package manager at the registry and generated code from somebody else’s schema becomes just another dependency:
# Go needs no configuration at all
go get buf.build/gen/go/connectrpc/eliza/connectrpc/go
# npm needs a one-time setup for the registry
npm config set @buf:registry https://buf.build/gen/npm/v1
npm install @buf/connectrpc_eliza.bufbuild_esTo pick up a schema change, you bump the dependency like any other. Generated SDKs covers the setup for every language the registry supports.
That same push produces documentation: every commit gets a rendered and browsable reference. It beats the inevitable wiki page or markdown file that a dev wrote once and never updated.
Dependencies for your Protobuf files work like any other package in your language. You list modules under deps in buf.yaml, run buf dep update, and get a buf.lock. With the BSR, you can finally retire the third_party/proto directory that you copy-pasted Protobuf files into three years ago and then forgot about.
GitHub Action
One step of the Buf GitHub Action builds, checks formatting, lints, and checks for breaking changes on every pull request, reports the results directly on the pull request, and runs buf push on Git pushes, so a merge to the default branch pushes the module to the BSR registry.
# .github/workflows/buf-ci.yaml
name: Buf CI
on:
push:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
delete:
permissions:
contents: read
pull-requests: write
jobs:
buf:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: bufbuild/buf-action@v1
with:
token: ${{ secrets.BUF_TOKEN }}ConnectRPC
So far the schema has been a source file to check, format, generate from, and publish. It also defines how your services actually talk.
ConnectRPC is a Protobuf-driven RPC framework. The service definitions in your .proto files sit alongside the messages, and code generation turns them into a server interface and a typed client, in many different programming languages. You write the definitions once, and every server and client generated from them is type-safe, whatever language it’s in.
Connect’s own protocol is deliberately built around ordinary HTTP semantics. A unary (non-streaming) call is just an HTTP POST request: the RPC name is the URL path, the body is a single message, and failures come back as real status codes, so curl works and standard HTTP tooling can inspect it without needing to understand gRPC:
POST /connectrpc.eliza.v1.ElizaService/Say HTTP/1.1
host: demo.connectrpc.com
content-type: application/json
{"sentence": "I feel happy."}
HTTP/1.1 200 OK
content-type: application/json
{"sentence": "Feeling happy? Tell me more."}That body can be JSON or binary Protobuf, chosen per request, so nobody has to adopt Protobuf just to try your API.
One ConnectRPC server also answers gRPC and gRPC-Web natively, with no proxy in front of it and no second endpoint. This makes it trivial to integrate with existing gRPC servers and clients. That’s easiest to see with buf curl:
URL=https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say
# a browser, or curl, over HTTP/1.1
buf curl --protocol connect --data '{"sentence":"hi"}' $URL
# an existing gRPC client, over HTTP/2
buf curl --protocol grpc --data '{"sentence":"hi"}' $URL
# a browser client speaking gRPC-Web
buf curl --protocol grpcweb --data '{"sentence":"hi"}' $URLExisting gRPC and gRPC-Web clients keep working, the server picks the protocol off the request’s Content-Type, and your handlers don’t need to care which protocol a request came in on. That means you can move a single service to ConnectRPC without updating any of its callers. Streaming works too, over the Connect protocol and over gRPC.
On the web
The browser is another place where API details tend to get copied by hand. Call an endpoint without a generated client and somebody has to build the URL string, define the request and response types, and keep all of that synchronized with the server.
Connect-ES starts from the generated service definition instead. Give that definition a transport and you get a typed client whose methods, inputs, and outputs all come from the .proto file:
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { ElizaService } from "./gen/eliza_pb";
const client = createClient(
ElizaService,
createConnectTransport({ baseUrl: "https://demo.connectrpc.com" }),
);
const { sentence } = await client.say({ sentence: "Hello" });The examples repository has that same service called from a pile of browser frameworks, React Native, and plain JavaScript, plus servers on several Node runtimes.
If the frontend uses TanStack Query, Connect-Query is an easy choice. It turns the generated RPC definitions into TanStack Query operations:
const { data } = useQuery(ElizaService.method.say, { sentence: "Hello" });The query key and its request and response types all come from the RPC definition.
Protovalidate
Protobuf is permissive by default: missing fields won’t cause parsing errors. Early versions (proto2) included a required keyword to enforce data presence, but it became an issue with living, large systems. If you ever needed to deprecate a required field later, removing it would cause older clients to reject the entire message. The design proved so brittle that proto3 dropped the feature entirely, and the official advice is to never use required.
Protovalidate moves validation rules into the schema without putting them in the wire format. Instead of modifying the parser, the rules are added as custom field options. A separate library evaluates them only after the message is successfully parsed. This lets validation evolve independently of the Protobuf wire format. You even get required back. Here’s a rule that enforces a 2-character minimum for names:
string name = 1 [(buf.validate.field).string.min_len = 2];The rest of the standard rules look similar:
message Team {
string name = 1 [
(buf.validate.field).string.min_len = 2,
(buf.validate.field).string.max_len = 50
];
Tier tier = 2 [(buf.validate.field).enum.defined_only = true];
repeated string roles = 3 [
(buf.validate.field).repeated.min_items = 1,
(buf.validate.field).repeated.unique = true
];
map<string, int32> quotas = 4 [
(buf.validate.field).map.max_pairs = 10,
(buf.validate.field).map.values.int32.gte = 0
];
}The full list of rules is available on the standard rules page of protovalidate.com.
Collection limits are particularly useful for APIs where a repeated field drives work: database writes, downstream requests, or rows returned. repeated.max_items puts that bound in the contract where callers can see it.
Because the rules live in the schema, both ends of a call can enforce them. On a Go server there’s no per-handler validation code to write: a ConnectRPC validation interceptor checks every request against the schema, so registering it once covers every handler. In the browser, @bufbuild/protovalidate evaluates those same rules without restating any of them in TypeScript, so a form can reject a bad value instantly, with no round trip to the server:
string name = 1 [(buf.validate.field).string.min_len = 2];connect.WithInterceptors(validate.NewInterceptor())const result = validator.validate(TeamSchema, req);Editor support
Authoring .proto files used to be the most frustrating part of the workflow. While editors offered basic syntax highlighting, import paths had to be configured manually. As a result, a schema that compiled perfectly would often sit in your IDE covered in red squiggles.
buf lsp serve is a language server that ships inside the Buf CLI. Imports resolve the way buf build resolves them, diagnostics come from the rules buf lint enforces in CI, and you get go-to-definition, completion, hover, and formatting on top. VS Code has the Buf extension and IntelliJ has Buf for Protocol Buffers; Zed, Vim, Neovim, and Emacs just need pointing at buf lsp serve. There’s a whole post on how it works if you want the internals.
Fitting everything together
What ties all of this together is that the .proto file is the source for all of it. Add a field and it shows up in generated SDKs, validation, registry docs, and the compatibility check that protects the next change.
You don’t need to adopt all of it together, though. buf breaking is worth running in a repo with no other Buf command in it, and buf format earns its place in a project that adopts nothing else.
Install the CLI and buf lint will have something to say about your schema inside a minute. The Buf Slack is a good place to ask if you’re working through any of it.