Reading an entire io.Reader is convenient, but convenience can become a resource problem when the reader is not fully under your control. A request body, uploaded file, decompressed stream, subprocess output, or protocol payload may be much larger than expected. Calling io.ReadAll directly asks Go to keep reading until EOF, growing memory as needed.

The important mental model is that a size limit should sit in front of the consumer. Instead of trusting every caller to stop at the right point, wrap the source in a reader that exposes only a bounded prefix. Go’s io.LimitReader does exactly that.

There is one subtlety: io.LimitReader reports EOF when its byte budget is exhausted. That makes it excellent for bounding reads, but EOF alone cannot tell you whether the original input ended exactly at the limit or continued beyond it. Correct size validation therefore needs one extra byte of evidence.

Understand what io.LimitReader guarantees

io.LimitReader(r, n) returns an io.Reader that reads from r but stops with io.EOF after at most n bytes. Internally, the standard library uses an io.LimitedReader, whose remaining byte count decreases as reads succeed.

A minimal example makes the behavior visible:

source := strings.NewReader("abcdefgh")
limited := io.LimitReader(source, 4)

data, err := io.ReadAll(limited)
if err != nil {
    return err
}

fmt.Println(string(data)) // abcd

The consumer receives only four bytes even though the underlying reader contains eight. This is a useful guarantee: code downstream of limited cannot read the remaining bytes through that wrapper.

However, reaching the limit looks like ordinary EOF to the downstream reader. If the source contains exactly four bytes, io.ReadAll(limited) also returns "abcd" with no error. Those two cases are deliberately indistinguishable through the limited wrapper alone.

Why limiting and validating are different problems

Consider a service that accepts configuration documents no larger than 1 MiB:

const maxConfigSize int64 = 1 << 20

data, err := io.ReadAll(io.LimitReader(source, maxConfigSize))
if err != nil {
    return err
}

This code protects the consumer from reading more than 1 MiB, but it does not prove that the original document was at most 1 MiB. An input of exactly 1 MiB and an input of 20 MiB both produce a 1 MiB result.

That distinction matters when truncation would change meaning. JSON, signed messages, configuration files, and binary formats should usually be rejected when oversized rather than silently parsed as a prefix.

The solution is to let the bounded reader expose one byte beyond the accepted maximum:

const maxConfigSize int64 = 1 << 20

limited := io.LimitReader(source, maxConfigSize+1)
data, err := io.ReadAll(limited)
if err != nil {
    return err
}
if int64(len(data)) > maxConfigSize {
    return ErrTooLarge
}

Now the outcomes are distinguishable:

  • fewer than or exactly maxConfigSize bytes means the input fit within the limit;
  • maxConfigSize + 1 bytes proves that the original input exceeded the accepted size.

You do not need to read the rest of an oversized input merely to know that it is too large.

Build a reusable bounded-read helper

For repeated use, keep the policy in one helper instead of duplicating limit+1 logic throughout the codebase:

package boundedio

import (
    "errors"
    "fmt"
    "io"
    "math"
)

var ErrTooLarge = errors.New("input exceeds size limit")

func ReadAll(r io.Reader, limit int64) ([]byte, error) {
    if limit < 0 {
        return nil, fmt.Errorf("limit must be non-negative")
    }
    if limit == math.MaxInt64 {
        return nil, fmt.Errorf("limit is too large for sentinel-byte validation")
    }

    // The extra byte is evidence that the input exceeded the limit.
    limited := io.LimitReader(r, limit+1)
    data, err := io.ReadAll(limited)
    if err != nil {
        return nil, err
    }
    if int64(len(data)) > limit {
        return nil, ErrTooLarge
    }
    return data, nil
}

The helper has a clear contract: either return the complete input when it fits, or reject it when there is evidence of at least one byte beyond the maximum.

The helper rejects math.MaxInt64 because adding the sentinel byte would overflow int64. Such an enormous limit is also a sign that io.ReadAll is the wrong consumption strategy; use incremental processing instead.

A limit controls bytes read, not every resource cost

A bounded reader prevents its consumer from obtaining more than the configured number of bytes through that wrapper. It does not automatically bound every resource involved in producing those bytes.

For example, imagine this pipeline:

compressed input -> decompressor -> LimitReader -> parser

Placing the limit after decompression bounds the number of decompressed bytes delivered to the parser. That is useful for limiting parser memory, but decompression itself may still consume CPU. Placing the limit before decompression bounds compressed bytes instead, which is a different policy and does not cap expansion.

The location of the limit therefore defines what you are measuring. Put it at the boundary that corresponds to the resource you need to control.

The same reasoning applies to decoding, decryption, archive extraction, and generated streams. A byte cap is one layer of resource control, not a substitute for timeouts, cancellation, format-specific limits, or complexity limits.

