binder / Docs / Performance

Performance

Binding from path, query, cookie or header costs about a hundred nanoseconds and allocates nothing of its own. Binding a body costs more, because the body has to be read and parsed before any field can be converted.

How these were taken

Measured on an Apple M-series laptop with Go 1.27, -benchtime=200ms -count=10, reporting the median of ten runs. Each benchmark times binding, not building the request: the request is built once and its body re-armed between iterations, so httptest.NewRequest is not folded into the figures — it costs more memory than the binding itself. Re-arming the body is timed, and adds two allocations to each body benchmark.

make bench

Results

Benchmarkns/opB/opallocs/op
BindHeaderOnly52161
BindPathOnly6081
BindQueryOnly82161
BindCookieOnly86161
BindNoQueryParams87161
BindOmitEmpty118481
BindParallel22755211
BindBodyOnly/FormBody54255211
BindBodyOnly/JSONBody57231613
BindMixed/WithForm72961611
Bind7473367
BindMixed/WithJSON75237213
BindManyQueryParams9421281
BindWithoutCache2,3843,49420
BindMultipart7,87631,65778

Reading the numbers

The tag cache

Bind against BindWithoutCache measures the per-type tag cache: 747 ns and 7 allocations with it warm, against 2,384 ns and 20 allocations when it is cleared before every iteration. Reflecting over a type's tags happens once per type, not once per request, which is why the first request against a handler is the expensive one and no other is.

Path, query, header and cookie allocate nothing

The one allocation in each of those benchmarks is the target itself, escaping to the heap once it is passed to Bind as any. Everything else is free: for up to 64 parameters or cookies, the query string is never parsed into url.Values and the Cookie header never into *http.Cookie values, and each field's value is found by scanning for it. A value with nothing to unescape is a substring of the request rather than a copy, and a string headed for an int, bool or float64 is parsed straight into the field.

BindManyQueryParams binds eight query parameters in one allocation. The scan takes the same steps as url.ParseQuery, and a fuzz test holds it to that. A query of more than 64 parameters is parsed once instead, so that it costs one pass rather than one per field.

Bodies cost more than the rest

A body has to be read before any field can be converted, which is why every body benchmark starts higher than the ones that read a header or a path value. The body is read into one buffer sized from Content-Length, up to 64 KB before any of it arrives, and a JSON body goes from there straight into your fields through a pooled jsontext decoder; a member no field binds is skipped without being decoded at all. A form body is parsed from the same buffer into a map of values first, and costs about the same as JSON. A handler that reads only a path parameter and a header touches none of it.

Multipart is an order of magnitude dearer

BindMultipart carries two text fields and a 4 KB file. The gap is inherent to the encoding rather than to binding: the parser copies each part, and the file is held in memory rather than spilled to disk. It is the price of an upload, not of the tag lookup.

Context

Setting the upload aside, the slowest case here is about two microseconds, and that only for the first request of a type. On any endpoint that touches a database, a cache or another service, binding is not the thing to optimise — these numbers exist to show that it stays out of the way, not to invite tuning.

Allocation counts are not part of the compatibility contract. A release may bind in a different order or with a different number of allocations without that being a breaking change. See Compatibility.