Week 17: gRPC & Protocol Buffers for Service-to-Service Calls

Week 9's httpx calls between services used JSON over REST — readable, flexible, and a bit wasteful for high-frequency internal traffic where both ends are Python (or any language) you control. This week introduces gRPC: a binary, strongly-typed RPC framework built on HTTP/2 and Protocol Buffers, widely used for service-to-service communication where REST's flexibility isn't needed and its overhead genuinely costs something.

Module 14 of 22 Week 17 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Define a service contract in Protocol Buffers and generate Python code from it
  • Implement a gRPC server and call it from a Python client
  • Use a streaming RPC, and know when gRPC is actually worth choosing over REST

1. Defining a Contract with Protocol Buffers

Where a REST API's contract lives in Pydantic models and (loosely) in OpenAPI docs, a gRPC service's contract is a .proto file — a language-neutral schema that generates strongly-typed client and server code for Python, Go, Java, or any other supported language from the exact same source of truth.

inventory.proto
syntax = "proto3";

package inventory;

service InventoryService {
  rpc ReserveStock (ReserveRequest) returns (ReserveResponse);
}

message ReserveRequest {
  string sku = 1;
  int32 quantity = 2;
}

message ReserveResponse {
  string reservation_id = 1;
  bool success = 2;
}

The numbers after each field (= 1, = 2) aren't default values — they're each field's unique position in the message's binary wire format, which is how Protocol Buffers achieves both a compact binary encoding and safe schema evolution: a new field can be added with a new number without breaking old clients that don't know about it, as long as existing field numbers are never reused or renumbered.

terminal — generating Python code from the .proto file
python -m grpc_tools.protoc \
  -I. --python_out=. --grpc_python_out=. \
  inventory.proto

# generates inventory_pb2.py (message classes)
# and inventory_pb2_grpc.py (server/client stubs)
Never reuse or renumber a field's tag once it's shipped

The field number, not the field name, is what's actually encoded on the wire. Renumbering an existing field, or reusing a number a now-deleted field once had, makes an old message unparseable or silently misinterpreted by a client still expecting the original meaning. Deprecate a field by marking it reserved in the schema instead of ever reusing its number.

2. A gRPC Server & Client in Python

The generated _pb2_grpc.py module gives you a base class to implement for the server side, and a stub class the client uses to make calls that look like ordinary Python method calls, with real request/response types instead of untyped JSON dictionaries.

server.py
import grpc
from concurrent import futures
import inventory_pb2
import inventory_pb2_grpc

class InventoryService(inventory_pb2_grpc.InventoryServiceServicer):

    def ReserveStock(self, request, context):
        if not has_stock(request.sku, request.quantity):
            context.set_code(grpc.StatusCode.FAILED_PRECONDITION)
            context.set_details(f"Insufficient stock for {request.sku}")
            return inventory_pb2.ReserveResponse(success=False)

        reservation_id = create_reservation(request.sku, request.quantity)
        return inventory_pb2.ReserveResponse(
            reservation_id=reservation_id, success=True
        )

