How to Read Request Body and Query Parameters in Go

Web
Read query parameters with r.URL.Query() and parse the request body using json.NewDecoder(r.Body) in Go.

The form submission that never arrives

You build a simple form on a webpage. A user types their name, picks an option from a dropdown, and clicks submit. The browser sends a POST request to your Go server. Half the data arrives in the URL as ?category=books&sort=price. The other half rides in the HTTP body as a JSON payload. Your job is to catch both pieces, validate them, and hand them to your business logic. If you mix up the URL parsing with the body decoding, the request falls through the cracks and the user stares at a 500 error.

How Go splits the request

Go treats an incoming HTTP request as a single http.Request value. That value carries two distinct data streams. The URL query string lives in r.URL.RawQuery. It is already parsed into a map-like structure the moment the server reads the headers. The request body lives in r.Body, which is an io.ReadCloser. It streams data from the network socket into your process. You cannot rewind it. Once you read a byte from the body, that byte is gone unless you explicitly buffer it yourself.

Query parameters are cheap to access. They are decoded once during the initial request parsing. The body requires you to decide how to consume it. You can read it as raw bytes, parse it as JSON, decode it as a form, or stream it line by line. The standard library gives you tools for each path. You just need to pick the right one and handle the errors.

The minimal handler

Here is the simplest handler that grabs a query parameter and decodes a JSON body.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

// handleRequest reads a query parameter and a JSON body.
func handleRequest(w http.ResponseWriter, r *http.Request) {
    // Query parameters are parsed automatically by the net/http package.
    // Get returns an empty string if the key is missing, which is safe for optional fields.
    category := r.URL.Query().Get("category")

    // Define a struct to match the expected JSON shape.
    // Struct tags map JSON keys to Go fields without requiring public field names.
    var payload struct {
        Title string `json:"title"`
        Price float64 `json:"price"`
    }

    // Decode streams the body directly into the struct without loading it all into memory first.
    // This keeps heap allocation proportional to the struct size, not the payload size.
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        // Return a 400 Bad Request with a plain text error message.
        // The if err != nil pattern is verbose by design to make failure paths visible.
        http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
        return
    }

    // Send the parsed values back as a response.
    // fmt.Fprintf writes directly to the ResponseWriter without extra allocations.
    fmt.Fprintf(w, "Category: %s, Item: %s, Cost: %.2f", category, payload.Title, payload.Price)
}

What happens under the hood

The net/http server reads the incoming TCP connection and parses the HTTP headers before your handler even runs. It splits the URL into path and query components. r.URL.Query() returns a url.Values map. Calling .Get("category") looks up that key. If the key does not exist, it returns an empty string. This is safe and fast.

The body works differently. r.Body is an io.ReadCloser backed by a buffered reader that pulls from the network. json.NewDecoder(r.Body) wraps that reader. When you call .Decode(&payload), it reads bytes until it finds a complete JSON value. It unmarshals the data directly into your struct. The struct tags (json:"title") tell the decoder which JSON keys map to which Go fields. If the JSON is malformed or missing a required field, .Decode returns an error. You check it immediately. The Go community accepts the if err != nil boilerplate because it forces you to acknowledge failure paths instead of hiding them behind silent defaults.

Notice the struct is defined inside the handler. This keeps the scope tight. You do not leak the type into the package level unless other functions need it. The decoder does not allocate a giant byte slice for the entire body. It reads in chunks. This keeps memory usage predictable even if a client sends a megabyte of JSON.

Public names start with a capital letter. Private start lowercase. The json package respects this rule. If you want a field to appear in JSON but stay unexported in Go, you use a tag with a lowercase name. If you want it exported, you capitalize the Go field and match the JSON key in the tag. The convention is clear. Follow it and your code reads like everyone else's.

Handling forms and mixed payloads

Web browsers often submit forms as application/x-www-form-urlencoded. The data looks like name=alice&age=28. Go provides r.ParseForm() to handle this. It merges query parameters and form fields into a single r.Form map. After calling r.ParseForm(), you access values with r.FormValue("name").

Here is how you handle a form submission that also carries query parameters.

package main

import (
    "fmt"
    "net/http"
)

// handleForm reads form data and query parameters from a single request.
func handleForm(w http.ResponseWriter, r *http.Request) {
    // ParseForm reads the body and merges it with the URL query string.
    // It must be called before accessing r.Form or r.PostForm.
    if err := r.ParseForm(); err != nil {
        http.Error(w, "failed to parse form", http.StatusBadRequest)
        return
    }

    // FormValue checks r.Form first, then falls back to the URL query string.
    // This makes it convenient for endpoints that accept data in either location.
    username := r.FormValue("username")
    action := r.FormValue("action")

    // Access raw query parameters directly when you need to distinguish them from form data.
    source := r.URL.Query().Get("source")

    // Return a confirmation response.
    fmt.Fprintf(w, "User: %s, Action: %s, Source: %s", username, action, source)
}

