# 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.

Source: https://voxgig.com/howto/define-a-grpc-service-in-proto3

- Audience: api-producer
- Level: beginner
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://buf.build/docs/reference/cli/buf/build/), 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](https://protobuf.dev/programming-guides/proto3/), and the four method shapes
in the gRPC [core concepts](https://grpc.io/docs/what-is-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](https://buf.build/docs/lint/rules/) | 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](https://protobuf.dev/editions/overview/) | 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](https://protobuf.dev/programming-guides/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](https://github.com/yoheimuta/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.

```proto title="proto/inventory/v1/inventory.proto"
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](https://protobuf.dev/reference/go/go-generated/) requires an import path for
every file it touches, from `go_package` or from an `M` flag on the command line. The
[Java generator](https://protobuf.dev/reference/java/java-generated/) 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.

```proto title="proto/inventory/v1/inventory.proto"
// 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.

```bash
npx buf build proto && echo "proto3 module compiles"
```

```text output
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.

```bash
node presence.mjs
```

```text output
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](https://protobuf.dev/programming-guides/encoding/) 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.

```proto title="broken/inventory/v1/inventory.proto"
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;
}
```

```bash
npx buf build broken
```

```text output
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](https://protobuf.dev/editions/features/) that have a default per edition.
Presence is the one that shows in a file this size.

```proto title="editions/inventory/v1/inventory.proto"
edition = "2023";

package inventory.v1;
```

```proto title="editions/inventory/v1/inventory.proto"
  // 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;
```

```bash
npx buf build editions && echo "editions module compiles"
```

```text output
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](https://protobuf.dev/editions/implementation/) 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.

```bash
npx buf lint style
```

```text output
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".
```

```bash
npx protolint lint style
```

```text output
[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](https://protobuf.dev/programming-guides/style/). 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

```bash
node --test proto.test.mjs
```

```text output
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.

```bash
npx buf build nopkg && npx buf lint nopkg && echo "compiles, and lints clean"
```

```text output
compiles, and lints clean
```

Add the options to every file. If buf generates your code, its
[managed mode](https://buf.build/docs/generate/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

- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

- [Choose consumer-driven or spec-driven contract tests](/howto/choose-consumer-driven-or-spec-driven-contract-tests)

## 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.

[^1]: The largest field number is 536,870,911, which is two to the power of 29, less one. The
[encoding guide](https://protobuf.dev/programming-guides/encoding/) 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](https://protobuf.dev/programming-guides/proto3/) 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.

[^2]: The [version support](https://protobuf.dev/support/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](https://protobuf.dev/editions/overview/)
records that 2024 removed `import weak`, and the
[editions guide](https://protobuf.dev/programming-guides/editions/) 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.

[^3]: The Protobuf [style guide](https://protobuf.dev/programming-guides/style/) 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.