Generate a Go Server From an OpenAPI Spec Step by Step
Generate a Go net/http server from an OpenAPI file with oapi-codegen: config, the interface, validation middleware and what generated code leaves unchecked.

Generating a server from an OpenAPI document trades a little flexibility for a contract that cannot silently diverge. The document says what the routes, parameters and bodies are; the generator writes the Go types and the routing glue; you write only the behavior behind each operation. If the document changes and the code does not, the build fails.
Tested with Go 1.25.6, oapi-codegen v2.8.1, kin-openapi v0.152.0, macOS on Apple Silicon (arm64)
This article does it end to end with oapi-codegen, the most widely used generator for Go, targeting the standard library's net/http router. It writes the spec, generates the code, implements the interface, adds request validation, and then runs the server twice to show what the generated code enforces and what it does not, which is the part tutorials skip. It follows the comparison of code-first and spec-first and leads into generating a typed client. Everything ran on Go 1.25.6 with oapi-codegen v2.8.1.
The document
openapi: 3.0.3
info:
title: Tasks
version: 1.0.0
paths:
/v1/tasks:
get:
operationId: listTasks
parameters:
- name: limit
in: query
schema: {type: integer, minimum: 1, maximum: 100, default: 20}
- name: status
in: query
schema: {$ref: '#/components/schemas/Status'}
responses:
'200':
description: a page of tasks
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
type: array
items: {$ref: '#/components/schemas/Task'}
post:
operationId: createTask
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/NewTask'}
responses:
'201':
description: created
content:
application/json:
schema: {$ref: '#/components/schemas/Task'}
default:
$ref: '#/components/responses/Problem'
/v1/tasks/{id}:
parameters:
- name: id
in: path
required: true
schema: {type: integer, format: int64, minimum: 1}
get:
operationId: getTask
responses:
'200':
description: the task
content:
application/json:
schema: {$ref: '#/components/schemas/Task'}
'404':
$ref: '#/components/responses/Problem'
delete:
operationId: deleteTask
responses:
'204': {description: deleted}
'404':
$ref: '#/components/responses/Problem'
components:
responses:
Problem:
description: an error
content:
application/problem+json:
schema: {$ref: '#/components/schemas/Problem'}
schemas:
Status:
type: string
enum: [todo, doing, done]
NewTask:
type: object
additionalProperties: false
required: [title]
properties:
title: {type: string, minLength: 1, maxLength: 120}
status: {$ref: '#/components/schemas/Status'}
Task:
type: object
required: [id, title, status]
properties:
id: {type: integer, format: int64}
title: {type: string}
status: {$ref: '#/components/schemas/Status'}
Problem:
type: object
required: [title, status]
properties:
title: {type: string}
status: {type: integer}
detail: {type: string}The API is the same task list as the earlier articles: list with a limit and a status filter, create, get and delete. Four choices in the document shape the generated code.
Statusis a named schema with anenum, so the generator emits a Go type with constants and aValidmethod instead of a bare string.limithasminimum,maximumand adefault. These are in the document, and, as the run shows, they are enforced by the validator, not by generated parsing.NewTaskhasadditionalProperties: falseandminLength: 1on the title, constraints that Go struct types cannot express.- Errors use one shared
Problemresponse with theapplication/problem+jsonmedia type, referenced from several operations, in the shape of problem details.
The generator config
package: main
output: api.gen.go
generate:
std-http-server: true
models: true
embedded-spec: trueFour lines of configuration. package: main places the output in the same package as the handlers. generate.std-http-server asks for routing for the standard library's ServeMux, which uses the method-and-wildcard patterns from Go 1.22 (the stdlib router). The same tool also supports chi, gin, echo and others, and you pick one. models writes the schema types and embedded-spec embeds the document in the binary, which is what the validator reads at run time.
Generate
The command pins the generator's version, which is what makes the output reproducible.
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/[email protected] -config cfg.yaml spec.yaml
wc -l api.gen.go
sed -n '/^type ServerInterface/,/^}/p' api.gen.go
grep -n "^type .* struct" api.gen.go | head -8 448 api.gen.go
type ServerInterface interface {
// (GET /v1/tasks)
ListTasks(w http.ResponseWriter, r *http.Request, params ListTasksParams)
// (POST /v1/tasks)
CreateTask(w http.ResponseWriter, r *http.Request)
// (DELETE /v1/tasks/{id})
DeleteTask(w http.ResponseWriter, r *http.Request, id int64)
// (GET /v1/tasks/{id})
GetTask(w http.ResponseWriter, r *http.Request, id int64)
}
type ServerInterfaceWrapper struct {
Handler ServerInterface
HandlerMiddlewares []MiddlewareFunc
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}
45:type NewTask struct {
51:type Problem struct {
61:type Task struct {
68:type ListTasksParams struct {
93:type ServerInterfaceWrapper struct {
213:type UnescapedCookieParamError struct {
226:type UnmarshalingParamError struct {
239:type RequiredParamError struct {The generated file is 448 lines. Its important parts:
- Models.
Task,NewTask,Problem,Statusand the constantsTodo,Doing,Done. The fieldidbecameId, because the generator follows Go's exported-name rule without the initialism handlingIDthat a person would use, and optional properties such asNewTask.Statusare pointers (*Status), so absence is representable. ListTasksParams. A struct withLimit *intandStatus *Status, filled from the query string by the wrapper.ServerInterface. One method per operation, with parameters already parsed:GetTask(w, r, id int64). A path segment that is not an integer is rejected before your method runs.ServerInterfaceWrapperandHandlerWithOptions. The glue that registersGET /v1/tasks/{id}and friends on the mux and binds parameters.GetSwagger. The embedded document, loaded for the validator.
The behavior
package main
import (
"encoding/json"
"net/http"
"sort"
"sync"
)
// Server implements the generated ServerInterface. The generated code parses and type-checks parameters;
// this file holds only behavior.
type Server struct {
mu sync.Mutex
next int64
tasks map[int64]Task
}
func NewServer() *Server { return &Server{next: 1, tasks: map[int64]Task{}} }
var _ ServerInterface = (*Server)(nil)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func problem(w http.ResponseWriter, status int, title string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(Problem{Title: title, Status: status})
}
func (s *Server) ListTasks(w http.ResponseWriter, r *http.Request, params ListTasksParams) {
limit := 20
if params.Limit != nil {
limit = *params.Limit
}
s.mu.Lock()
out := []Task{}
for _, t := range s.tasks {
if params.Status == nil || t.Status == *params.Status {
out = append(out, t)
}
}
s.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Id < out[j].Id })
if len(out) > limit {
out = out[:limit]
}
writeJSON(w, 200, struct {
Data []Task `json:"data"`
}{out})
}
func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request) {
var in NewTask
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
problem(w, 400, "malformed body")
return
}
status := Todo
if in.Status != nil {
status = *in.Status
}
s.mu.Lock()
t := Task{Id: s.next, Title: in.Title, Status: status}
s.tasks[t.Id] = t
s.next++
s.mu.Unlock()
w.Header().Set("Location", "/v1/tasks/"+itoa(t.Id))
writeJSON(w, 201, t)
}
func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, id int64) {
s.mu.Lock()
t, ok := s.tasks[id]
s.mu.Unlock()
if !ok {
problem(w, 404, "task not found")
return
}
writeJSON(w, 200, t)
}
func (s *Server) DeleteTask(w http.ResponseWriter, r *http.Request, id int64) {
s.mu.Lock()
_, ok := s.tasks[id]
delete(s.tasks, id)
s.mu.Unlock()
if !ok {
problem(w, 404, "task not found")
return
}
w.WriteHeader(204)
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
var b []byte
for ; n > 0; n /= 10 {
b = append([]byte{byte('0' + n%10)}, b...)
}
return string(b)
}Server implements the interface with an in-memory map. The line var _ ServerInterface = (*Server)(nil) is the compile-time check: add an operation to the document, regenerate, and the build breaks until you implement it. That is the main benefit of the whole approach, and it costs one line.
Handlers use the generated types for their results. They return bodies with Task and Problem, so a field renamed in the document is a compile error here, not a surprise in production. They do not validate beyond what the types give them. That is the next point.
Wiring and running it twice
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
nethttpmiddleware "github.com/oapi-codegen/nethttp-middleware"
)
func handler(validate bool) http.Handler {
spec, err := GetSwagger()
if err != nil {
panic(err)
}
spec.Servers = nil // match any host, including the test server's
opts := StdHTTPServerOptions{BaseRouter: http.NewServeMux()}
if validate {
opts.Middlewares = []MiddlewareFunc{nethttpmiddleware.OapiRequestValidator(spec)}
}
return HandlerWithOptions(NewServer(), opts)
}
func main() {
for _, validate := range []bool{false, true} {
srv := httptest.NewServer(handler(validate))
fmt.Printf("== validation middleware: %v\n", validate)
do := func(method, path, body string) {
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
out := strings.TrimSpace(string(b))
if len(out) > 90 {
out = out[:90] + "…"
}
fmt.Printf("%-6s %-24s %d %s\n", method, path, res.StatusCode, out)
}
do("POST", "/v1/tasks", `{"title":"write"}`)
do("POST", "/v1/tasks", `{"title":"review","status":"doing"}`)
do("GET", "/v1/tasks?status=doing", "")
do("GET", "/v1/tasks/1", "")
do("GET", "/v1/tasks/99", "")
do("POST", "/v1/tasks", `{"title":""}`)
do("POST", "/v1/tasks", `{"title":"x","status":"urgent"}`)
do("POST", "/v1/tasks", `{"title":"x","colour":"red"}`)
do("GET", "/v1/tasks?limit=1000", "")
do("GET", "/v1/tasks/abc", "")
do("DELETE", "/v1/tasks/1", "")
do("DELETE", "/v1/tasks/1", "")
srv.Close()
fmt.Println()
}
}== validation middleware: false
POST /v1/tasks 201 {"id":1,"status":"todo","title":"write"}
POST /v1/tasks 201 {"id":2,"status":"doing","title":"review"}
GET /v1/tasks?status=doing 200 {"data":[{"id":2,"status":"doing","title":"review"}]}
GET /v1/tasks/1 200 {"id":1,"status":"todo","title":"write"}
GET /v1/tasks/99 404 {"status":404,"title":"task not found"}
POST /v1/tasks 201 {"id":3,"status":"todo","title":""}
POST /v1/tasks 201 {"id":4,"status":"urgent","title":"x"}
POST /v1/tasks 201 {"id":5,"status":"todo","title":"x"}
GET /v1/tasks?limit=1000 200 {"data":[{"id":1,"status":"todo","title":"write"},{"id":2,"status":"doing","title":"review…
GET /v1/tasks/abc 400 Invalid format for parameter id: error binding string parameter: strconv.ParseInt: parsing…
DELETE /v1/tasks/1 204
DELETE /v1/tasks/1 404 {"status":404,"title":"task not found"}
== validation middleware: true
POST /v1/tasks 201 {"id":1,"status":"todo","title":"write"}
POST /v1/tasks 201 {"id":2,"status":"doing","title":"review"}
GET /v1/tasks?status=doing 200 {"data":[{"id":2,"status":"doing","title":"review"}]}
GET /v1/tasks/1 200 {"id":1,"status":"todo","title":"write"}
GET /v1/tasks/99 404 {"status":404,"title":"task not found"}
POST /v1/tasks 400 request body has an error: doesn't match schema #/components/schemas/NewTask: Error at "/t…
POST /v1/tasks 400 request body has an error: doesn't match schema #/components/schemas/NewTask: Error at "/s…
POST /v1/tasks 400 request body has an error: doesn't match schema #/components/schemas/NewTask: property "co…
GET /v1/tasks?limit=1000 400 parameter "limit" in query has an error: number must be at most 100
GET /v1/tasks/abc 400 Invalid format for parameter id: error binding string parameter: strconv.ParseInt: parsing…
DELETE /v1/tasks/1 204
DELETE /v1/tasks/1 404 {"status":404,"title":"task not found"}The program starts the server twice, first with no middleware and then with nethttp-middleware's OapiRequestValidator built from the embedded spec, and sends the same twelve requests to each.
The first four requests behave the same in both runs: tasks are created, the status filter works, GET /v1/tasks/99 is a 404 problem document, and abc as an id is a 400 from the generated binder.
The difference is in the next four.
| Request | Without validator | With validator |
|---|---|---|
POST {"title":""} | 201, an empty title is stored | 400, error at /title |
POST {"status":"urgent"} | 201, an invalid status is stored | 400, error at /status |
POST {"title":"x","colour":"red"} | 201, unknown field ignored | 400, property "colour" unsupported |
GET ?limit=1000 | 200, ignores the maximum | 400, number must be at most 100 |
That is the finding. Generated code type-checks the input. It makes limit an int and id an int64, and the JSON decoder makes Title a string. It does not enforce minLength, enum on a plain string, maximum, or additionalProperties. Those constraints are in the document and nothing reads them until you add the validator. A server built from a spec without it is more permissive than its own documentation, which is a quiet way to ship an API that accepts garbage.
The validator is a middleware that finds the operation for each request, validates parameters and body against the document, and answers 400 on failure. Two points on using it for real:
- Its error body is plain text. By default the failure message came back as text, not as
application/problem+json. Provide an error handler in the middleware options that writes a problem document, so the contract applies to errors too. - Set
spec.Servers = nil. The validator matches the request host against the document'sserverslist unless you clear it, which is why the program does so. In production, either list your real hosts or clear it behind a router that has already matched the host.
Tests
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func post(h http.Handler, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/v1/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestGeneratedServerAloneDoesNotEnforceMinLength(t *testing.T) {
if rec := post(handler(false), `{"title":""}`); rec.Code != 201 {
t.Fatalf("expected the generated code to accept it, got %d", rec.Code)
}
}
func TestValidatorEnforcesTheDocument(t *testing.T) {
h := handler(true)
for _, body := range []string{`{"title":""}`, `{"title":"x","status":"urgent"}`, `{"title":"x","colour":"red"}`, ``} {
if rec := post(h, body); rec.Code != 400 {
t.Errorf("%q -> %d, want 400", body, rec.Code)
}
}
}
func TestCreateThenGetThroughGeneratedRouting(t *testing.T) {
h := handler(true)
if rec := post(h, `{"title":"write","status":"doing"}`); rec.Code != 201 {
t.Fatalf("create: %d", rec.Code)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/tasks/1", nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"status":"doing"`) {
t.Fatalf("get: %d %s", rec.Code, rec.Body.String())
}
}=== RUN TestGeneratedServerAloneDoesNotEnforceMinLength
--- PASS: TestGeneratedServerAloneDoesNotEnforceMinLength (0.01s)
=== RUN TestValidatorEnforcesTheDocument
--- PASS: TestValidatorEnforcesTheDocument (0.01s)
=== RUN TestCreateThenGetThroughGeneratedRouting
--- PASS: TestCreateThenGetThroughGeneratedRouting (0.01s)
PASS
ok example.test/lab 1.365sThe tests pin the lesson. The first asserts that the generated server alone accepts an empty title, which documents the gap and will fail, usefully, if a generator update starts enforcing it. The second asserts that the validated handler refuses it. The third is a round trip: create, then fetch and compare, through the generated routing.
Working with generated code
Commit it, and regenerate in CI. A checked-in api.gen.go makes the repository build without the tool; a CI step that regenerates and runs git diff --exit-code fails if the file is stale. Pin the generator version in the command, or in go.mod as a tool dependency (go get -tool), so every machine produces the same bytes. Put the command in a //go:generate comment next to main.
Never edit the generated file. Add behavior in your own files. Where the generator's output is wrong for you, change the spec, the config (it has options for naming and for extensions such as x-go-type) or a template.
Consider the strict server. The tool can generate a "strict" interface where each operation takes a typed request object and returns a typed response object, so GetTask returns GetTask200JSONResponse or GetTask404ApplicationProblemPlusJSONResponse and the compiler checks that you return only documented responses. It trades the http.ResponseWriter freedom for a tighter contract. Try it on a new service.
Know the limits. Complex schemas (oneOf, allOf with discriminators, recursive types) generate awkward Go, and are where the tool needs the most configuration. Prefer simpler schemas in the API, which also helps every client.
Pick the validator's cost deliberately. It parses the document once and then validates each request, which adds some CPU. For a public API this is cheap insurance; measure with your own routes before deciding to skip it (benchmarking handlers).
The generator is documented at pkg.go.dev, and the standard library routing it targets is described in the net/http package.


