How to Use io.LimitReader to Prevent Memory Exhaustion

Use io.LimitReader to cap the number of bytes read from an io.Reader and prevent memory exhaustion.

The upload that kills the server

You are building an API that accepts file uploads. A client sends a request. Your handler calls io.ReadAll on the request body to parse the payload. Suddenly, the server memory spikes. The operating system OOM killer steps in. Your process dies. Other users get 502 errors. The culprit is not a bug in your logic. It is a malicious or misconfigured client sending a 10GB file when you expected a 1MB config. Go makes it easy to read data, but it does not stop you from reading too much.

How LimitReader acts as a meter

io.LimitReader wraps an io.Reader and enforces a hard cap on how many bytes can be read. It acts like a prepaid meter on a pipe. Data flows through normally until the meter hits the limit. Then the valve slams shut. Your code sees io.EOF as if the stream ended naturally. The underlying reader might still have data, but LimitReader refuses to hand it over.

This wrapper is essential when you use functions like io.ReadAll or ioutil.ReadAll. Those functions allocate a buffer and grow it until the source returns io.EOF. If the source is untrusted, the buffer can grow until the process runs out of memory. LimitReader guarantees the buffer never exceeds the limit. The wrapper implements the io.Reader interface, so it drops in anywhere a reader is expected. It holds an internal counter and delegates every read to the underlying source, subtracting from the counter until it reaches zero.

LimitReader is a guardrail. It stops the flow before the bucket overflows.

Minimal example: capping a string

Here is the simplest usage. You have a source with more data than you want. You wrap it with a limit and read.

package main

import (
	"fmt"
	"io"
	"strings"
)

func main() {
	// Simulate a large source that we do not want to fully consume
	source := strings.NewReader("Hello, World! This is a long string.")

	// Wrap with a limit of 5 bytes to restrict output
	limited := io.LimitReader(source, 5)

	// Read all available data from the limited reader
	data, err := io.ReadAll(limited)
	if err != nil {
		panic(err)
	}

	// Prints: Hello
	fmt.Printf("%s\n", string(data))
}

The wrapper counts. The source does not know.

Inside the wrapper

When you call Read on a LimitReader, it checks its internal counter. If the counter is zero, it returns io.EOF immediately without touching the underlying reader. If the counter is positive, it asks the underlying reader for data.

The Read method signature is Read(p []byte) (n int, err error). The caller passes a buffer and expects some bytes. LimitReader calculates how many bytes it is allowed to return. That is the smaller of the requested buffer size and the remaining limit. It calls the underlying reader with a slice of the buffer restricted to that size. It copies the result, decrements the counter by the number of bytes read, and returns the count.

If the underlying reader returns fewer bytes than requested, LimitReader passes that through. The limit is a ceiling, not a quota. If the source ends before the limit, LimitReader returns io.EOF normally. The counter never matters. This behavior means LimitReader works correctly with streams that produce data in chunks. You can call Read multiple times. The limit applies cumulatively across all calls.

The limit is a ceiling. If the source ends first, the limit never triggers.

Realistic example: HTTP handler with truncation check

In a web server, you often need to reject payloads that exceed a size limit. io.LimitReader helps you read safely, but it also introduces a nuance. If the read stops exactly at the limit, you cannot tell if the client sent exactly that amount or more. LimitReader swallows the excess. To enforce a strict maximum, you must check for truncation.

Here is a handler that reads the body, checks the limit, and detects if the client tried to send more data.

package main

import (
	"fmt"
	"io"
	"net/http"
)

// maxUploadSize caps the request body to 1MB
const maxUploadSize = 1 * 1024 * 1024

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	// Wrap the body to prevent OOM on large uploads
	limitedBody := io.LimitReader(r.Body, maxUploadSize)

	// Read the content safely into memory
	body, err := io.ReadAll(limitedBody)
	if err != nil {
		http.Error(w, "read error", http.StatusInternalServerError)
		return
	}

	// Check if the body was truncated by the limit
	if len(body) == maxUploadSize {
		// Try to read one more byte from the original body
		extra := make([]byte, 1)
		n, err := r.Body.Read(extra)
		if n > 0 || err == nil {
			// More data exists. The payload exceeded the limit.
			http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
			return
		}
	}

	// Close the original body to release resources
	r.Body.Close()

	fmt.Fprintf(w, "Received %d bytes", len(body))
}

The handler wraps r.Body with LimitReader. It reads the data. If the length equals the limit, it attempts to read one more byte from the original r.Body. If that read succeeds or returns no error, the client sent more data than allowed. The handler returns a 413 status. If the extra read returns io.EOF, the client sent exactly the limit, which is acceptable.

Note the explicit r.Body.Close() call. io.LimitReader returns an io.Reader, not an io.ReadCloser. The wrapper hides the Close method. You must close the original reader to avoid leaking file descriptors. Go conventions require closing request bodies. The if err != nil check after ReadAll is mandatory. Go makes errors explicit. Ignoring the error can lead to processing partial data or panics.

Wrap the body. Check the length. Close the original.

Pitfalls and compiler traps

The most common mistake is losing the Close method. io.LimitReader returns a value that implements io.Reader. It does not implement io.ReadCloser. If you try to assign the result to a variable typed as io.ReadCloser, the compiler rejects the program with cannot use io.LimitReader(...) (value of type io.LimitedReader) as io.ReadCloser value in variable declaration. You must keep a reference to the original reader and close it manually. Forgetting to close request bodies on high-traffic servers exhausts file descriptors and crashes the process.

Another trap is assuming len(data) == limit means the payload was too large. As shown in the realistic example, the payload might be exactly the limit size. You must probe past the limit to distinguish between a valid max-sized payload and an oversized one. LimitReader does not expose its internal counter. You cannot query how much data was discarded.

io.ReadAll allocates memory eagerly. It starts with a small buffer and doubles it as needed. If you set the limit to a very large value, ReadAll will still allocate that much memory. LimitReader protects against infinite streams, but it does not protect against unreasonable limits. Choose a limit that fits your server's memory budget. If you need to process large files without loading them entirely into memory, read in chunks using a fixed-size buffer and io.ReadFull or a loop.

Losing Close is a leak waiting to happen. Track your resources.

Decision matrix

Use io.LimitReader when you need a generic byte cap on any io.Reader, including files, pipes, or network streams.

Use http.MaxBytesReader when handling HTTP requests, as it wraps the body and automatically returns a 413 error if the limit is exceeded.

Use a manual read loop when you need fine-grained control over partial reads or want to enforce limits per-chunk rather than per-stream.

Use multipart.Reader size checks when processing form uploads, since multipart parsing has its own buffer limits and field size constraints.

Use plain io.ReadAll only when you trust the source completely and the data size is known to be small.

Pick the tool that matches the boundary. HTTP needs HTTP limits. Files need file limits.

Where to go next