Go

Everybody Talks About AI -- Let's Talk About Code Generators in Go

Explore Go's powerful code generation ecosystem: sqlc for type-safe SQL, OpenAPI generators for API clients and servers, and go generate as the glue. Practical examples with OpenAI, Stripe, and more.

Aleksandr Perederei 2026-02-20 15 min

The Hype vs. the Reality

Every conference talk, every blog post, every Twitter thread — it’s all about AI writing your code. And yes, tools like Copilot and ChatGPT are genuinely useful. But here’s the thing: Go developers have been generating code automatically for over a decade, and the approach is fundamentally different from AI.

AI-generated code is probabilistic — it guesses what you want. Code generators in Go are deterministic — they read a schema, a spec, or a SQL query and produce type-safe, predictable, reviewable code every single time. No hallucinations. No drift. No surprises.

AI Code Generation:

  • Probabilistic output
  • Can hallucinate APIs
  • Requires manual review
  • Great for boilerplate & exploration

Schema-Based Generation:

  • Deterministic output
  • Type-safe by construction
  • Reproducible in CI/CD
  • Single source of truth

In this article, we’ll walk through three powerful code generation patterns I use in production every day: sqlc for database access, OpenAPI generators for API clients and servers, and go generate as the glue that ties it all together.

go generate — The Foundation

Before we dive into specific tools, let’s understand the mechanism that makes Go’s code generation ecosystem work: go generate.

It’s dead simple. You add a special comment to any .go file, and go generate runs the command:

// generate.go
package db

//go:generate sqlc generate
//go:generate mockgen -source=repository.go -destination=mock_repository.go
$ go generate ./...
# Scans all .go files for //go:generate comments
# Executes each command in order

The flow is: Schema / Spec (SQL, OpenAPI, Proto) -> go generate (runs the tool) -> Generator (sqlc, oapi-codegen) -> Go Code (type-safe, ready to use).

Key principle: Generated code is committed to the repo. It’s not generated at build time. This means every developer and CI run uses the exact same generated code — no surprises.

sqlc — Type-Safe SQL Without an ORM

If you’ve ever been frustrated by ORMs that hide the SQL, or by raw database/sql code that’s tedious and error-prone, sqlc is the answer. You write SQL. It generates Go.

How It Works

First, you define your schema and queries in plain SQL files:

-- schema.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    username   TEXT      NOT NULL UNIQUE,
    email      TEXT      NOT NULL UNIQUE,
    full_name  TEXT      NOT NULL,
    bio        TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE posts (
    id         BIGSERIAL PRIMARY KEY,
    author_id  BIGINT    NOT NULL REFERENCES users(id),
    title      TEXT      NOT NULL,
    content    TEXT      NOT NULL,
    status     TEXT      NOT NULL DEFAULT 'draft',
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- queries/users.sql

-- name: GetUser :one
SELECT id, username, email, full_name, bio, created_at, updated_at
FROM users
WHERE id = $1;

-- name: ListUsers :many
SELECT id, username, email, full_name, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;

-- name: CreateUser :one
INSERT INTO users (username, email, full_name, bio)
VALUES ($1, $2, $3, $4)
RETURNING *;

-- name: UpdateUserBio :exec
UPDATE users
SET bio = $2, updated_at = NOW()
WHERE id = $1;

-- name: SearchUsers :many
SELECT id, username, email, full_name
FROM users
WHERE username ILIKE $1 OR full_name ILIKE $1
ORDER BY username
LIMIT 20;

Then you configure sqlc:

# sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    queries: "queries/"
    schema: "schema.sql"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_empty_slices: true

Run it:

$ sqlc generate

And sqlc produces fully type-safe Go code:

// internal/db/users.sql.go (generated)
// Code generated by sqlc. DO NOT EDIT.

type User struct {
    ID        int64          `json:"id"`
    Username  string         `json:"username"`
    Email     string         `json:"email"`
    FullName  string         `json:"full_name"`
    Bio       pgtype.Text    `json:"bio"`
    CreatedAt time.Time      `json:"created_at"`
    UpdatedAt time.Time      `json:"updated_at"`
}

type CreateUserParams struct {
    Username string      `json:"username"`
    Email    string      `json:"email"`
    FullName string      `json:"full_name"`
    Bio      pgtype.Text `json:"bio"`
}

func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
    row := q.db.QueryRow(ctx, getUser, id)
    var i User
    err := row.Scan(
        &i.ID,
        &i.Username,
        &i.Email,
        &i.FullName,
        &i.Bio,
        &i.CreatedAt,
        &i.UpdatedAt,
    )
    return i, err
}

