go-playground/form: decoding url.Values into Go structs
:steam_locomotive: Decodes url.Values into Go value(s) and Encodes Go value(s) into url.Values. Dual Array and Full map support.
At a glance
- What is it?
- go-playground/form maps HTML form field names onto nested Go structs, slices, arrays and maps, and encodes the same structures back into url.Values. It is a drop-in codec for form and query data, with a v4 module path, index-aware slices and a custom type hook, and it deliberately does not implement encoding.TextUnmarshaler.
- Who is it for?
- Adopt go-playground/form when your handler receives flat or bracketed form or query data and you want it reflected into a nested Go struct without hand written parsing, since the package handles indexed slices, nested maps and mixed arrays in one pass.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 33 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One field naming scheme for structs, slices, arrays and maps
The package exists because form data and Go structures do not naturally agree. A browser sends flat key value pairs, and nested structures have to be spelled out in the key. go-playground/form defines a single convention for that spelling and uses it in both directions, decoding url.Values into Go values and encoding Go values back into url.Values.
The convention has two rules and nothing else. A dot separates a field from a nested struct, so `structfield.field` reaches into a nested value. A bracket holds an index or a key, so `arrayfield[0]` selects an element of a slice or array and `mapfield[keyvalue]` selects a map entry. Everything else in the library is a consequence of these two rules.
The type coverage is wide enough for ordinary application work. Strings, booleans, the signed and unsigned integer families, float32 and float64, structs including anonymous structs, interface{}, time.Time, a pointer to any of the above, slices, arrays and maps are supported out of the box, and custom types can override any of them. The documentation notes that map, struct and slice nesting is ad infinitum, which is the reason a form with a repeating row of addresses does not need a bespoke decoder.
Index aware slices behave differently from index aware arrays
This is the sharpest edge in the package and it is documented as a feature rather than a quirk. Both slices and arrays honour an explicit index in the field name, but they respond differently when that index exceeds the current length.
For a slice, if `Slice[2]` is the only Slice value passed down, it is placed at index 2, and the slice is expanded if it is not big enough. For an array, the same input puts the value at index 2 as well, but if the array is not big enough a warning is printed and the value is ignored. So a form that posts a single indexed field can silently drop data on the array path, while the slice path quietly grows to make room.
That difference has a practical consequence for how you model a form. A slice models a list whose length the client decides, and the decoder will make room for whatever indices arrive. An array models a fixed size field, and mismatches surface as a warning on standard error rather than as an error return. If you need the mismatch to fail loudly, the array path as documented will not do it for you.
Field names in a real HTML form
The documentation shows the naming scheme in an actual form, which is the clearest way to see how the two rules combine. A flat field lands directly on a struct field, a repeated group of fields becomes an indexed slice of structs, a bracketed key becomes a map entry, and the bracket and dot forms can be combined to any depth.
<form method="POST">
<input type="text" name="Name" value="joeybloggs"/>
<input type="text" name="Age" value="3"/>
<input type="text" name="Gender" value="Male"/>
<input type="text" name="Address[0].Name" value="26 Here Blvd."/>
<input type="text" name="Address[0].Phone" value="9(999)999-9999"/>
<input type="text" name="Address[1].Name" value="26 There Blvd."/>
<input type="text" name="Address[1].Phone" value="1(111)111-1111"/>
<input type="text" name="active" value="true"/>
<input type="text" name="MapExample[key]" value="value"/>
<input type="text" name="NestedMap[key][key]" value="value"/>
<input type="text" name="NestedArray[0][0]" value="value"/>
<input type="submit"/>
</form>Look at Address[0].Name and Address[1].Name together. The index picks the slice element and the dot picks the field inside that struct, so a repeated address block needs no special casing in Go. The same idea continues with NestedMap[key][key], where two brackets address two levels of map, and NestedArray[0][0], which addresses a cell in a slice of slices.
Note that the Go side of this example uses a struct tag for one field only, `form:"active"`, to match the lowercase incoming key to the exported field. Everything else relies on the field names matching directly.
Decoding url.Values into a nested struct
Decoding is a two step affair: create a decoder, then hand it a pointer and the values. The values usually come from the standard library, since the example simulates the result of http.Request's ParseForm() function, but any url.Values will do.
// use a single instance of Decoder, it caches struct info
var decoder *form.Decoder
func main() {
decoder = form.NewDecoder()
// this simulates the results of http.Request's ParseForm() function
values := parseForm()
var user User
// must pass a pointer
err := decoder.Decode(&user, values)
if err != nil {
log.Panic(err)
}
fmt.Printf("%#v\n", user)
}The comments inside the example carry two operational warnings. The target must be a pointer, because the decoder writes into the value you give it. And you should use a single instance of the decoder, because it caches struct info, which is where the reflection cost of a per request decoder would otherwise land.
The struct being filled looks like this, and it is the shape the field names above target:
type User struct {
Name string
Age uint8
Gender string
Address []Address
Active bool `form:"active"`
MapExample map[string]string
NestedMap map[string]map[string]string
NestedArray [][]string
}The decoder returns an error rather than logging, so a handler can decide whether a malformed value is fatal, ignored or answered with a 400.
Encoding a struct back into url.Values
The reverse direction exists because a server often has to build a query string, a redirect, or a pre filled form out of a Go value. The encoder is the mirror image of the decoder, and the same caching advice applies: use a single instance, since it caches struct info.
// use a single instance of Encoder, it caches struct info
var encoder *form.Encoder
func main() {
encoder = form.NewEncoder()
user := User{
Name: "joeybloggs",
Age: 3,
Gender: "Male",
Address: []Address{
{Name: "26 Here Blvd.", Phone: "9(999)999-9999"},
{Name: "26 There Blvd.", Phone: "1(111)111-1111"},
},
Active: true,
MapExample: map[string]string{"key": "value"},
NestedMap: map[string]map[string]string{"key": {"key": "value"}},
NestedArray: [][]string{{"value"}},
}
// must pass a pointer
values, err := encoder.Encode(&user)
if err != nil {
log.Panic(err)
}
fmt.Printf("%#v\n", values)
}A point that is easy to miss: the pointer requirement applies here too. The encoder takes a pointer as well, even though it appears to only need to read the value.
A practical use for this is a round trip test. Encode a struct, decode the result, and compare. That catches mismatches between your field names and your struct tags early, which is cheaper than discovering them from a misrouted form in production.
Custom types, time.Time and the TextUnmarshaler refusal
Some types need a rule of their own, and the package offers a registration hook for that. time.Time is handled out of the box using the RFC3339 format, and the documentation states that this default can be changed by registering a custom type.
decoder.RegisterCustomTypeFunc(func(vals []string) (interface{}, error) {
return time.Parse("2006-01-02", vals[0])
}, time.Time{})The signature takes the slice of submitted strings and returns a value or an error, and the second argument is the type the function applies to. There is an additional rule for struct types: if a struct type is registered, the function is only called when a url.Value exists for the struct itself and not merely for its fields. So a registered struct handler does not shadow the field by field decoding, it handles the case where the whole struct arrives as one value.
One deliberate omission is documented as a question rather than hidden. The package does not support encoding.TextUnmarshaler, and the reason given is specific: TextUnmarshaler only accepts a byte slice, while posted values can carry multiple values, so it is not a suitable fit. If your codebase already has custom types implementing TextUnmarshaler, they will not be picked up automatically, and you will need a custom type registration for each of them.
Module path, release history and the v4 import line
The module is versioned under the /v4 suffix, and the import line follows from that.
go get github.com/go-playground/formimport "github.com/go-playground/form/v4"The go.mod file declares the module as github.com/go-playground/form/v4 and sets a go 1.26 directive, so the toolchain line in your own module has to be compatible with that. The same file records two retractions: v4.2.3, for retractions and revert of that code, and v4.2.2, marked as accidentally publishing a breaking change. That is a useful signal about the release process, since breaking changes have been retracted before.
Release activity is steady. Version 4.5.0 was published on 2026-09-20, 4.4.0 the same day, and 4.3.1 on 2026-09-08, and the last push was on 2026-09-20. The repository is not archived.
The build tooling is visible in the Makefile, where the default target runs lint, test and bench in sequence, the linter is golangci-lint with a five minute timeout, the test command enables the race detector with atomic coverage mode, and the bench target runs with benchmem. The source layout keeps decoder, encoder, cache and util code in separate files, with the cache implementation being the piece that backs the struct info caching the decoder and encoder share.
Editorial conclusion
Adopt go-playground/form when your handler receives flat or bracketed form or query data and you want it reflected into a nested Go struct without hand written parsing, since the package handles indexed slices, nested maps and mixed arrays in one pass. Do not reach for it when your payload is JSON, when you need encoding.TextUnmarshaler semantics, or when you depend on struct tags other than the form tag, because the package deliberately refuses TextUnmarshaler and does not document those tags. Verify first that the field names your front end sends match the dot and bracket syntax, that the Go version in your go.mod satisfies the go 1.26 directive in this module, and that you understand the slice versus array difference, since a slice grows to fit an index while an array prints a warning and drops the value.
Frequently asked questions
How do I install go-playground/form and import it?
The documented install step is go get github.com/go-playground/form, and the import path in your own code is github.com/go-playground/form/v4. The go.mod file in this repository declares the module as github.com/go-playground/form/v4 and sets a go 1.26 directive.
Does go-playground/form support encoding.TextUnmarshaler?
No, and the reason given in the documentation is that TextUnmarshaler only accepts a byte slice, while posted values can have multiple values, so it is not suitable. You can still handle such types through RegisterCustomTypeFunc.
What happens when a slice index is larger than the slice in go-playground/form?
For a slice, the value is placed at the specified index and the slice is expanded if it is not big enough. For an array, a warning is printed and the value is ignored when the array is not big enough.
How do I decode nested form fields into a Go struct with go-playground/form?
Use a dot to separate struct fields and a bracket for a slice index or a map key, so Address[0].Name targets a field of the first element of an Address slice. Decode into a pointer with a decoder created by form.NewDecoder(), and reuse that decoder instance because it caches struct info.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/go-playground-form)Was this useful?