An open-source library from uRadical · v1.2.0

Go 1.27 · MIT · zero dependencies

HTTP requests,
bound to your structs.

Path parameters, query strings, JSON, form and multipart bodies, cookies and headers — onto one struct, in one call, with every bad field reported at once. binder does that, runs your own validation in the same call, and nothing else, with nothing in its go.mod but Go itself.

go get uradical.io/go/binder
before
// ...in every handler, again
id := r.PathValue("id")
n, err := strconv.Atoi(id)
if err != nil { /* 400 */ }

name := r.URL.Query().Get("name")

var body struct{ Email string }
if err := json.NewDecoder(r.Body).
    Decode(&body); err != nil {
    /* 400 */
}

trace := r.Header.Get("X-Request-ID")
// ...and a body someone forgot to cap
after
type Req struct {
    ID    int         `path:"id"`
    Name  string      `query:"name"`
    Email string      `body:"email"`
    Trace string      `header:"X-Request-ID"`
}

var req Req
if err := binder.Bind(r, &req); err != nil {
    http.Error(w, err.Error(),
        http.StatusBadRequest)
    return
}

// every field filled, body capped,
// one error path

Do one thing, do it well

binder binds, then runs the validation your type defines. It ships no rule language, and does not log, route or transform. That refusal is what keeps it small enough to read in one sitting and cheap enough to leave in the hot path.

Zero dependencies

Nothing in go.mod but Go itself. Nothing to audit, nothing to upgrade in a hurry, no transitive surprise arriving through a package you chose for something else.

Small enough to read

About 2,700 lines of code, and nothing else to read beneath them. When binding does something you did not expect, the answer is a short read away rather than three layers into a framework.

Sub-microsecond

60 ns to bind a path parameter, 0.75 µs for a full mixed request. Tags are reflected over once per type, path and header values are looked up directly, and query and cookie values are found by scanning rather than parsing, allocating nothing. Faster than Echo and Gin in every comparison, with as few allocations or fewer, and a fraction of the memory.

See the full benchmarks →  ·  Compared with Echo and Gin →

No magic

A field's tag says where its value comes from, and that is the whole model. No conventions inferred from names, no ambient configuration, no behaviour that changes with what else is imported.

Composable

Works with your validator, your logger, your router, your framework. Implement Validate(ctx) error and your own checks run as part of the same call, with the request's context.

Validation →

Middleware-friendly

The request body is restored after reading, so binding does not consume it. Middleware further down the chain still sees what the client sent.

Every source, one struct

Each field names where its value comes from. When a field carries more than one tag the first in this order wins, so precedence is something you can read off the page rather than discover in production.

path

Path parameters, read through net/http's own PathValue. binder does not route; it reads what your mux already resolved.

ID int `path:"id"`

query

The URL query string, scanned for just the keys your fields ask for, with no map built for up to 64 parameters. Repeated parameters fill a slice.

Tags []string `query:"tag"`

body

JSON, form-encoded or multipart, chosen by Content-Type — including the +json suffix types. One struct serves an API call, an HTML form post and a file upload, and form: reads a form body or else the query, as r.FormValue does.

Email string `body:"email"`

json

The body again, for types already tagged for encoding/json. json:"-" is never bound, and binder reads its options from this tag too, so json:"x,omitempty" skips empty values here as well.

Email string `json:"email"`

cookie

Request cookies by name. A cookie that was not sent leaves the field as it was, unless it is tagged required.

Token string `cookie:"session"`

header

Request headers, matched case-insensitively, so the tag can spell one however it reads best. Repeated headers fill a slice.

Auth string `header:"Authorization"`

Types, not strings

A query string has no types; the field decides what its value means. Numbers, booleans and floats parse, slices fill, pointers tell “absent” from “sent as zero”, nested structs bind from nested JSON, and anything implementing encoding.TextUnmarshaler — including time.Time, net.IP and uuid.UUID — or UnmarshalJSON decides for itself.