func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
    row := q.db.QueryRow(ctx, createUser,
        arg.Username,
        arg.Email,
        arg.FullName,
        arg.Bio,
    )
    var i User
    err := row.Scan(
        &i.ID,
        &i.Username,
        &i.Email,
        &i.FullName,
        &i.Bio,
        &i.CreatedAt,
        &i.UpdatedAt,
    )
    return i, err
}

Using the Generated Code

// service.go
type UserService struct {
    queries *db.Queries
}

func NewUserService(pool *pgxpool.Pool) *UserService {
    return &UserService{
        queries: db.New(pool),
    }
}

func (s *UserService) GetUser(ctx context.Context, id int64) (*db.User, error) {
    user, err := s.queries.GetUser(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return &user, nil
}

func (s *UserService) CreateUser(ctx context.Context, req CreateUserRequest) (*db.User, error) {
    user, err := s.queries.CreateUser(ctx, db.CreateUserParams{
        Username: req.Username,
        Email:    req.Email,
        FullName: req.FullName,
        Bio:      pgtype.Text{String: req.Bio, Valid: req.Bio != ""},
    })
    if err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    return &user, nil
}

Why sqlc is a game-changer:

  • You write real SQL — no learning a DSL or ORM magic
  • Compile-time safety — if your query doesn’t match the schema, sqlc fails
  • Zero runtime overhead — generated code is just plain Go
  • Catches bugs early — typos in column names are caught at generation time, not at 3 AM in production

OpenAPI Code Generation — API Clients and Servers

If you’re building or consuming REST APIs, an OpenAPI spec is your single source of truth. Instead of writing HTTP clients and request/response types by hand, let a generator do it.

oapi-codegen — The Go-Native Choice

oapi-codegen takes an OpenAPI 3.0 spec and generates Go types, server interfaces, and client code.

# api.yaml (OpenAPI spec)
openapi: "3.0.0"
info:
  title: User Service API
  version: "1.0.0"
paths:
  /users:
    get:
      operationId: listUsers
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: List of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/User"
    post:
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"

  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "200":
          description: User details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"

components:
  schemas:
    User:
      type: object
      required: [id, username, email, fullName, createdAt]
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
        email:
          type: string
        fullName:
          type: string
        bio:
          type: string
        createdAt:
          type: string
          format: date-time

    CreateUserRequest:
      type: object
      required: [username, email, fullName]
      properties:
        username:
          type: string
        email:
          type: string
        fullName:
          type: string
        bio:
          type: string
// generate.go
package api

//go:generate oapi-codegen --config=oapi-config.yaml api.yaml
# oapi-config.yaml
package: api
output: api_gen.go
generate:
  models: true
  chi-server: true    # generates a Chi router interface
  client: true        # generates an HTTP client

The generator produces a server interface that you implement:

// api_gen.go (generated -- server interface)

// ServerInterface represents all server handlers.
type ServerInterface interface {
    // (GET /users)
    ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams)
    // (POST /users)
    CreateUser(w http.ResponseWriter, r *http.Request)
    // (GET /users/{id})
    GetUser(w http.ResponseWriter, r *http.Request, id int64)
}

And you implement it with your business logic:

// handler.go
type Handler struct {
    userService *UserService
}

// Compile-time check: Handler implements ServerInterface
var _ api.ServerInterface = (*Handler)(nil)

func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request, params api.ListUsersParams) {
    limit := 20
    if params.Limit != nil {
        limit = *params.Limit
    }

    users, err := h.userService.ListUsers(r.Context(), limit, 0)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request, id int64) {
    user, err := h.userService.GetUser(r.Context(), id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req api.CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    user, err := h.userService.CreateUser(r.Context(), req)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

It also generates a type-safe client:

// client_usage.go
// Generated client -- call APIs with Go types, not raw HTTP
client, err := api.NewClientWithResponses("https://api.example.com")
if err != nil {
    log.Fatal(err)
}

// Type-safe parameters -- no string concatenation
limit := 10
resp, err := client.ListUsersWithResponse(ctx, &api.ListUsersParams{
    Limit: &limit,
})
if err != nil {
    log.Fatal(err)
}

// Type-safe response -- no manual JSON parsing
for _, user := range *resp.JSON200 {
    fmt.Printf("User: %s (%s)\n", user.Username, user.Email)
}

The beauty: When the API spec changes, you re-run go generate and the compiler immediately tells you what’s broken. No runtime surprises. No integration test failures at midnight.

SDK Generation — OpenAI, Stripe, and Beyond

The same principle applies when consuming third-party APIs. Many companies now publish OpenAPI specs for their APIs, which means you can generate Go clients automatically instead of writing HTTP boilerplate by hand — or relying on community-maintained libraries that might lag behind the API.

Example: Building an OpenAI Client

OpenAI publishes their API spec. You can use oapi-codegen or openapi-generator to create a typed Go client:

# Download the OpenAI spec
$ curl -o openai-api.yaml https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml

# Generate Go client
$ oapi-codegen --package openai \
    --generate types,client \
    -o internal/openai/client_gen.go \
    openai-api.yaml

Now you have a fully typed client with no manual HTTP code:

// ai_service.go
type AIService struct {
    client *openai.ClientWithResponses
}

func NewAIService(apiKey string) (*AIService, error) {
    client, err := openai.NewClientWithResponses(
        "https://api.openai.com/v1",
        openai.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
            req.Header.Set("Authorization", "Bearer "+apiKey)
            return nil
        }),
    )
    if err != nil {
        return nil, err
    }
    return &AIService{client: client}, nil
}

