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 benchResults
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
BindHeaderOnly | 52 | 16 | 1 |
BindPathOnly | 60 | 8 | 1 |
BindQueryOnly | 82 | 16 | 1 |
BindCookieOnly | 86 | 16 | 1 |
BindNoQueryParams | 87 | 16 | 1 |
BindOmitEmpty | 118 | 48 | 1 |
BindParallel | 227 | 552 | 11 |
BindBodyOnly/FormBody | 542 | 552 | 11 |
BindBodyOnly/JSONBody | 572 | 316 | 13 |
BindMixed/WithForm | 729 | 616 | 11 |
Bind | 747 | 336 | 7 |
BindMixed/WithJSON | 752 | 372 | 13 |
BindManyQueryParams | 942 | 128 | 1 |
BindWithoutCache | 2,384 | 3,494 | 20 |
BindMultipart | 7,876 | 31,657 | 78 |
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.