server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
inventory_pb2_grpc.add_InventoryServiceServicer_to_server(InventoryService(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
client.py — called from another Python service
import grpc
import inventory_pb2
import inventory_pb2_grpc

with grpc.insecure_channel("inventory-service:50051") as channel:
    stub = inventory_pb2_grpc.InventoryServiceStub(channel)
    response = stub.ReserveStock(
        inventory_pb2.ReserveRequest(sku="ABC123", quantity=2)
    )
    print(response.success, response.reservation_id)

Notice there's no manual JSON serialization, no URL string to get right, and no response schema to validate by hand — request.sku and response.reservation_id are typed attributes generated directly from the .proto file, and a typo in a field name is a Python AttributeError caught immediately, not a silently-wrong dictionary key discovered at runtime.

Use grpc.StatusCode, not a made-up error convention

gRPC has its own standard status code set (NOT_FOUND, FAILED_PRECONDITION, UNAUTHENTICATED, and others) analogous to HTTP status codes — use them via context.set_code() rather than inventing a custom error field inside your response message. Clients and tooling across every language already know how to handle standard gRPC status codes correctly.

3. Streaming RPCs & gRPC vs. REST

Beyond the simple request-response call from Section 2, gRPC supports streaming natively, built on HTTP/2's ability to multiplex multiple streams over one connection — no long-polling or WebSocket workaround required:

inventory.proto — a server-streaming RPC
service InventoryService {
  rpc WatchStockLevel (WatchRequest) returns (stream StockUpdate);
}

message WatchRequest { string sku = 1; }
message StockUpdate { string sku = 1; int32 quantity = 2; }
server.py — yielding a stream of updates
class InventoryService(inventory_pb2_grpc.InventoryServiceServicer):

    def WatchStockLevel(self, request, context):
        for update in stock_change_events(request.sku):
            yield inventory_pb2.StockUpdate(sku=request.sku, quantity=update.quantity)

A client consuming WatchStockLevel receives each StockUpdate as it's yielded, over the same long-lived connection, rather than polling a REST endpoint repeatedly or standing up a separate WebSocket server for the same purpose.

None of this makes gRPC a universal replacement for REST. REST's plain-text JSON is trivially debuggable with curl and readable in a browser network tab; gRPC's binary wire format needs dedicated tooling (grpcurl, a gRPC-aware client) to inspect. Browsers can't call a gRPC service directly without a proxy layer (gRPC-Web), which makes REST the natural choice for anything a browser or a public, loosely-coupled third party talks to directly. gRPC earns its place specifically for internal, service-to-service traffic you control on both ends, where the performance of binary serialization and native streaming outweigh REST's simplicity and universal tooling.

A hybrid architecture is normal, not a compromise

A real system commonly runs REST at its public edge — where browsers, mobile apps, and third parties connect — and gRPC for the internal service-to-service mesh behind that edge, where you control both ends and the traffic volume justifies the added tooling. Choosing one for the whole system by default, rather than per-boundary, is usually the wrong call either direction.

4. Hands-on Exercise

Hands-on

Replace one REST call with gRPC, and add a streaming endpoint

Convert one service-to-service call from Week 9 to gRPC and add a real streaming RPC.

Requirements:

  1. Write a .proto file defining a simple unary RPC equivalent to one REST call from your Week 9 microservice project, and generate the Python stubs.
  2. Implement the gRPC server and confirm a Python client can call it and receive a correctly-typed response.
  3. Deliberately send an invalid request and confirm the server returns a proper grpc.StatusCode the client can check, not just a generic error.
  4. Add a server-streaming RPC that yields several messages over time, and write a client that consumes them as they arrive rather than waiting for all of them.
  5. Write a short comparison: for the specific call you converted, is gRPC actually the better choice here, and why?
Hint

Install grpcurl to inspect and manually call your gRPC service from the terminal, the same way you'd use curl against a REST endpoint — it's the fastest way to confirm a service works correctly before writing a full client.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why must a Protocol Buffers field's number never be reused after that field is removed?

The field number, not its name, is what's actually encoded in the binary wire format. If a new field reuses a number a deleted field once had, an old message serialized under the original schema (or a client still running the old schema) can be misinterpreted as containing the new field's data, silently corrupting the value rather than failing loudly.

Q2

Why is a browser generally unable to call a gRPC service directly, unlike a REST API?

gRPC relies on HTTP/2 trailers and framing details that browsers' fetch/XHR APIs don't fully expose, and its binary Protocol Buffers payloads aren't natively readable the way JSON is. A browser client needs a proxy layer (gRPC-Web) translating between what the browser can actually send and a real gRPC connection — REST's plain HTTP and JSON need no such translation layer.

Q3

What kind of traffic is gRPC the strongest fit for, versus where REST remains the better default?

gRPC fits internal, service-to-service traffic where you control both ends and the volume justifies its binary serialization performance and native streaming support. REST remains the better default at a system's public edge — anything a browser, mobile app, or loosely-coupled third party talks to directly — where plain-text JSON's universal tooling and debuggability matter more than raw efficiency.