Form data is just key-value pairs on a wire
You are building a client to hit a legacy API. The documentation says "POST form data." You send a JSON body, and the server returns 400 Bad Request. You switch to a raw string, and the server complains about missing fields. The issue isn't your logic. It is the encoding. The server expects application/x-www-form-urlencoded, the format browsers use when you submit an HTML form. Go makes this straightforward, but you need to use the right tools to avoid manual string concatenation and header mistakes.
How form encoding works
Form data is a flat list of keys and values joined by ampersands. Spaces become plus signs or percent-encoded sequences. Special characters like & or = get escaped so they don't break the structure. The result looks like username=alice&action=login.
Go provides url.Values to handle this. It is a map where keys are strings and values are slices of strings. The slice design exists because HTML forms can have multiple values for the same key, such as checkboxes. The Encode method turns this structure into the correct string format. You also need to set the Content-Type header. If you skip the header, the server might guess wrong or reject the request.
Form data is flat. Keep it simple.
Minimal example
Here is the simplest way to encode form data and send a request. The code uses url.Values to build the payload and sets the required header.
package main
import (
"fmt"
"net/url"
)
func main() {
// url.Values is a map[string][]string.
// Slices allow multiple values for the same key, like checkboxes.
data := url.Values{
"username": []string{"alice"},
"action": []string{"login"},
}
// Encode returns a percent-encoded query string.
// Keys and values are sorted alphabetically for consistency.
encoded := data.Encode()
fmt.Println(encoded)
}
The output is deterministic. Encode sorts the keys, so the result is always action=login&username=alice. This sorting matters for caching, logging, or generating signatures.
Now send the request. You need to wrap the string in a reader and set the header.
package main
import (
"fmt"
"net/http"
"net/url"
"strings"
)
func main() {
data := url.Values{
"username": []string{"alice"},
}
// strings.NewReader converts the string to an io.Reader.
// http.NewRequest expects a reader for the body.
req, err := http.NewRequest("POST", "https://httpbin.org/post", strings.NewReader(data.Encode()))
if err != nil {
panic(err)
}
// Set the header so the server knows how to parse the body.
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status)
}
The request object holds the method, URL, headers, and body. http.DefaultClient.Do sends the bytes over the wire. The server receives the headers, sees the content type, and parses the body as form data.
What happens under the hood
When you call data.Encode(), Go iterates over the map. It sorts the keys to ensure deterministic output. Each key-value pair gets percent-encoded. Spaces become +. Special characters become %XX sequences. The result is a safe string for transmission.
When you pass this to http.NewRequest, the body is buffered. strings.NewReader creates a lightweight reader over the string. It implements io.Reader without copying the data. The request object stores a reference to this reader.
http.DefaultClient.Do opens a connection to the server. It writes the request line, headers, and body. The client handles connection pooling and TLS automatically. If the server responds, the client returns the response. You must close the response body to release resources.
The body is a stream. Read it once, or buffer it.
Realistic example
Real code needs error handling, context, and a clean separation between data and transport. A struct holds the form fields. A method encodes the struct. A function sends the request.
package main
import (
"net/url"
)
// LoginForm holds the fields for a login request.
// Structs make it easy to pass data around your application.
type LoginForm struct {
Email string
Password string
Remember bool
}
// EncodeForm converts the struct to url.Values.
// This keeps the encoding logic separate from the HTTP call.
func (f LoginForm) EncodeForm() url.Values {
// Create a new url.Values map.
data := url.Values{}
// Add sets a single value for the key.
// It appends to the slice if the key already exists.
data.Add("email", f.Email)
data.Add("password", f.Password)
// Handle boolean flags by converting to a string.
if f.Remember {
data.Add("remember", "true")
}
return data
}
The receiver name is f, a short abbreviation matching the type. Go convention prefers one or two letter receiver names. The Add method appends to the value slice. If you call Add twice with the same key, you get multiple values. Use Set if you want to replace existing values.
Now the HTTP function. It uses context for cancellation and a custom client for timeouts.
package main
import (
"context"
"fmt"
"net/http"
"strings"
"time"
)
// PostLogin sends the form data to the server.
// Context allows cancellation and deadlines.
func PostLogin(ctx context.Context, form LoginForm) (*http.Response, error) {
// Encode the form data into a string.
encoded := form.EncodeForm().Encode()
// NewRequestWithContext creates a request that can be cancelled.
// Always use context for long-running operations.
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/login", strings.NewReader(encoded))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
// Set the header so the server parses the body correctly.
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Create a client with a timeout.
// http.DefaultClient has no timeout and can block indefinitely.
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(req)
}
context.Context always goes as the first parameter. The convention is to name it ctx. Functions that take a context should respect cancellation. NewRequestWithContext links the request to the context. If the context expires, the request aborts.
Error handling follows the if err != nil pattern. The community accepts the boilerplate because it makes the unhappy path visible. Wrap errors with %w to preserve the chain.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
Manual string building is a trap. If you concatenate keys and values yourself, you risk forgetting to encode special characters. A value containing & splits into a new key. A value containing = breaks the parser. The compiler won't catch this. You get a runtime logic error. Trust url.Values.
Reusing url.Values maps can cause bugs. url.Values is a map. If you pass a map to a function and modify it, the original changes. Encode the data before passing it around, or copy the map if you need isolation.
Headers matter. If you omit Content-Type, some servers default to raw body parsing. Others reject the request. Always set the header explicitly.
Compiler errors appear when you misuse the API. If you pass a malformed URL to NewRequest, the compiler rejects it with invalid URL escape or missing port in address. If you forget to import a package, you get undefined: pkg. If you import a package and don't use it, the compiler complains with imported and not used.
Runtime errors happen when the network fails. http.Client.Do returns an error if the connection drops or the server is unreachable. Check the error. If you use context and the deadline passes, the request fails with context deadline exceeded.
Manual encoding is a trap. Trust url.Values.
When to use form data
Choose the encoding based on the server's requirements and the data shape.
Use url.Values with application/x-www-form-urlencoded when the server requires form-encoded data or when you are integrating with a legacy system that predates JSON.
Use json.Marshal with application/json when the server supports JSON, as it handles nested structures and types more cleanly than flat form fields.
Use multipart/form-data when you need to upload binary files along with text fields, since form encoding cannot handle binary data safely.
Use query parameters appended to the URL when the request is a GET operation or when the data is small, safe to cache, and idempotent.
Match the server's expectation. Don't guess.