Get a quote

Type-Safe SQL and gRPC in Production Go Backends: How sqlc and Protobuf Reduce Runtime Bugs

A Go backend that has been in production for six months almost always has two recurring bug classes: SQL queries that break when a column changes, and internal service calls that fail because someone changed a response shape without updating the caller. sqlc and protobuf solve both problems at compile time rather than at runtime.

A Go backend that has been in production for six months almost always has two recurring bug classes: SQL queries that break when a column changes, and internal service calls that fail because someone changed a response shape without updating the caller. sqlc and protobuf solve both problems at compile time rather than at runtime. This is how we structure production Go backends at Voxire for SaaS products built in Lebanon and across the MENA region.

Why raw SQL strings in Go become a production liability

The standard pattern for database access in Go without a code generator is writing SQL as string literals and scanning results manually:

rows, err := db.QueryContext(ctx,
    "SELECT id, email, created_at FROM users WHERE tenant_id = $1",
    tenantID,
)
for rows.Next() {
    var id uuid.UUID
    var email string
    var createdAt time.Time
    rows.Scan(&id, &email, &createdAt)
}

This works until a schema migration renames created_at to registered_at. The query breaks at runtime, not at build time. The bug surfaces the first time that code path executes in production.

The second failure mode is scan order coupling. rows.Scan must list arguments in the same order as the SELECT columns. Reordering SELECT columns produces a runtime panic or silent data corruption, not a compiler warning.

For a single developer on a small codebase this is manageable. For a team of three to five engineers shipping features on a live SaaS product, it is a recurring source of production incidents that all follow the same pattern: schema change, missed grep, silent runtime failure, on-call alert.

How sqlc works and what it generates

sqlc takes three inputs: a YAML configuration file, SQL schema files, and SQL query files. It outputs typed Go code: structs that match table shapes, and typed functions for each named query.

The configuration file:

version: "2"
sql:
  - engine: "postgresql"
    queries: "internal/db/queries/"
    schema:  "internal/db/schema/"
    gen:
      go:
        package: "db"
        out:     "internal/db/generated/"
        emit_json_tags:       true
        emit_prepared_queries: true

A query file defines named queries:

-- name: GetTenantUsers :many
SELECT id, email, created_at
FROM users
WHERE tenant_id = @tenant_id
ORDER BY created_at DESC
LIMIT @limit;

sqlc generates:

type GetTenantUsersParams struct {
    TenantID uuid.UUID
    Limit    int32
}

type GetTenantUsersRow struct {
    ID        uuid.UUID
    Email     string
    CreatedAt time.Time
}

func (q *Queries) GetTenantUsers(
    ctx context.Context,
    arg GetTenantUsersParams,
) ([]GetTenantUsersRow, error) { ... }

If a schema migration renames created_at, sqlc regeneration fails to compile. The bug surfaces before the code ships.

Structuring sqlc for a multi-tenant SaaS backend

In a multi-tenant SaaS backend, sqlc makes tenant isolation auditable. Every query that returns data must include tenant_id in its WHERE clause. With raw SQL strings, this is enforced only through code review discipline. With sqlc, generated function signatures make it visible at a glance whether the tenant_id parameter is present.

A useful convention: separate query files by domain. users.sql, orders.sql, inventory.sql. Each file contains only queries for that domain. When reviewing a diff, a query without a tenant_id filter stands out because every other query in the file has one.

The double-filter pattern for row-level multi-tenant isolation:

-- name: GetOrder :one
SELECT id, tenant_id, status, total_cents, created_at
FROM orders
WHERE id = @id AND tenant_id = @tenant_id;

Both filters are required in the generated function signature. A handler that passes the wrong tenant_id gets no result rather than leaking another tenant's data. PostgreSQL row security policies add another enforcement layer at the database level.

For SaaS products serving Lebanese businesses and Gulf enterprises from the same backend, this two-layer enforcement matters practically. Compliance questions from larger customers often arrive before you expect them.

When gRPC makes sense and when REST is the right choice

gRPC is worth the setup overhead when services communicate at high frequency, when you want compile-time contract enforcement across service boundaries, or when you have multiple consumers of an internal API in different languages.

