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.
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 generateand 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-codestep in CI ensures that generated code is always committed and up to date. If someone changes a SQL query but forgets to re-rungo 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
- Write SQL, Get Go — sqlc turns queries into type-safe code
- Spec-First APIs — OpenAPI generators build clients and servers
- One Command —
go 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.