A value that will not convert is an error naming the field, the source and the key, not a zero quietly left in place.

Types & conversion →

type Search struct {
	Q      string    `query:"q,required"`
	Page   int       `query:"page"`
	Tags   []string  `query:"tag"`
	Since  time.Time `query:"since"`
	Cursor *string   `query:"cursor"`
}

var req = Search{Page: 1} // no ?page=, or an empty one, keeps it
err := binder.Bind(r, &req)

Careful with what the client sends

Binding is where untrusted input first meets your types, so the failure modes matter more than the feature list. Each of these exists because the quiet version of it costs someone an afternoon.

Bounded work

Bodies are capped at 10 MB by default, enforced while reading rather than trusted from Content-Length. An oversized body is rejected whole — never truncated, so a handler never sees half a request. Binding costs time and memory in proportion to the body, and at most 100 failures are reported.

Nothing is swallowed

A body that declares JSON and is not JSON is ErrMalformedBody, not a struct full of zero values and a cheerful 200.

No panics

An unusable target is reported as an error, and an unexported field is skipped, as encoding/json skips one. Nothing binder does turns a strange request into a stack trace in your request path.

Their fault or yours

ErrInvalidTarget means the handler passed something unbindable and deserves a 500; ErrBodyTooLarge deserves a 413; the rest point at the request and deserve a 400. The distinction is in the error, not in the message text.

Error handling →

Every bad field, at once

binder does not stop at the first bad field. Every field that fails comes back together as BindErrors, each entry carrying the key the client sent and the source it was read from — enough to tell the client everything it got wrong in one 400, rather than one field per round trip.

Failures of the request as a whole carry a sentinel to match with errors.Is, except a body that could not be read at all, which returns the I/O error. Message text is deliberately not part of the API.

var errs binder.BindErrors
switch {
case errors.As(err, &errs):
	for _, e := range errs {
		// e.Name, e.Source, e.Err
	}
	// 400, listing every field
case errors.Is(err, binder.ErrInvalidTarget):
	// our bug: 500
case errors.Is(err, binder.ErrBodyTooLarge):
	// 413
case err != nil:
	// 400
}

How fast, next to what?

The same request bound into the same shape of struct, by each library in turn. Nanoseconds per bind, lower is better, shared scale across the three panels.

Query string, 5 fields hand-written stdlib: 363 ns hand-written stdlib 363 binder: 520 ns binder 520 Echo: 874 ns Echo 874 Gin: 1104 ns Gin 1,104 gorilla/schema: 1934 ns gorilla/schema 1,934 JSON body, 5 fields binder: 599 ns binder 599 hand-written stdlib: 743 ns hand-written stdlib 743 Echo: 820 ns Echo 820 Gin: 864 ns Gin 864 Every source at once binder: 606 ns binder 606 Echo: 1336 ns Echo 1,336 Gin: 1474 ns Gin 1,474
binder is the quickest binder in all three shapes, and 2.2× faster than Echo filling a struct from every source, with six allocations against fifteen. Method, library versions and the allocation and memory figures are on the comparison page.

Install

Requires Go 1.27 or newer.

go get uradical.io/go/binder

Then, three steps

  1. Describe the request as a struct. One field per value you need, each tagged with where it comes from.
    type CreateComment struct {
    	PostID int    `path:"id"`
    	Author string `body:"author,required"`
    	Text   string `body:"text,required"`
    }
  2. Bind it. One call reads every source and converts every value.
    var req CreateComment
    if err := binder.Bind(r, &req); err != nil {
    	http.Error(w, err.Error(), http.StatusBadRequest)
    	return
    }
  3. Handle the failures you care about. Match the sentinels rather than the message, and give ErrInvalidTarget a 500 — it is your bug, not theirs.

The documentation covers every source, type conversion, options, errors, validation and the compatibility contract — or start with the quick start.