Skip to content
gohacks.dev
APIs

Code First API Frameworks for Go Compared on Real Code

Huma, Fuego and Fizz compared on one endpoint: generated OpenAPI, validation, error shapes, 3.5 to 6.6 µs per request, binary size and dependencies.

12 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Code-first API frameworks promise something attractive: write Go types and handlers once, and get request validation, error responses and an OpenAPI document without keeping any of them in step by hand. The article on OpenAPI described the idea and showed one framework doing it. This one puts three side by side on the same endpoint, measures them, and reports where each makes choices that matter in practice.

Tested with Go 1.25.6, huma v2.39.1, fuego v0.19.0, fizz v0.23.0, macOS on Apple Silicon (arm64)

The selection is the code-first tools I could get to run on Go 1.25: huma, fuego and fizz. Goa (a design DSL that generates code), swaggo/swag (OpenAPI from comments), swaggest/rest and the spec-first generators are different shapes and are not in the comparison. A hand-written net/http handler is included once, as a reference for scale, not as a contender. All of it ran on Go 1.25.6.

What was tested

One endpoint, POST /v1/tasks, implemented in each framework:

  • title is required, 1 to 120 characters.
  • priority is optional, one of low, normal or high, default normal.
  • Success is 201 with {id, title, priority}.

Four requests exercise it: a valid one, an empty title, a priority that is not in the list, and a body with an unknown field. Then three measurements: the generated OpenAPI document (does it say what the code enforces?), the cost of a valid request, and the size and dependencies of the resulting binary.

Each framework lives in its own file behind a build tag, with a shared main and benchmark, so one lab builds any of them with -tags.

huma_impl.goGo
//go:build huma

package main

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/danielgtaylor/huma/v2"
	"github.com/danielgtaylor/huma/v2/adapters/humago"
)

const name = "huma v2.39.1 (net/http)"

type createIn struct {
	Body struct {
		Title    string `json:"title" minLength:"1" maxLength:"120"`
		Priority string `json:"priority,omitempty" enum:"low,normal,high" default:"normal"`
	}
}
type task struct {
	ID       int    `json:"id"`
	Title    string `json:"title"`
	Priority string `json:"priority"`
}
type taskOut struct{ Body task }

func build() (http.Handler, []byte) {
	mux := http.NewServeMux()
	cfg := huma.DefaultConfig("Tasks", "1.0.0")
	cfg.CreateHooks = nil
	api := humago.New(mux, cfg)
	huma.Register(api, huma.Operation{OperationID: "create-task", Method: http.MethodPost, Path: "/v1/tasks", DefaultStatus: 201},
		func(ctx context.Context, in *createIn) (*taskOut, error) {
			p := in.Body.Priority
			if p == "" {
				p = "normal"
			}
			return &taskOut{task{1, in.Body.Title, p}}, nil
		})
	spec, _ := json.Marshal(api.OpenAPI())
	return mux, spec
}
fuego_impl.goGo
//go:build fuego

package main

import (
	"net/http"

	"github.com/go-fuego/fuego"
)

const name = "fuego v0.19.0 (net/http)"