For most SaaS products in Lebanon and the MENA region at early scale, REST with OpenAPI specs serves internal service communication better until you have three or more services calling each other frequently. gRPC adds toolchain overhead: proto files, generated stubs, gRPC server setup, and a different debugging model.

The cases where gRPC consistently pays off in production systems:

  • Worker services processing queue items and calling back to a core API at high frequency
  • POS terminal backends communicating with a central sync service using bi-directional streaming
  • Reporting aggregation services querying multiple internal services where protobuf payload reduction matters at volume

For CRUD APIs serving a frontend or a third-party integration, REST is almost always the right choice. gRPC toolchain complexity is not justified by performance gains at typical frontend call volumes.

Protobuf schema design for internal services

Protobuf messages are forward-compatible: adding fields does not break existing consumers, removing fields they use does. This asymmetry shapes proto file design for production services.

A proto file for a tenant order service:

syntax = "proto3";
package orders.v1;

message GetOrderRequest {
  string tenant_id = 1;
  string order_id  = 2;
}

message Order {
  string id          = 1;
  string tenant_id   = 2;
  string status      = 3;
  int64  total_cents = 4;
  string created_at  = 5;
}

message GetOrderResponse {
  Order order = 1;
}

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
}

Use string for IDs and timestamps. It simplifies client code and avoids cross-language type mapping issues. Use int64 for monetary values in the smallest unit (cents, fils, piastres) rather than floats. Float arithmetic on money is a production bug waiting to happen.

Version packages from day one: orders.v1, not orders. When a breaking change is needed, add orders.v2 and migrate consumers. The version suffix makes dual-version operation during migration explicit in the codebase.

Running codegen in CI

Both sqlc and protobuf codegen should run in CI, not as a manual developer step. The generated code should be committed to the repository. CI verifies that committed generated code matches what would be generated from the current schema and proto files.

A GitHub Actions step that catches drift:

- name: Verify sqlc generated code
  run: |
    sqlc generate
    git diff --exit-code internal/db/generated/

If generated code is out of sync with the schema, the diff is non-empty and the step fails. This prevents the common failure of someone updating a schema file without regenerating code, only for the mismatch to surface at runtime.

The same pattern for protobuf:

- name: Verify protobuf generated code
  run: |
    buf generate
    git diff --exit-code internal/pb/

Running codegen in CI adds 10 to 30 seconds to build time. The tradeoff is eliminating a class of runtime bugs that cost hours to diagnose when they surface in production.

Tradeoffs and what you give up

sqlc requires the database schema to be the source of truth. If your team is accustomed to building domain models in Go first and generating the schema from structs (the ORM approach), sqlc inverts that workflow. Some engineers find this adjustment takes a sprint to internalize.

sqlc also does not support highly dynamic queries where the WHERE clause changes based on runtime conditions. You end up writing multiple query variants or using string SQL for those specific cases. For most SaaS query patterns this limitation is minor, but it exists.

gRPC requires all service consumers to have protobuf stubs. A JavaScript frontend cannot call gRPC directly without a gRPC-web proxy or a translation layer. For MENA SaaS products where the frontend is typically React or Next.js, gRPC is an internal service communication tool. The client-facing API stays REST.

The overall assessment: sqlc with protobuf and gRPC for internal services is a production-grade architecture for Go SaaS backends beyond the prototype stage. The setup overhead is real but front-loaded. The reduction in runtime bugs and the ability to refactor schema and proto contracts safely pays that overhead back quickly in systems under active development by a team.


Key lessons from production

sqlc eliminates scan-order bugs entirely and makes tenant isolation auditable in code review. Adopt it when the project moves beyond prototype.

gRPC pays off for high-frequency internal service communication and bi-directional streaming. For REST-facing APIs, stick with OpenAPI.

Version proto packages from day one. Migrating an unversioned proto mid-project is more disruptive than adding the suffix from the start.

Run codegen in CI and commit the output. Drift between schema and generated code is a production liability that CI should catch, not an on-call engineer at 2am.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

Voxire designs and builds production Go backends for SaaS teams in Lebanon and across the MENA region, including sqlc setup, gRPC service architecture, and multi-tenant data layers. If your backend is growing faster than your team can safely maintain it, we can help you get the architecture right.

https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp