JavaRush /Courses /Go SELF /Safe request body reading in Go: limit, json.Decoder, an...

Safe request body reading in Go: limit, json.Decoder, and closing Body

Go SELF
Level 59, Lesson 2
Available

1. Why reading r.Body is “input from a stranger”

When you write an HTTP server, at some point the inevitable happens: the client brings you data in the body and says, “Here you go, I’m a good guy.” The problem is that the internet is built in such a way that a “good guy” can turn out to be broken, huge, or even slightly malicious. That’s why reading a request body is not “read JSON and move on”, but a small security protocol.

From a practical standpoint, we care about three risks. The first risk is size: the client can send not { "title": "buy milk" }, but 50 megabytes of text (by accident or on purpose), and you will heroically try to swallow it all with RAM. The second risk is invalid format: the JSON can be corrupted, of an unexpected type, or contain junk “after” a valid JSON. The third risk is unexpected fields: the client sent { "titile": "..." } instead of { "title": "..." }, and you silently swallowed it—then the application starts behaving oddly, as if a “ghost” is editing your code.

For reference, here’s how we want the request-body processing flow to look:

flowchart TD
    A[HTTP request: headers + body] --> B[Limit body size]
    B --> C[json.Decoder: Decode into struct]
    C --> D[Check: exactly one JSON in body]
    D --> E[Close body]
    E --> F[Next: field validation / business logic]

2. Tools for safely reading the body

What r.Body is and why you shouldn’t read it “as a string”

If you previously wrote console programs, input was usually fmt.Scan or bufio.Scanner: you read data, and it stays in your variables. In HTTP, everything is a bit more “streaming”: the request body is a byte stream that you can read gradually. In Go, this is expressed in the type: r.Body is an io.ReadCloser.

It’s important to understand two things.

First, r.Body is not a string. It doesn’t store the entire text for you upfront. It’s like a faucet: as long as you “turn it” (read), water flows (bytes arrive). Second, r.Body can’t be “read twice”. If you read the stream to the end once, the second time there’s nothing left to read (at best), or you’ll get weird effects if you try to mix different reading approaches.

Sometimes beginners try to do this (and it even compiles—so it’s especially dangerous):

package main

import (
	"io"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	b, _ := io.ReadAll(r.Body) // read EVERYTHING (unsafe)
	_ = b
}

This code is dangerous not because io.ReadAll is “bad on paper”. It’s dangerous because without a limit you agree to read any amount of data. If the client sends a lot, you’ll try to allocate a lot of memory. And memory, as we know, is not infinite (and especially not on a server, where other requests also live alongside yours).

http.MaxBytesReader: putting a “turnstile” at the entrance

When you build a registration form on a website, you don’t leave the “name” field unlimited to 500 megabytes. The same story applies to the body: the server must be able to say, “Stop, I’m not accepting more.”

Go has a ready-made tool for this: http.MaxBytesReader. It “wraps” r.Body so that reading can’t exceed the limit.

And it looks very simple:

package main

import "net/http"

func limitBody(w http.ResponseWriter, r *http.Request) {
	const maxBodySize = 1 << 20 // 1 MiB
	r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
}

There are two important questions that usually come up immediately.

First: why does MaxBytesReader take w http.ResponseWriter? Because when the limit is exceeded, the server sometimes needs to form a correct response (for example, for a request that’s too large). The internal implementation is tied to the response context, so it needs w.

Second: what limit should you choose? There is no universal “correct” number, but there is common sense. For creating a task (a couple of fields), 1 MiB is generous. For uploading an avatar, it’s small (but that’s usually not JSON). A good thought: the limit should match the endpoint’s contract.

A small “rules-of-thumb cheat sheet” (not a law, just a guideline):

Scenario Typical body Practical limit
POST “create task” (title + a couple of flags) hundreds of bytes 64 KiB – 1 MiB
POST “import JSON array” kilobytes–megabytes 1–10 MiB (and streaming is better)
file upload tens of megabytes usually not JSON; different techniques

json.Decoder: streaming JSON parsing without io.ReadAll

Even if you set a limit on the request body size, you still want to read data “the grown-up way”: not drag everything into memory as one huge string, but parse the stream. For this, Go has json.Decoder.

The key idea: json.NewDecoder(r.Body) accepts an io.Reader, i.e., a stream, and reads it gradually.

A minimal decoding example looks like this:

package main

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

type createTaskRequest struct {
	Title string `json:"title"`
}

func decodeExample(r *http.Request) error {
	var req createTaskRequest
	return json.NewDecoder(r.Body).Decode(&req)
}

What matters here: we decode into a struct, so we immediately get typed data. That’s much nicer than keeping a “map of random stuff” and then manually figuring out types.

Another benefit of json.Decoder: it fits well with the “don’t trust input” idea. If the JSON is broken, Decode returns an error—and you can tell the client the request is invalid. If you first did ReadAll and then json.Unmarshal, you would still catch an error, but only after reading the entire text into memory.

Strict decode: DisallowUnknownFields() and the “exactly one JSON” rule

If you’ve ever made a typo in a field name even once, you know what “pure pain” looks like. The worst case is when the server silently swallows that typo.

By default, encoding/json behaves fairly leniently: if the JSON contains fields that are not in your struct, it ignores them. Sometimes that’s convenient, but for an API it’s usually harmful: you want the client to learn immediately that they sent something wrong.

For this, json.Decoder has a strict mode:

package main

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

func strictDecoder(r *http.Request) *json.Decoder {
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec
}

