Your first .proto file compiles, generates stubs, and ships. A year on, a field is dropped
and its number is given to a different field. A client on the previous build decodes a
warehouse name where a supplier code used to be. Nothing in the file told the compiler that
number was spoken for, so the compiler let it through.
What you get
You will end up with a proto3 file declaring a package, two messages, and a unary service,
compiled by buf build, linted twice, and paired with an Edition 2023 twin. This is for you if
you are writing your first gRPC contract and want it to survive its second version.
Short answer
Declare syntax = "proto3", a versioned package such as inventory.v1, the go_package and
java_package options, your messages, and a service whose methods take one request message and
return one response message. Run buf build to compile it and buf lint to check the names.
Every field number is permanent, so retire a field with reserved rather than by deleting its
line.
You will need
A working install of the buf CLI, and Node 22 or later if you want the samples as they are, which fetch buf and protolint from npm. Verified 2026-09-24 against Node 22.22.2, buf 1.73.0, and protolint 0.57.0. The language is specified in the proto3 guide, and the four method shapes in the gRPC core concepts, of which this page uses the first: one request in, one response out.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| buf lint | You use buf already and want package, naming, and RPC rules from one line of config | STANDARD adds opinions the style guide never had: Service suffixes, Request and Response names, an _UNSPECIFIED zero value | You want a fixer, or rules for line length and quote style |
| Protobuf Editions 2023 | A fresh file, explicit presence by default, and a path to later editions | Every generator in the build has to understand editions, and one that does not refuses the file outright | A plugin you depend on has not added editions support |
| proto3 | Every toolchain, every plugin, every tutorial you will find | Implicit presence unless you write optional, so an unset integer and a zero are the same bytes | Most fields need presence and you do not want a keyword on each |
| protolint | You want the official style guide enforced, with -fix for what it can rewrite | A second binary and config, and default rules that stop at the style guide, so RPC and package conventions are yours to add | buf’s rules already run in your pipeline |
proto3 and Editions describe the same wire format, so the choice is about the file and the tools that read it. proto3 works everywhere and spells presence with a keyword on each field. Editions make presence the default and ask every plugin in your build to have caught up. The two linters overlap on names and diverge on the rest: buf adds RPC and package rules the style guide does not contain, and protolint adds the formatting rules buf leaves alone.
Declare the package and where the code lands
The file opens with the syntax, a package with a version suffix, and the two options that decide where generated code goes.
syntax = "proto3";
package inventory.v1;
// Where generated code lands. The file compiles without either option, and
// both are wrong to leave out: protoc-gen-go refuses a file with no Go import
// path, and Java classes land in a package named after the proto package.
option go_package = "example.com/inventory/gen/inventory/v1;inventoryv1";
option java_package = "com.example.inventory.v1";
option java_multiple_files = true;
The v1 in the package is not decoration. A package is the name every generated type
lives under, so inventory.v1 and a future inventory.v2 can be imported side by side, and buf’s
PACKAGE_VERSION_SUFFIX rule fails a package without one. The
Go generator requires an import path for
every file it touches, from go_package or from an M flag on the command line. The
Java generator falls back to the package
declared in the file when java_package is absent, which puts your classes in inventory.v1 beside nothing
else of yours.
Write the messages and the service
Two messages for the request and the response, one for the thing itself, and a service with a unary method.
// Item is one stock keeping unit and its quantity on hand.
message Item {
// Numbers 3 and 4 once carried `location` and `supplier_code`. They stay
// reserved so no future field can decode old bytes as something they are not.
reserved 3, 4;
reserved "location", "supplier_code";
string sku = 1;
string name = 2;
int32 quantity = 5;
// A note is either set or not, and an empty string is a set note. proto3
// needs the `optional` keyword to say so; Editions say it by default.
optional string note = 6;
}
// GetItemRequest names one item by SKU.
message GetItemRequest {
string sku = 1;
}
// GetItemResponse carries the item; a NOT_FOUND status carries nothing.
message GetItemResponse {
Item item = 1;
}
// InventoryService reads stock levels.
service InventoryService {
// GetItem returns one item by SKU.
rpc GetItem(GetItemRequest) returns (GetItemResponse);
}
Every method gets its own request and response message, even when the request is one string.
A method that takes Item and returns Item cannot grow a second argument without breaking
every caller, and buf lint says so in the run below. The reserved lines are the reason the
opening paragraph cannot happen to this file.
Compile it and read back what the compiler saw
buf build compiles the module and prints nothing on success, so the sample echoes.
npx buf build proto && echo "proto3 module compiles"
proto3 module compiles
A compiled file is a descriptor, and the descriptor is the ground truth for what the options
and the presence rules came out as. presence.mjs asks buf for the image as JSON and prints
the parts this page is about for both files, proto3 first.
node presence.mjs
proto/inventory/v1/inventory.proto
syntax proto3
go_package example.com/inventory/gen/inventory/v1;inventoryv1
java_package com.example.inventory.v1
reserved numbers 3, 4; names location, supplier_code
field 1 sku implicit (proto3 default)
field 2 name implicit (proto3 default)
field 5 quantity implicit (proto3 default)
field 6 note explicit (proto3 optional)
editions/inventory/v1/inventory.proto
syntax editions
go_package example.com/inventory/gen/inventory/v1;inventoryv1
java_package com.example.inventory.v1
reserved numbers 3, 4; names location, supplier_code
field 1 sku implicit
field 2 name implicit
field 5 quantity implicit
field 6 note explicit (Editions default)
Same numbers, same options, same reserved set, same presence on every field. The two files differ in how they say it and in nothing the wire will see.
Reserve what you retire
A field number identifies the field in the encoded bytes, which is why the
encoding guide says it can never change
once a message is in use.1 Deleting a field’s line frees its number for the next person, and
the compiler cannot know that the number ever meant anything. reserved tells it.
message Item {
reserved 3, 4;
reserved "location", "supplier_code";
string sku = 1;
string name = 2;
// Number 3 is reserved above. This file exists to show that the compiler refuses it.
string warehouse = 3;
}
npx buf build broken
broken/inventory/v1/inventory.proto:12:22:use of reserved field number `3`
Reserve the name as well as the number. The number protects the bytes; the name protects the JSON mapping and any code that looked the field up by name.
Say the same thing in Edition 2023
Editions replace the syntax line with an edition, and replace proto3’s hard-coded behavior
with features that have a default per edition.
Presence is the one that shows in a file this size.
edition = "2023";
package inventory.v1;
// Presence is explicit by default in Editions, so `sku` asks for proto3's
// behavior by name: an empty string is not serialized and cannot be told
// from an unset one.
string sku = 1 [features.field_presence = IMPLICIT];
string name = 2 [features.field_presence = IMPLICIT];
int32 quantity = 5 [features.field_presence = IMPLICIT];
// No keyword. Explicit presence is the default, which `optional` spelt in
// proto3.
string note = 6;
npx buf build editions && echo "editions module compiles"
editions module compiles
The trade is in the table. Editions are where the language is going, and a generator plugin has
to be taught them before it will accept the
file. Check every plugin in your buf.gen.yaml against an editions file before you commit a new
API to it.2
Lint it twice
style/inventory/v1/stock.proto carries five habits imported from other languages: single
quotes, a snake_case message, an upper-case field, a singular repeated field, and an enum
with no prefix. Both linters read it.
npx buf lint style
style/inventory/v1/stock.proto:7:9:Message name "stock_level" should be PascalCase, such as "StockLevel".
style/inventory/v1/stock.proto:8:10:Field name "SKU" should be lower_snake_case, such as "sku".
style/inventory/v1/stock.proto:14:3:Enum value name "IN_STOCK" should be prefixed with "STATUS_".
style/inventory/v1/stock.proto:14:3:Enum zero value name "IN_STOCK" should be suffixed with "_UNSPECIFIED".
style/inventory/v1/stock.proto:15:3:Enum value name "BACK_ORDERED" should be prefixed with "STATUS_".
style/inventory/v1/stock.proto:18:9:Service name "Stock" should be suffixed with "Service".
style/inventory/v1/stock.proto:19:3:RPC "getLevel" has the same type "inventory.v1.stock_level" for the request and response.
style/inventory/v1/stock.proto:19:7:RPC name "getLevel" should be PascalCase, such as "GetLevel".
style/inventory/v1/stock.proto:19:16:RPC request type "stock_level" should be named "GetLevelRequest" or "StockGetLevelRequest".
style/inventory/v1/stock.proto:19:38:RPC response type "stock_level" should be named "GetLevelResponse" or "StockGetLevelResponse".
npx protolint lint style
[style/inventory/v1/stock.proto:1:1] Quoted string should be "proto3" but was 'proto3'.
[style/inventory/v1/stock.proto:14:3] EnumField name "IN_STOCK" should have the prefix "STATUS"
[style/inventory/v1/stock.proto:15:3] EnumField name "BACK_ORDERED" should have the prefix "STATUS"
[style/inventory/v1/stock.proto:14:3] EnumField name "IN_STOCK" with zero value should have the suffix "UNSPECIFIED"
[style/inventory/v1/stock.proto:8:3] Field name "SKU" must be underscore_separated_names like "sku"
[style/inventory/v1/stock.proto:9:3] Repeated field name "warehouse" must be pluralized name "warehouses"
[style/inventory/v1/stock.proto:7:1] Message name "stock_level" must be UpperCamelCase like "StockLevel"
[style/inventory/v1/stock.proto:19:3] RPC name "getLevel" must be UpperCamelCase like "GetLevel"
Ten findings against eight, with six in common. buf alone wants the Service suffix, the
Request and Response names, and one message per direction, which are buf’s rules and not
the style guide’s. protolint alone catches
the single quotes and the singular warehouse, both of which are in the style guide. buf’s
STANDARD category, its default when buf.yaml has no lint section, does not check either.3
Run buf for the API rules and protolint with -fix for the formatting, or pick one and write
down which rules you are giving up.
Check it worked
node --test proto.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1088.919883
The tests read the descriptors back and assert that the proto3 file and its Editions twin agree on every number, every option, and every presence rule. The fifth asserts the compile error shown earlier, so a future buf that let a reserved number through would fail the suite.
When it goes wrong
The Go stubs land in the wrong import path, or the Java classes land in a package named
inventory.v1. Both options were left out, which no tool reported, because a file without them
compiles and lints clean.
npx buf build nopkg && npx buf lint nopkg && echo "compiles, and lints clean"
compiles, and lints clean
Add the options to every file. If buf generates your code, its
managed mode sets go_package and
java_package at generation time from one rule in buf.gen.yaml, so the source files can stay
silent and still land in the right place. Pick one of the two and do not mix them, because a
file that says one thing and a config that says another is a merge conflict waiting for a
release.
A generator rejects the Editions file with an error about an unsupported edition. That plugin has not implemented editions. Keep the API on proto3 until it does, or replace the plugin; converting the file back is mechanical, as the presence output earlier shows.
The zero value of an enum means something. In proto3 an unset enum field reads as its zero value,
so IN_STOCK = 0 makes every item in stock until somebody says otherwise. Both linters flag
it for that reason, and the fix is a STATUS_UNSPECIFIED = 0 nobody writes on purpose.
When not to do this
Do not reuse a field number, whatever the pressure. A tool cannot save a file that has forgotten
its own history, and reserved costs one line per retirement.
Do not start a new API on Edition 2023 until every plugin in the build has compiled an editions file in front of you. The wire format is the same, so there is nothing to gain from switching early and a build to lose.
Do not run both linters without deciding what to do when they disagree. They agree on six findings and differ on six, and a pipeline that fails on either is a pipeline that fails on the union, which nobody chose.
Do not put a version in a message or service name. The package carries it, and
inventory.v2.Item is a different type from inventory.v1.Item by construction, with no
ItemV2 to rename later.
Do not reach for streaming methods on the first version. A unary method is a function call, and every client library, proxy, and gateway understands it. Streams change error handling and timeouts, which is a later page.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2, buf 1.73.0, and protolint 0.57.0, both installed from
npm in the sample directory. Every output block is what the command preceding it printed. No
code was generated: the go_package and java_package behavior is read from the generators’
documentation, and the “unsupported edition” failure was not reproduced, because no plugin
without editions support was installed.
Footnotes
-
The largest field number is 536,870,911, which is two to the power of 29, less one. The encoding guide explains the arithmetic. A record’s tag is a
varintholding the field number shifted left three places, with the wire type in the low bits. Twenty-nine is what remains of 32 once the wire type has taken its three. The proto3 guide then removes 19,000 to 19,999 for the implementation’s own use, so the available numbers run to 536,869,911 and nobody has complained. ↩︎ Back to text -
The version support page lists the syntax values a
.protofile may declare as proto2, proto3, 2023, 2024, and 2026, so Edition 2023 is already two editions behind. The editions overview records that 2024 removedimport weak, and the editions guide that it addedexportandlocal. A file that saysedition = "2023"is therefore not the current edition. It is the first one, which is the edition most tools learned first and the one this page uses for that reason. ↩︎ Back to text -
The Protobuf style guide is one page. It asks for pluralized repeated fields, double quotes, two-space indents, and 80-character lines, and says nothing about a
Servicesuffix beyond using one in its own example. Two linters read that page and arrived at overlapping lists, each with rules the other lacks, which is what happens when a style guide is short enough to leave room. ↩︎ Back to text