type createIn struct {
	Title    string `json:"title" validate:"required,min=1,max=120"`
	Priority string `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
}
type task struct {
	ID       int    `json:"id"`
	Title    string `json:"title"`
	Priority string `json:"priority"`
}

func build() (http.Handler, []byte) {
	s := fuego.NewServer(fuego.WithoutStartupMessages(), fuego.WithoutLogger())
	fuego.Post(s, "/v1/tasks", func(c fuego.ContextWithBody[createIn]) (task, error) {
		in, err := c.Body()
		if err != nil {
			return task{}, err
		}
		p := in.Priority
		if p == "" {
			p = "normal"
		}
		return task{1, in.Title, p}, nil
	}, fuego.OptionDefaultStatusCode(201))
	spec, _ := s.OutputOpenAPISpec().MarshalJSON()
	return s.Mux, spec
}
fizz_impl.goGo
//go:build fizz

package main

import (
	"encoding/json"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/loopfz/gadgeto/tonic"
	"github.com/wI2L/fizz"
	"github.com/wI2L/fizz/openapi"
)

const name = "fizz v0.23.0 (gin)"

type createIn struct {
	Title    string `json:"title" validate:"required,min=1,max=120"`
	Priority string `json:"priority" validate:"omitempty,oneof=low normal high" default:"normal"`
}
type task struct {
	ID       int    `json:"id"`
	Title    string `json:"title"`
	Priority string `json:"priority"`
}

func build() (http.Handler, []byte) {
	gin.SetMode(gin.ReleaseMode)
	f := fizz.New()
	f.POST("/v1/tasks", []fizz.OperationOption{fizz.ID("createTask")}, tonic.Handler(func(c *gin.Context, in *createIn) (*task, error) {
		p := in.Priority
		if p == "" {
			p = "normal"
		}
		return &task{1, in.Title, p}, nil
	}, 201))
	f.Generator().SetInfo(&openapi.Info{Title: "Tasks", Version: "1.0.0"})
	spec, _ := json.Marshal(f.Generator().API())
	return f, spec
}
plain_impl.goGo
//go:build plain

package main

import (
	"encoding/json"
	"net/http"
	"slices"
)

const name = "plain net/http (no spec, for scale)"

type createIn struct {
	Title    string `json:"title"`
	Priority string `json:"priority"`
}
type task struct {
	ID       int    `json:"id"`
	Title    string `json:"title"`
	Priority string `json:"priority"`
}

func build() (http.Handler, []byte) {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /v1/tasks", func(w http.ResponseWriter, r *http.Request) {
		var in createIn
		dec := json.NewDecoder(r.Body)
		dec.DisallowUnknownFields()
		fail := func(status int, title string) {
			w.Header().Set("Content-Type", "application/problem+json")
			w.WriteHeader(status)
			json.NewEncoder(w).Encode(map[string]any{"title": title, "status": status})
		}
		if err := dec.Decode(&in); err != nil {
			fail(400, "invalid body")
			return
		}
		if n := len([]rune(in.Title)); n < 1 || n > 120 {
			fail(422, "title must be 1 to 120 characters")
			return
		}
		if in.Priority == "" {
			in.Priority = "normal"
		}
		if !slices.Contains([]string{"low", "normal", "high"}, in.Priority) {
			fail(422, "priority must be low, normal or high")
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(201)
		json.NewEncoder(w).Encode(task{1, in.Title, in.Priority})
	})
	return mux, nil
}
main.goGo
package main

import (
	"fmt"
	"net/http/httptest"
	"strings"
)

func main() {
	h, spec := build()
	fmt.Println("==", name)
	for _, c := range []struct{ label, body string }{
		{"valid", `{"title":"write"}`},
		{"empty title", `{"title":""}`},
		{"bad priority", `{"title":"x","priority":"urgent"}`},
		{"unknown field", `{"title":"x","colour":"red"}`},
	} {
		req := httptest.NewRequest("POST", "/v1/tasks", strings.NewReader(c.body))
		req.Header.Set("Content-Type", "application/json")
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		body := strings.TrimSpace(rec.Body.String())
		if len(body) > 110 {
			body = body[:110] + "…"
		}
		fmt.Printf("%-14s %d %-34s %s\n", c.label, rec.Code, rec.Header().Get("Content-Type"), body)
	}
	if len(spec) == 0 {
		fmt.Println("spec: none generated")
		return
	}
	s := string(spec)
	fmt.Printf("spec: %d bytes; minLength=%v maxLength=%v enum=%v default=%v\n", len(s),
		strings.Contains(s, `"minLength":1`), strings.Contains(s, `"maxLength":120`), strings.Contains(s, `"enum":["low"`), strings.Contains(s, `"default":"normal"`))
	fmt.Printf("spec declares: openapi=%s\n", first(s, `"openapi":"`, `"`))
}

func first(s, after, until string) string {
	i := strings.Index(s, after)
	if i < 0 {
		return "?"
	}
	s = s[i+len(after):]
	return s[:strings.Index(s, until)]
}
bench_test.goGo
package main

import (
	"net/http/httptest"
	"strings"
	"testing"
)

func BenchmarkCreate(b *testing.B) {
	h, _ := build()
	b.ReportAllocs()
	for b.Loop() {
		req := httptest.NewRequest("POST", "/v1/tasks", strings.NewReader(`{"title":"write"}`))
		req.Header.Set("Content-Type", "application/json")
		h.ServeHTTP(httptest.NewRecorder(), req)
	}
}

The three framework files have the same shape: input and output types with the constraints as struct tags, a registration call with a handler that fills in the default priority, and a function that returns the generated document. Huma uses its own tags (minLength, enum, default). Fuego and fizz lean on the validate tags from go-playground/validator, covered in the validator article, and fizz adds default.

Behavior

Shell
for t in plain huma fuego fizz; do go run -tags $t .; echo; done
Text
== plain net/http (no spec, for scale)
valid          201 application/json                   {"id":1,"title":"write","priority":"normal"}
empty title    422 application/problem+json           {"status":422,"title":"title must be 1 to 120 characters"}
bad priority   422 application/problem+json           {"status":422,"title":"priority must be low, normal or high"}
unknown field  400 application/problem+json           {"status":400,"title":"invalid body"}
spec: none generated

== huma v2.39.1 (net/http)
valid          201 application/json                   {"id":1,"title":"write","priority":"normal"}
empty title    422 application/problem+json           {"title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"message":"expected lengt…
bad priority   422 application/problem+json           {"title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"message":"expected value…
unknown field  422 application/problem+json           {"title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"message":"unexpected pro…
spec: 2318 bytes; minLength=true maxLength=true enum=true default=true
spec declares: openapi=3.1.0

== fuego v0.19.0 (net/http)
valid          201 application/json                   {"id":1,"title":"write","priority":"normal"}
empty title    400 application/json                   {"title":"Validation Error","status":400,"detail":"Title is required","errors":[{"more":{"field":"Title","nsFi…
bad priority   400 application/json                   {"title":"Validation Error","status":400,"detail":"Priority should be oneof=low normal high","errors":[{"more"…
unknown field  400 application/json                   {"title":"Decoding Failed","status":400,"detail":"cannot decode request body: json: unknown field \"colour\""}
spec: 5314 bytes; minLength=true maxLength=true enum=false default=false
spec declares: openapi=3.1.0

== fizz v0.23.0 (gin)
valid          201 application/json; charset=utf-8    {"id":1,"title":"write","priority":"normal"}
empty title    400 application/json; charset=utf-8    {"error":"binding error: Key: 'createIn.Title' Error:Field validation for 'Title' failed on the 'required' tag…
bad priority   400 application/json; charset=utf-8    {"error":"binding error: Key: 'createIn.Priority' Error:Field validation for 'Priority' failed on the 'oneof' …
unknown field  201 application/json; charset=utf-8    {"id":1,"title":"x","priority":"normal"}
spec: 692 bytes; minLength=true maxLength=true enum=false default=true
spec declares: openapi=3.0.1

Read the four requests down each column.

validempty titlebad priorityunknown field
huma201422 problem+json422 problem+json422 problem+json
fuego201400 JSON400 JSON400 JSON
fizz201400 {"error":...}400 {"error":...}201
plain (reference)201422 problem+json422 problem+json400 problem+json

Huma rejected all three with a 422, an application/problem+json content type and a list of errors with a location for each field. Unknown fields are rejected because its generated schema says additionalProperties: false. The error shape is the one problem details recommends, with no work from the handler.

Fuego rejected all three with a 400 and a body that has title, status, detail and an errors array, close to problem details but served as plain application/json. The validator's internals are visible inside each error (Key: 'NewTask.Title' Error:Field validation for 'Title' failed on the 'required' tag), which tells a client more about your Go types than it needs to know. Unknown fields were rejected at decode time with a clear message.

Fizz (through tonic and gin) rejected the empty title and the bad priority with {"error":"binding error: Key: 'createIn.Title' Error:Field validation ..."}, which names a Go struct in the response. It accepted the unknown field and created the task. If you rely on strict decoding to catch misspelled client fields (strict JSON), you must add it yourself, and gin's binding does not offer the same switch the standard decoder does.

None of these errors is wrong. They differ in what the client gets for free and what you have to override.

What the generated document says

The program also reports whether each generated document contains the constraints that the code enforces.

OpenAPIdocument sizeminLengthmaxLengthenumdefault
huma3.1.02,318 bytesyesyesyesyes
fuego3.1.05,314 bytesyesyesnono
fizz3.0.1692 bytesyesyesnoyes

All three turned the length bounds into minLength and maxLength. Only huma also emitted the enum list; fuego and fizz enforced oneof=low normal high at run time and then left it out of the document, so a client generated from their documents would not know the allowed values. Fuego also omitted the default. This is the gap the code-first promise hides: the document is only as complete as the translation from tags, and the translation has holes. Diff the generated document in CI, and read it once for each new tag style you use.

Two further details: fizz's document is OpenAPI 3.0.1 where the others are 3.1.0, which matters if your tooling prefers one, and huma's default configuration adds a $schema property to responses unless you disable the hook, as the earlier article showed.

What they cost

Shell
for t in plain huma fuego fizz; do
  echo "-- $t"
  go test -tags $t -run '^$' -bench . -benchmem -count=1 . | grep Benchmark
  go build -tags $t -o app-$t . && echo "binary: $(wc -c < app-$t) bytes; packages built: $(go list -tags $t -deps . | wc -l)"
done
Text
-- plain
BenchmarkCreate-10    	  514375	      2336 ns/op	    7542 B/op	      31 allocs/op
binary:  5777730 bytes; packages built:      184
-- huma
BenchmarkCreate-10    	  337960	      3522 ns/op	    7521 B/op	      41 allocs/op
binary:  7243666 bytes; packages built:      195
-- fuego
BenchmarkCreate-10    	  159810	      6573 ns/op	    8640 B/op	      68 allocs/op
binary:  17164066 bytes; packages built:      237
-- fizz
BenchmarkCreate-10    	  274219	      4405 ns/op	    8190 B/op	      42 allocs/op
binary:  30151026 bytes; packages built:      314

On an Apple M1 Max, a valid POST through each handler (request construction, routing, decoding, validation, encoding) took:

time per requestallocationsbinarypackages in the build
plain net/http2,336 ns315.8 MB184
huma3,522 ns417.2 MB195
fizz (gin)4,405 ns4230.2 MB314
fuego6,573 ns6817.2 MB237

Absolute numbers are small: these are microseconds on an in-memory request with no I/O, so none of them would be the bottleneck of a service that talks to a database. The ordering is the useful part. Huma's overhead over the hand-written handler was about 1.2 µs. Fuego was almost three times the reference. The binary sizes show how much each brings with it: fuego carries more with it, and fizz brings gin and its dependencies, which makes it five times the size of the reference binary. A dependency count of 314 against 184 also means more to audit and more advisories to follow (vulnerability scanning).

Evaluating a framework you did not test

The comparison is a method as much as a result, and the same short list works for any other tool in this category, including a newer release of these three.

  1. Write the endpoint with your real constraints: a required field, a bounded string, an enum, a default, a nested object and a list. The toy example above is deliberately small; the interesting failures appear at the edges of your own types.
  2. Send the bad requests, not only the good one: empty, wrong type, unknown field, oversized body, wrong content type. Note the status code, the content type and whether the body leaks Go names.
  3. Read the generated document, and look for each constraint you wrote. Anything missing is a gap a generated client will not know about.
  4. Generate a client from it and call the server (typed clients). A document that parses is not the same as one that works.
  5. Measure the request and the binary with the same benchmark, and count the packages in the build.
  6. Check how you escape: can you replace the error format, add middleware, mount it on your router and serve a hand-written route beside the generated ones? A framework you cannot step out of is a commitment.

An afternoon spent this way is cheaper than the migration it prevents.

Compatibility note

The latest fuego release at the time of the run required Go 1.26.5, which the toolchain used here could not satisfy, so the comparison uses v0.19.0, the newest release that built on Go 1.25.6. If you are pinned to an older toolchain, check each framework's go directive before you plan around it, and note that versions move quickly in this part of the ecosystem.

Choosing

Pick huma when you want a net/http-compatible service whose contract is its Go types. It had the most complete document, the strictest decoding, the standard error format and the lowest overhead. It also has adapters for other routers, so it does not force the choice of router on you (stdlib router or framework). The cost is learning its tag vocabulary and its operation-registration style, which is different from plain handlers.

Consider fuego if you like its generated route registration and want validator tags you already know. Check the missing enum and the error format against your needs, and plan to wrap the handlers for the response shape you want.

Consider fizz if you already run gin and want to add documentation to existing routes incrementally. Be aware of the unknown-field behavior and the larger dependency set.

Write it by hand when the API is small, when exact control of the wire format is the point, or when the spec is written first and the code only needs to match it (spec-first). The reference handler is 30 lines and entirely yours.

Whichever you choose, the discipline from the earlier articles still applies: test the wire format with golden strings, diff the generated document in CI, validate against it (OpenAPI), and keep error responses in a shape clients can depend on. The frameworks are documented on pkg.go.dev: huma, fuego and fizz.

Taggedroundup
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.