The ParseForm method reads the entire body into memory. It is fine for typical web forms. It is dangerous for file uploads or large payloads. If you expect files, use r.MultipartForm() instead. It streams files to disk or memory buffers while keeping form fields accessible. The standard library separates these paths intentionally. You pay for what you use.

A realistic API endpoint

Real endpoints rarely just echo data back. They validate input, interact with a database, and return structured responses. Here is a handler that accepts a POST request, validates the body, and prepares a response.

package main

import (
    "context"
    "encoding/json"
    "net/http"
    "time"
)

// CreateItem handles POST requests to create a new resource.
func CreateItem(w http.ResponseWriter, r *http.Request) {
    // Extract the query parameter for routing or filtering.
    source := r.URL.Query().Get("source")
    if source == "" {
        http.Error(w, "missing source parameter", http.StatusBadRequest)
        return
    }

    // Define the expected input shape with validation tags.
    var input struct {
        Name  string `json:"name" validate:"required"`
        Value int    `json:"value" validate:"min=1"`
    }

    // Limit the body size to prevent slowloris or memory exhaustion attacks.
    // MaxBytesReader returns an error if the client exceeds the limit.
    r.Body = http.MaxBytesReader(w, r.Body, 1024*1024) // 1MB limit

    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        http.Error(w, "bad request body", http.StatusBadRequest)
        return
    }

    // Pass context to downstream calls so they respect client timeouts.
    // Context always goes as the first parameter, conventionally named ctx.
    ctx := r.Context()
    processInput(ctx, source, input)

    // Return a 201 Created with a JSON response.
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]string{
        "status": "accepted",
        "source": source,
    })
}

// processInput simulates a database write or external API call.
func processInput(ctx context.Context, source string, input struct{ Name string; Value int }) {
    // In a real app, you would pass ctx to your database driver here.
    // The context carries deadlines and cancellation signals.
    select {
    case <-ctx.Done():
        fmt.Println("request cancelled or timed out")
        return
    case <-time.After(50 * time.Millisecond):
        fmt.Printf("processed %s from %s\n", input.Name, source)
    }
}

Where things go sideways

The most common mistake is reading the body twice. r.Body is a stream. If you call json.NewDecoder(r.Body).Decode and then try to read r.Body again later in the same handler, you get an empty read or an EOF error. The compiler will not catch this. The program compiles fine, but the second read returns nothing. If you need the raw bytes for logging or validation, read them first with io.ReadAll, then wrap the result in bytes.NewReader if you need to decode it later.

Another trap is ignoring the Content-Type header. Clients sometimes send form-encoded data but your handler expects JSON. json.NewDecoder will fail to parse the form data. The compiler complains with invalid character '&' looking for beginning of value if you try to decode a query-string style payload as JSON. Check r.Header.Get("Content-Type") or let the decoder fail and return a clear 400 response.

Missing fields in JSON do not cause panics. They leave the Go struct fields at their zero values. A missing string becomes "". A missing integer becomes 0. If your business logic treats 0 as valid, you will silently process incomplete data. Use pointer fields like *int or *string if you need to distinguish between "the client sent zero" and "the client sent nothing". A pointer will be nil when the JSON key is absent.

Context handling is another silent failure point. If you spawn a background goroutine from the handler, do not pass r.Context() to it. The request context gets cancelled the moment your handler returns. The goroutine will immediately fail or hang. Detach the context with context.Background() or context.WithoutCancel if the work must survive the HTTP response. The worst goroutine bug is the one that never logs. Always attach a trace ID or request ID to background work so you can find it in your logs.

Goroutines are cheap. Channels are not magic. Trust the standard library. Argue logic, not formatting.

When to reach for what

Use r.URL.Query().Get() when you need a single optional parameter from the URL. Use r.URL.Query() when you need to iterate over all query parameters or check for duplicates. Use json.NewDecoder(r.Body) when the client sends structured JSON data and you want to stream it without buffering the entire payload. Use r.ParseForm() when the client sends application/x-www-form-urlencoded or multipart/form-data and you need access to both query parameters and form fields in one map. Use io.ReadAll(r.Body) when you need the raw bytes for cryptographic hashing, file storage, or logging before decoding. Use a custom io.Reader wrapper when you need to enforce a strict size limit or rate limit on the incoming stream.

Streams move forward. Buffers cost memory. Pick the path that matches your data shape and handle the errors before they reach production.

Where to go next