func (s *AIService) Chat(ctx context.Context, prompt string) (string, error) {
    model := "gpt-4"
    resp, err := s.client.CreateChatCompletionWithResponse(ctx,
        openai.CreateChatCompletionRequest{
            Model: model,
            Messages: []openai.ChatCompletionMessage{
                {
                    Role:    "user",
                    Content: prompt,
                },
            },
        },
    )
    if err != nil {
        return "", fmt.Errorf("chat completion: %w", err)
    }

    if resp.JSON200 == nil {
        return "", fmt.Errorf("unexpected status: %d", resp.StatusCode())
    }

    return resp.JSON200.Choices[0].Message.Content, nil
}

Why Generate Instead of Using an SDK?

Generated Client:

  • Always matches the API spec
  • No dependency on a third-party SDK
  • You control the code
  • Update by re-running the generator
  • Minimal dependencies

Official SDK:

  • May add convenience wrappers
  • Can lag behind API changes
  • Larger dependency tree
  • Opinionated error handling
  • Harder to customize

My approach: For stable, well-maintained SDKs (like Stripe’s official Go library), I use the SDK. For fast-moving APIs (like OpenAI, where the spec changes frequently) or for internal services, I generate clients from the spec. Best of both worlds.

Putting It All Together

Here’s what a real project structure looks like when you embrace code generation:

myapp/
├── api/
│   ├── openapi.yaml          # API spec -- the source of truth
│   ├── oapi-config.yaml      # Generator config
│   └── generate.go           # //go:generate oapi-codegen ...

├── db/
│   ├── schema.sql            # Database schema
│   ├── queries/
│   │   ├── users.sql         # SQL queries
│   │   └── posts.sql
│   ├── sqlc.yaml             # sqlc config
│   └── generate.go           # //go:generate sqlc generate

├── internal/
│   ├── api/
│   │   └── api_gen.go        # Generated API types + server + client
│   ├── db/
│   │   ├── models.go         # Generated DB models
│   │   ├── users.sql.go      # Generated query methods
│   │   └── posts.sql.go
│   ├── openai/
│   │   └── client_gen.go     # Generated OpenAI client
│   └── handler/
│       └── handler.go        # Your business logic (not generated)

├── Makefile
└── go.mod
# Makefile
generate:
	go generate ./...

lint:
	sqlc vet
	go vet ./...

ci: generate lint test
	# Verify no diff in generated code
	git diff --exit-code internal/

CI Guard: Catch Stale Generated Code. The git diff --exit-code step in CI ensures that generated code is always committed and up to date. If someone changes a SQL query but forgets to re-run go generate, the CI pipeline fails.

Other Generators Worth Knowing

  • protoc-gen-go — Generates Go code from Protocol Buffer definitions. Essential for gRPC services.
  • mockgen — Generates mock implementations of Go interfaces. Invaluable for unit testing.
  • stringer — Generates String() methods for integer constants (enums). Ships with the Go tools.
  • enumer — Like stringer, but also generates JSON marshaling, validation, and enum iteration.
  • ent — A powerful entity framework that generates the entire data access layer from a Go schema.

Conclusion

While everyone is talking about AI-generated code, Go’s code generation ecosystem has been quietly solving real problems for years. The approach is different — it’s deterministic, reproducible, and type-safe — but the outcome is the same: less boilerplate, fewer bugs, more time for business logic.

Key Takeaways

  1. Write SQL, Get Go — sqlc turns queries into type-safe code
  2. Spec-First APIs — OpenAPI generators build clients and servers
  3. One Commandgo generate ./... runs everything

My advice: use AI tools where they shine (exploration, boilerplate, prototyping), but reach for deterministic code generators when you need reliability, reproducibility, and type safety in your production systems. They complement each other perfectly.

References

Get engineering articles in your inbox

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

Book Your Growth Session

Let's identify your #1 skill gap and create a 90-day learning plan to level up your engineering abilities.

Powered by Cal.com - No account required