Skip to content

Writing Plugins

Protoc plugins generate additional files from your Protobuf schema. Running alongside protoc-gen-py, your plugin can generate RPC interfaces, database mappings, validation helpers, or any other code that builds on the message types protoc-gen-py produces.

Plugins are implemented as simple executables, named protoc-gen-<name> by convention. They receive the schema via stdin and return generated files via stdout.

Hello world

Create a file named protoc-gen-hello.py:

protoc-gen-hello.py
#!/usr/bin/env python3
from protobuf.plugin import Schema, run

def generate(schema: Schema) -> None:
    for desc in schema.files_to_generate:
        f = schema.generate_file(desc, "_hello.py")
        f.preamble(desc)
        f.print('print(f"Hello! Here is your descriptor:\\n{', desc, '.proto.to_json()}")')

run("protoc-gen-hello", "0.1.0", generate)

Make it executable and confirm it responds to --version:

chmod +x protoc-gen-hello.py
./protoc-gen-hello.py --version
protoc-gen-hello v0.1.0

Add the plugin to your buf.gen.yaml:

buf.gen.yaml
version: v2
inputs:
  - directory: proto
plugins:
  - local: protoc-gen-py
    out: src/gen
  - local: protoc-gen-hello.py
    out: src/gen

Run buf generate. For each .proto file, a _hello.py file appears alongside the _pb.py file:

src/gen/
└── example/
    └── v1/
        ├── example_pb.py     # generated by protoc-gen-py
        └── example_hello.py  # generated by protoc-gen-hello

example_hello.py contains the preamble header followed by your generated code:

# Generated from example/v1/example.proto. DO NOT EDIT.
# Generated by protoc-gen-hello v0.1.0 with parameter "".
# ruff: noqa: PGH004
# ruff: noqa
# fmt: off

from __future__ import annotations

from . import example_pb


print(f"Hello! Here is your descriptor:\n{example_pb.proto.to_json()}")

The framework automatically imports example_pb because desc was passed directly to f.print().

A complete example

Building on the hello world structure, here is a plugin that generates a typed Protocol for each service's unary RPCs. Given a service like this:

// user/v1/user.proto
service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc ListUsers(ListUsersRequest) returns (stream ListUsersResponse);
}

A plugin that generates a typed Protocol for each unary RPC:

protoc-gen-rpc.py
#!/usr/bin/env python3
from protobuf.plugin import Ident, Module, Schema, run

_PROTOCOL = Module("typing").ident("Protocol")

def generate(schema: Schema) -> None:
    for desc in schema.files_to_generate:
        f = schema.generate_file(desc, "_rpc.py")
        f.preamble(desc)
        for service in desc.services:
            with f.scope("class ", f.ident(service.name), "(", _PROTOCOL, "):"):
                for method in service.methods:
                    if method.method_kind != "unary":
                        continue  # skip streaming RPCs
                    request = Ident.for_desc(method.input, type_only=True)
                    response = Ident.for_desc(method.output, type_only=True)
                    with f.scope("def ", method.name, "(self, request: ", request, ") -> ", response, ":"):
                        f.print("...")

run("protoc-gen-rpc", "0.1.0", generate)

Ident.for_desc() takes any message, enum, or extension descriptor and returns a reference to the corresponding symbol in the _pb file protoc-gen-py generates. See Generating Files for details.

Add both plugins to buf.gen.yaml:

buf.gen.yaml
version: v2
inputs:
  - directory: proto
plugins:
  - local: protoc-gen-py   # generates _pb files with message classes
    out: src/gen
  - local: protoc-gen-rpc.py  # generates _rpc files referencing those classes
    out: src/gen

Run buf generate. Both plugins receive the same proto schema simultaneously. For user/v1/user.proto this produces src/gen/user/v1/user_rpc.py:

# Generated from user/v1/user.proto. DO NOT EDIT.
# Generated by protoc-gen-rpc v0.1.0 with parameter "".
# ruff: noqa: PGH004
# ruff: noqa
# fmt: off

from __future__ import annotations

from typing import Protocol, TYPE_CHECKING

if TYPE_CHECKING:
    from .user_pb import CreateUserRequest, CreateUserResponse, GetUserRequest, GetUserResponse


class UserService(Protocol):
    def GetUser(self, request: GetUserRequest) -> GetUserResponse:
        ...
    def CreateUser(self, request: CreateUserRequest) -> CreateUserResponse:
        ...

ListUsers is skipped because it is a streaming RPC. Users of the generated file implement the Protocol and import message types from the _pb file normally:

from gen.user.v1.user_pb import GetUserRequest, GetUserResponse
from gen.user.v1.user_rpc import UserService

class InMemoryUserService(UserService):
    def GetUser(self, request: GetUserRequest) -> GetUserResponse:
        ...

Note

method.name is PascalCase as written in the proto. The examples/plugin/ directory shows a complete version of this plugin with snake_case conversion, docstrings, and forwarded proto comments.

Schema and descriptors

The Schema passed to your generate callback is the entry point:

schema.files_to_generate   # Sequence[DescFile] — files buf was asked to generate
schema.all_files           # Sequence[DescFile] — all files including transitive imports
schema.options             # parsed plugin options (see Options)

Always iterate files_to_generate, not all_files; the latter includes imported dependencies that the user did not explicitly ask you to generate.

For the full descriptor API, see Descriptors.

Creating output files

schema.generate_file() has two forms:

# Derive the output path from a DescFile + suffix:
# "user/v1/user.proto" + "_rpc.py" → "user/v1/user_rpc.py"
f = schema.generate_file(desc, "_rpc.py")

# Arbitrary output path:
f = schema.generate_file("my_package/__init__.py")

Note

If generate_file() is never called for a given file, it will not appear in the output. A File returned by generate_file() but with nothing printed will still be emitted.

Next steps