Go Code Generation with sqlc and OpenAPI: A Working Example
Generate Go SQL bindings and an OpenAPI client with sqlc and oapi-codegen. Follow a tested example, handle runtime errors, and catch stale generated code in CI.
A database column changes. Now someone has to update a query, the Go result type, and the arguments passed to Scan. An API response changes, and another developer updates a client by hand. These are good places to use a code generator: the information already exists, but the program needs it in another form.
For Go code generation, use sqlc to turn SQL into query methods, oapi-codegen to turn an OpenAPI document into Go types and an HTTP client, and go generate to run the commands. Keep the inputs, configuration, and tool versions together so another developer can reproduce the output.
The useful question is how much maintenance this removes, and which decisions remain yours. A generated client can compile and still mishandle a 404. A generated query can be correctly typed and still expose another customer’s data.
This walkthrough uses one small Task API. The complete example and tests are in the repository. It generates database bindings and a client; it does not implement an HTTP server or connect to a live database.
Choose the input before choosing the generator
| What you already maintain | Tool | What it produces | What you still own |
|---|---|---|---|
| SQL schema and named queries | sqlc | Go parameter types, result types, and query methods | Query intent, permissions, migrations, indexes, transactions |
| An OpenAPI document | oapi-codegen | Models, clients, or server interfaces, depending on configuration | Business logic, authentication, validation, operational behavior |
| Commands that should run together | go generate | No application code by itself; it invokes those commands | Tool installation, version pinning, execution order, CI checks |
If the schema is routinely out of date, generation will faithfully spread the wrong contract. Fix ownership of the input first. For a tiny integration with one stable endpoint, a small handwritten client may also be easier to maintain than the specification and generator configuration.
What go generate actually does
go generate runs commands declared in specially formatted comments. It is not automatically run by go build or go test, and it does not track dependencies between generator inputs and outputs. The Go command documentation describes the execution rules.
In the example, generate.go lives at the module root:
package taskgen
//go:generate sqlc generate
//go:generate oapi-codegen --config api/oapi-codegen.yaml api/openapi.yaml
Both commands run from the directory containing that file. This detail matters: moving the directive into internal/api without changing the paths will break the setup. The two directives in this file run in source order.
Run this from the example directory:
go generate ./...
go test ./...
The commands need to be installed and available on PATH. The example was tested with Go 1.26.8, sqlc 1.31.1, and oapi-codegen 2.8.0. Its README includes installation commands pinned to those generator releases; runtime dependencies are recorded in go.mod and go.sum. Use matching tool versions in CI. An unpinned installation of latest can change the generated diff even when the schema has not changed.
Generation executes programs, so review directives as you would a build script before running them from an unfamiliar repository.
Generate PostgreSQL query methods with sqlc
Start with three columns in db/schema.sql:
CREATE TABLE tasks (
id BIGINT PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
);
Write the query in db/queries.sql:
-- name: GetTask :one
SELECT id, title, done
FROM tasks
WHERE id = $1;
GetTask becomes the method name. The :one annotation tells sqlc that the query returns one row. Keeping the column list explicit also makes the query’s result easier to review when the table grows.
The module-root sqlc.yaml connects the inputs to the generated package:
version: "2"
sql:
- engine: "postgresql"
schema: "db/schema.sql"
queries: "db/queries.sql"
gen:
go:
package: "db"
out: "internal/db"
sql_package: "database/sql"
This example uses the standard database/sql interface. sqlc also supports pgx/v5; that is a configuration choice with consequences for generated types, particularly nullable values. See the sqlc configuration reference.
Run sqlc generate. The output includes internal/db/models.go, db.go, and queries.sql.go. Here is the generated method body:
func (q *Queries) GetTask(ctx context.Context, id int64) (Task, error) {
row := q.db.QueryRowContext(ctx, getTask, id)
var i Task
err := row.Scan(&i.ID, &i.Title, &i.Done)
return i, err
}
There is nothing unusual about the result: it runs the query and scans the row. The benefit is keeping those mechanically related pieces synchronized. Do not edit this method to change the query; edit the SQL and regenerate.
Try changing title to titel in the query while leaving the schema unchanged. In this example, sqlc rejects the missing column during generation. That is an error caught before compiling the application.
It will not catch a missing ownership check. WHERE id = $1 says nothing about which user may access the task. For a real application, decide where authorization belongs and test that boundary. Generating successfully also does not apply schema.sql to PostgreSQL or prove that your deployed database matches it. You still need migrations and database integration tests. The sqlc PostgreSQL tutorial shows the next step of connecting generated queries to a database.
Generate an OpenAPI client with oapi-codegen
Now describe the HTTP boundary. This is api/openapi.yaml:
openapi: "3.0.3"
info:
title: Task API
version: "1.0.0"
paths:
/tasks/{id}:
get:
operationId: getTask
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
responses:
"200":
description: A task
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
"404":
description: Task not found
components:
schemas:
Task:
type: object
required: [id, title, done]
properties:
id:
type: integer
format: int64
title:
type: string
done:
type: boolean
The operationId gives the generated operation its name. The response definitions say what the client expects to receive. Both deserve review as part of the API change.
The configuration in api/oapi-codegen.yaml asks for models and a client:
package: api
output: internal/api/client.gen.go
generate:
models: true
client: true
Because the directive runs from the module root, the output path lands in internal/api. We deliberately generate no server in this example. The project’s versioned documentation covers the other output options.
The generated api.Task and database db.Task remain different types. That is useful even though they currently have the same fields: a database change should not automatically become a public API change. Add an explicit mapping at that boundary when building the service. This is the same concern discussed in Go architecture and package boundaries.
A typed response still needs error handling
The generated client has a GetTaskWithResponse method. An HTTP error response can arrive without a Go transport error, so checking only err is insufficient.
The example’s FetchTask function handles three separate cases:
func FetchTask(ctx context.Context, client *api.ClientWithResponses, id int64) (api.Task, error) {
response, err := client.GetTaskWithResponse(ctx, id)
if err != nil {
return api.Task{}, fmt.Errorf("fetch task: %w", err)
}
if response.StatusCode() != http.StatusOK {
return api.Task{}, fmt.Errorf("fetch task: HTTP %d", response.StatusCode())
}
if response.JSON200 == nil {
return api.Task{}, fmt.Errorf("fetch task: expected a JSON response")
}
return *response.JSON200, nil
}
The complete file includes the imports. Create the client with api.NewClientWithResponses; pass a configured http.Client and a context deadline for real network calls. Whether to retry a failed request depends on the operation and its failure mode, not just the HTTP status.
There is another limit worth testing. The OpenAPI schema marks id, title, and done as required. With this generated client, a 200 application/json response containing {} still decodes into a Task with zero values. The companion test demonstrates it. A non-nil JSON200 is evidence that decoding succeeded, not proof that the response satisfies every schema constraint.
Decide whether to validate the response at runtime, enforce specific invariants in your application, or use contract tests against the provider. A generated Go type alone does not make that decision for you.
The example tests cover successful decoding, 404, an unexpected content type, malformed JSON, and missing required fields. They use a local httptest server. No external API or database is called.
Catch stale generated code in CI, including new files
For this project, commit the generated Go files alongside the SQL, OpenAPI document, configuration, and dependency files. That makes the generated API visible in review and lets consumers build without installing the generators.
After changing an input, regenerate and examine the diff. A generator upgrade should also be a deliberate change, with an output diff that reviewers can attribute to that upgrade.
A common CI check is git diff --exit-code. On its own, that misses newly generated, untracked files. The example uses this check-generated.sh instead:
#!/bin/sh
set -eu
cd "$(dirname "$0")"
go generate ./...
changes=$(git status --porcelain --untracked-files=all -- internal/db internal/api)
if [ -n "$changes" ]; then
printf '%s\n' 'Generated code differs from the committed version:' "$changes"
exit 1
fi
Run it in a clean checkout after installing the pinned tools, then run go test ./.... The check includes staged changes and untracked files in the generated directories. Keep those directories out of .gitignore.
This check compares regenerated output with what Git knows. It does not know whether an old output file should have been deleted: some generators leave obsolete files behind. Review removals when renaming queries, moving packages, or changing generation options.
To test the check itself, add an untracked Go file containing package api under internal/api. The check should fail. Removing it should restore a clean result. That small experiment is more useful than assuming a green pipeline detects every kind of drift.
What to review before merging generated code
Read the input diff first, then inspect what it changed in the generated API:
- Did a nullable column become non-nullable, or the reverse? Check the resulting Go type and how callers handle absence.
- Did an API field disappear or change meaning? A successful build does not establish compatibility with deployed consumers.
- Does a query enforce the intended data boundary? Include authorization cases in tests.
- Does the client handle unsuccessful responses, decoding failures, and deadlines?
- Can another developer reproduce the output with the recorded tools and dependencies?
AI assistance can help draft a query or specification. Review that input before treating it as authoritative. A repeatable generator will repeat a mistaken contract just as readily as a correct one.
Start with one boundary where duplicated maintenance is already causing mistakes. Keep the generation command small, commit its output, and test the behavior the generated types cannot establish. For more on choosing those tests, read Go Tests Are Permission to Change the System.
If you want help deciding where generation belongs in your own service, bring the schema, generated diff, and a difficult integration case to a software engineering mentoring session. You can also browse the Go articles and engineering mentoring resources.
About the author
Aleksandr Perederei is a Principal Engineer, former Staff Software Engineer, Engineering Manager, and CTO. He has mentored 120+ engineers on system design, technical leadership, promotion evidence, career direction, and stronger engineering judgment.
Related articles
Go Architecture: Start With Boundaries, Not a Framework
Go does not give you one official application framework. Start with clear package boundaries, a simple layered design, and only add architecture when it improves delivery.
GoUse Go for LLM Orchestration, Not Model Research
Go is a strong choice for production LLM orchestration, typed tool boundaries, and agent services. Python remains the practical choice for model research and evaluation.
GoGo Tests Are Permission to Change the System
The most valuable Go tests do more than raise coverage. They expose business assumptions, protect critical workflows, and make refactoring and AI-assisted changes safer.
Get engineering articles in your inbox
Practical advice on system design, technical leadership, and career growth. No spam.