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.

Aleksandr Perederei Updated 12 min

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 maintainToolWhat it producesWhat you still own
SQL schema and named queriessqlcGo parameter types, result types, and query methodsQuery intent, permissions, migrations, indexes, transactions
An OpenAPI documentoapi-codegenModels, clients, or server interfaces, depending on configurationBusiness logic, authentication, validation, operational behavior
Commands that should run togethergo generateNo application code by itself; it invokes those commandsTool 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.

Get engineering articles in your inbox

Practical advice on system design, technical leadership, and career growth. No spam.

Book a free introduction

Bring your current challenge and where you want to go. We’ll discuss your next step and whether mentoring is a good fit.