Do not confuse Content-Length with enforcement

In HTTP code, Content-Length can be useful metadata, but it should not be your only enforcement mechanism. A streaming body may not have a known length in advance, and application code should enforce the limit on the bytes it actually reads.

For server request bodies, the net/http package provides http.MaxBytesReader, which is designed specifically for this job:

func handleUpload(w http.ResponseWriter, r *http.Request) {
    const maxBodySize int64 = 2 << 20 // 2 MiB

    r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)

    data, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "request body too large or unreadable", http.StatusBadRequest)
        return
    }

    _ = data
}

Unlike io.LimitReader, http.MaxBytesReader is a ReadCloser, returns a non-EOF error when a read goes beyond the configured limit, and closes the underlying request body when its own Close method is called. It is therefore the better default when the source is an incoming HTTP request body.

The exact status code and error response are application policy. For example, an API may deliberately return HTTP 413 Request Entity Too Large for an oversized payload while using HTTP 400 for malformed content. Keep that mapping separate from the low-level read-limit mechanism.

Use io.LimitedReader when remaining capacity matters

io.LimitReader returns the io.Reader interface, which intentionally hides the concrete wrapper. Sometimes you need to inspect or adjust the remaining byte budget. In that case, construct io.LimitedReader directly:

limited := &io.LimitedReader{
    R: source,
    N: 1024,
}

buf := make([]byte, 256)
n, err := limited.Read(buf)
if err != nil && err != io.EOF {
    return err
}

fmt.Printf("read=%d remaining=%d\n", n, limited.N)

N is updated after successful reads. That can be useful in a parser where several fields share one total byte budget.

Do not treat N == 0 as proof that the underlying input has no more data. It means only that the limited reader has exhausted its allowance. The source may still contain bytes.

Keep framing limits separate from allocation limits

Many protocols begin with a length field. It is tempting to trust that length and allocate immediately:

size := binary.BigEndian.Uint32(header)
payload := make([]byte, size)

If size came from untrusted input, the allocation occurs before any bounded reader can help. Validate the declared size first:

const maxPayload = 4 << 20

size := binary.BigEndian.Uint32(header)
if size > maxPayload {
    return ErrTooLarge
}

payload := make([]byte, int(size))
if _, err := io.ReadFull(source, payload); err != nil {
    return err
}

Here the length field determines framing, while maxPayload determines allocation policy. This is different from reading an unknown-length stream with LimitReader, but both techniques apply the same principle: establish a resource boundary before consuming untrusted data.

Common mistakes to avoid

Treating io.LimitReader as an oversize detector

A read that ends because the limit was reached looks like EOF. If oversize input must be rejected, read at most limit+1 bytes and check whether the extra byte exists, or use an API such as http.MaxBytesReader that reports exceeding the limit explicitly.

Adding a limit after reading everything

This does not protect memory:

data, err := io.ReadAll(source)
if err != nil {
    return err
}
if len(data) > maxSize {
    return ErrTooLarge
}

The size check happens only after the allocation and read have already occurred. Put the bound around the reader before io.ReadAll.

Assuming a byte limit also creates a timeout

A reader can produce a small amount of data very slowly. io.LimitReader controls quantity, not elapsed time. Network services should combine size limits with request deadlines, context cancellation, or transport-level timeouts appropriate to the protocol.

Silently accepting a truncated prefix

Truncation may accidentally appear valid. For example, a line-oriented format could contain a complete first record before the size limit. Decide explicitly whether a prefix is useful. If the operation requires the complete document, reject oversized input rather than processing the bounded prefix.

When to use a bounded reader

io.LimitReader is a good fit when an API accepts a generic io.Reader and you want downstream code to see at most a fixed number of bytes. It is especially useful around parsers, decoders, subprocess streams, and other components that should not control their own input budget.

Use http.MaxBytesReader for incoming HTTP request bodies because it adds HTTP-server-specific behavior and reports attempts to read past the limit as an error.

For large valid inputs that should be processed incrementally, avoid io.ReadAll entirely. Keep the bounded reader, but stream into a parser or destination with io.Copy, bufio.Reader, a decoder, or another incremental API. The limit constrains total bytes; streaming constrains how much must be retained in memory at once.

Conclusion

A safe read limit is not a check performed after consumption. It is a boundary placed before the consumer.

io.LimitReader provides that boundary for generic Go streams, but its EOF behavior means size validation needs one extra byte when oversize input must be distinguished from input that ends exactly at the limit. For HTTP request bodies, use http.MaxBytesReader instead of rebuilding HTTP-specific limit handling yourself.

The practical pattern is simple: decide which bytes you need to bound, apply the limit before expensive or memory-growing work, and keep size limits separate from timeouts and other resource controls.