How-to › Integrate beyond REST

How to define a gRPC service in a proto3 file#

Write a proto3 file with a versioned package, two messages, a unary service, and reserved numbers, then compile it with buf and lint it two ways.

Audience
API producer
Level
beginner
Topic
Integrate with gRPC and protobuf
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
buf lintYou use buf already and want package, naming, and RPC rules from one line of configSTANDARD adds opinions the style guide never had: Service suffixes, Request and Response names, an _UNSPECIFIED zero valueYou want a fixer, or rules for line length and quote style
Protobuf Editions 2023A fresh file, explicit presence by default, and a path to later editionsEvery generator in the build has to understand editions, and one that does not refuses the file outrightA plugin you depend on has not added editions support
proto3Every toolchain, every plugin, every tutorial you will findImplicit presence unless you write optional, so an unset integer and a zero are the same bytesMost fields need presence and you do not want a keyword on each
protolintYou want the official style guide enforced, with -fix for what it can rewriteA second binary and config, and default rules that stop at the style guide, so RPC and package conventions are yours to addbuf’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.

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

  1. 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 varint holding 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

  2. The version support page lists the syntax values a .proto file may declare as proto2, proto3, 2023, 2024, and 2026, so Edition 2023 is already two editions behind. The editions overview records that 2024 removed import weak, and the editions guide that it added export and local. A file that says edition = "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

  3. 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 Service suffix 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.