Now if the client sends { "titile": "..." }, you won’t get “empty Title and silence”; you’ll get an error: unknown field.

The second rule that people rarely think about at first: the body must contain exactly one JSON object. Otherwise, a strange situation is possible: the client sends a valid JSON, then spaces, then another JSON. If you do a single Decode, it reads the first object, and the tail goes unnoticed. For an API, that’s almost always a bad idea: a “bundle of two JSONs” is most likely a client bug.

The checking trick is simple: after the first Decode, do a second Decode and expect io.EOF.

3. A helper for reading JSON: limit + strict mode + one object

Once you write 3–4 handlers, you quickly get tired of copying the same code: set a limit, create a decoder, enable strict mode, decode, check EOF. So it makes sense to extract this into a small function.

First, let’s define a limit constant (so we don’t scatter magic numbers throughout the code):

package main

const maxBodySize = 1 << 20 // 1 MiB

Next is the helper that does all the request-body “hygiene”:

package main

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

func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
	defer r.Body.Close()

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(dst); err != nil {
		return err
	}
	return ensureEOF(dec)
}

And a small function that checks “there’s nothing else in the body”:

package main

import (
	"encoding/json"
	"errors"
	"io"
)

func ensureEOF(dec *json.Decoder) error {
	if err := dec.Decode(&struct{}{}); err != io.EOF {
		return errors.New("body must contain a single JSON value")
	}
	return nil
}

Notice &struct{}{}. It’s an “empty container”: we don’t care what it is, we only care about seeing that there is nothing left to decode. If there’s another JSON value, Decode won’t return io.EOF, and we consider the request invalid.

4. Closing r.Body: why, when, and why defer fits

At first, closing the body can feel like a strange ritual: “I didn’t open anything—why am I closing something?” That’s normal: your brain is still living in the world of variables, while HTTP lives in the world of streams and resources.

It’s important to understand the idea: r.Body is a resource tied to a network connection and buffers. When we finish handling the request, those resources must be properly released. On the server side, net/http will close the body itself in many cases, but an explicit defer r.Body.Close() makes the code more predictable, especially if you replaced r.Body with a wrapper (MaxBytesReader) and want to guarantee “it gets closed on any exit path”.

The key practical effect: handlers often have early returns due to errors. If you don’t use defer, you’ll have to remember “close here, and here, and here too”. But defer does what it should: it closes once when the function returns, even if you returned from the middle.

And yes, defer is appropriate here precisely because the handler is usually small, and you read the body exactly once. It’s like “fastening your seat belt”: 99% of the time you don’t think about it, but when you need it, you’re glad it was there.

5. Integrating JSON reading into a handler

Now we’ll add the simplest “input” endpoint to our educational HTTP application. Let it be task creation. We’re not building a full storage layer yet and we’re not discussing rich responses— what matters to us is safe JSON intake.

Here’s the request payload structure:

package main

type createTaskRequest struct {
	Title string `json:"title"`
}

And here’s a handler that reads JSON via our helper:

package main

import "net/http"

func handleCreateTask(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	var req createTaskRequest
	if err := decodeJSONBody(w, r, &req); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

Notice something important: the handler remains linear and short. There are no “dances” with ReadAll, no manual limiting, no manual JSON tail checking—all of that is hidden in decodeJSONBody. This is very similar to the general Go approach: move repeated mechanics into a helper and keep a readable business path in the handler.

By the way, the same idea is often used when people want to centralize error handling in the HTTP layer: they create a type adapter that lets a handler “return an error”, and ServeHTTP decides what to write in the response. This technique is well-known and appears in both educational and real-world examples.

6. Common mistakes when reading a request body

Mistake #1: reading the body via io.ReadAll without a limit.
This is the most common “time bomb”. In tests everything is fine, on a local machine everything is fine, and then someone accidentally sends a large request—and the server starts consuming memory like it’s at a buffet. A limit via http.MaxBytesReader is basic protection you should implement every time.

Mistake #2: decoding JSON and not checking that the body has no “tail” left.
A single Decode can successfully read the first JSON and not notice there is extra data afterward. For an API, this often means you accept an invalid request “partially” and then debug ghosts. A second Decode that expects io.EOF closes this hole.

Mistake #3: forgetting DisallowUnknownFields() and getting “silent typos”.
The client sends a field with a typo in the name, and the server silently ignores it. As a result, the field ends up empty, and you start suspecting everything: encoding, the network, the phase of the moon, and Mercury being in retrograde. Strict decoding turns this into a clear error right at the boundary.

Mistake #4: trying to read r.Body twice.
For example, first doing ReadAll “for logs”, and then doing Decode again. The stream has already been consumed; the second time you’ll get emptiness or an error. If you need to log raw input, it must be done carefully and intentionally (usually via io.TeeReader), but in a basic server it’s better not to complicate things and read exactly once.

Mistake #5: putting defer r.Body.Close() somewhere “after Decode”, and then returning early.
If defer is placed too late, then on an early return it won’t run. So the rule is simple: as soon as you decide “I’m reading the body”, right after setting the limit, add defer r.Body.Close()—and then write the rest of the code calmly.

1
Task
Go SELF, level 59, lesson 2
Locked
Note title
Note title
1
Task
Go SELF, level 59, lesson 2
Locked
Sum with limit
Sum with limit
1
Task
Go SELF, level 59, lesson 2
Locked
Strict Note
Strict Note
1
Task
Go SELF, level 59, lesson 2
Locked
Decoder helper
Decoder helper
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION