• English
  • Example: io.LimitReader

    io.LimitReader wraps an io.Reader with a maximum read boundary. It allows downstream code to read at most n bytes from the underlying Reader while preserving the original streaming model.

    It solves a common production problem: the application needs to limit how much input a downstream operation is allowed to consume without buffering the entire input in memory.

    Typical use cases include:

    • Limiting the amount of data read from HTTP requests or responses
    • Restricting reads to a known protocol or frame boundary
    • Adding a size limit before calling io.ReadAll
    • Reading only the first N bytes of a large file or stream

    The most important boundary to understand is:

    EOF from the limited Reader does not necessarily mean EOF from the underlying Reader.

    When the configured limit is reached, io.LimitReader stops reading and returns io.EOF, even if the underlying Reader still contains more data.

    This distinction matters whenever the application must detect oversized input rather than silently accept the first n bytes.

    Quick Start

    The simplest scenario: read at most five bytes from a data source.

    package main
    
    import (
    	"fmt"
    	"io"
    	"strings"
    )
    
    func main() {
    	// Create a Reader to simulate a data source.
    	src := strings.NewReader("Hello, io.LimitReader from gobase.net.")
    
    	// Limit the downstream Reader to at most 5 bytes.
    	limited := io.LimitReader(src, 5)
    
    	// Read the limited input.
    	data, err := io.ReadAll(limited)
    	if err != nil {
    		fmt.Printf("failed to read: %v\n", err)
    		return
    	}
    
    	fmt.Printf("%s\n", data)
    	fmt.Printf("Remaining bytes in underlying Reader: %d\n", src.Len())
    }

    Output:

    Hello
    Remaining bytes in underlying Reader: 33

    The limited Reader has reached EOF after five bytes, but the underlying strings.Reader still contains unread data.

    This is the key behavior to remember:

    The limited Reader can be exhausted while the underlying Reader is not.

    The Core Contract

    io.LimitReader(r, n) returns an io.Reader that reads from r but returns at most n bytes in total.

    Conceptually:

    Underlying Reader
    
    
    ┌──────────────────┐
    │  io.LimitReader  │
    │   limit = n      │
    └──────────────────┘
    
    
    Downstream Reader

    The limited Reader:

    • reads from the underlying Reader on demand;
    • never returns more than n bytes in total;
    • does not buffer the entire input;
    • does not verify that the underlying input ends at n bytes;
    • does not close the underlying Reader.

    Once the limit is exhausted, subsequent reads return io.EOF.

    The critical point is that this io.EOF may be generated by the limit boundary, not by the underlying data source.


    The Critical Boundary: EOF Does Not Prove Underlying EOF

    Consider:

    Underlying data:
    "Hello, Go, and gobase.net."
    
           │ more data exists
    
    io.LimitReader(r, 5)
    
    
        "Hello"
    
    
          EOF

    The limited Reader returns io.EOF after five bytes.

    But the underlying Reader may still contain:

    ", Go, and gobase.net."

    Therefore:

    limited Reader returns EOF
    
    underlying Reader reached EOF

    This is the defining semantic boundary of io.LimitReader.

    Three Possible Outcomes

    SituationLimited ReaderUnderlying Reader
    Underlying Reader ends before n bytesReturns io.EOFActually reached EOF
    Exactly n bytes are availableReturns io.EOF after reading themMay also be at EOF
    More than n bytes are availableReturns io.EOF after reading n bytesStill has unread data

    The caller cannot distinguish the last two cases by observing the limited Reader alone.

    This is why:

    io.ReadAll(io.LimitReader(r, max))

    can safely limit how much data is read, but cannot prove that the original input was no larger than max bytes.


    When to Use

    Limit Input Size Without Changing the Streaming Model

    When reading data from a network connection, user upload, file, or third-party service, the input size may not be fully trusted.

    io.LimitReader is useful when the application needs to impose a hard upper bound on how much data a downstream operation can consume.

    For example:

    data, err := io.ReadAll(io.LimitReader(r, maxSize))

    This combination is useful when the goal is simply to prevent io.ReadAll from consuming an unbounded amount of input.

    The memory used by io.ReadAll will still depend on the amount of data it accumulates and its internal buffer capacity, but the input exposed through the limited Reader is bounded by maxSize.

    The important distinction is:

    io.LimitReader limits how much data is read. It does not determine whether the original input exceeded the limit.

    Use it when the application needs a read boundary.

    Do not use it alone when the application needs oversize detection.


    When Not to Use It Alone

    When the Application Must Reject Oversized Input

    Suppose an API accepts request bodies up to 1 MB.

    If the application uses:

    io.ReadAll(io.LimitReader(r, 1<<20))

    and the client sends 2 MB, the application reads the first 1 MB and the limited Reader eventually reports io.EOF.

    The read succeeds.

    But the original input was too large.

    The application cannot determine this from the limited Reader alone.

    It cannot distinguish:

    Input size = 1 MB

    from:

    Input size = 2 MB

    by observing only the limited Reader.

    For cases where exceeding the limit must be detected and rejected, read one byte beyond the allowed limit.

    maximum accepted size = max
    
    read up to max + 1
    
    
              ├── <= max
              │      └── input is within the limit
    
              └── max + 1
                     └── input exceeded the limit

    This turns a silent read boundary into an explicit validation boundary.


    Detecting Oversized Input with max + 1

    If the application must reject input larger than max, it needs to distinguish between:

    input size <= max

    and:

    input size > max

    A simple approach is to allow the limited Reader to expose one additional byte:

    data, err := io.ReadAll(io.LimitReader(r, max+1))
    if err != nil {
    	return nil, err
    }
    
    if int64(len(data)) > max {
    	return nil, errBodyTooLarge
    }

    The extra byte is not part of the accepted input. It is only evidence that the input exceeded the configured limit.

    The important distinction is:

    max limits how much data is accepted. max + 1 provides enough information to detect whether the boundary was exceeded.

    This works because io.ReadAll treats EOF as a normal termination condition. If the input contains exactly max + 1 bytes, io.ReadAll may still return err == nil, but the resulting length is max + 1, which correctly identifies the input as oversized.

    For production helpers, the limit itself should also be validated. In particular, blindly evaluating max + 1 can overflow if max is already the maximum value representable by its integer type.

    A defensive implementation can handle that boundary explicitly:

    package main
    
    import (
    	"errors"
    	"io"
    	"math"
    )
    
    var errBodyTooLarge = errors.New("body too large")
    
    func readAtMost(r io.Reader, max int64) ([]byte, error) {
    	if max < 0 {
    		return nil, errors.New("negative limit")
    	}
    
    	if max == math.MaxInt64 {
    		return io.ReadAll(r)
    	}
    
    	data, err := io.ReadAll(io.LimitReader(r, max+1))
    	if err != nil {
    		return nil, err
    	}
    
    	if int64(len(data)) > max {
    		return nil, errBodyTooLarge
    	}
    
    	return data, nil
    }

    In most application code, max is far smaller than math.MaxInt64, so the overflow case is highly unusual. The important production principle is simply:

    Do not let the max + 1 validation technique introduce an integer-overflow boundary of its own.


    Production Scenario: Limiting an HTTP Response Body

    When calling a third-party HTTP API, the response body should not be treated as inherently trustworthy.

    A malformed server, unexpected response, or configuration error could return a much larger response than expected.

    If the goal is only to protect memory, a simple limit is enough:

    package main
    
    import (
    	"fmt"
    	"io"
    	"net/http"
    	"net/http/httptest"
    	"time"
    )
    
    const maxBodySize = 1 << 20 // 1 MB
    
    func main() {
    	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		fmt.Fprint(w, `{"status":"ok"}`)
    	}))
    	defer server.Close()
    
    	client := &http.Client{Timeout: 3 * time.Second}
    
    	resp, err := client.Get(server.URL)
    	if err != nil {
    		fmt.Printf("request failed: %v\n", err)
    		return
    	}
    	defer resp.Body.Close()
    
    	body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
    	if err != nil {
    		fmt.Printf("failed to read response body: %v\n", err)
    		return
    	}
    
    	fmt.Printf("Response body size: %d bytes\n", len(body))
    }

    This protects io.ReadAll from reading an arbitrarily large response.

    However, it does not reject a response larger than maxBodySize. It silently reads only the first maxBodySize bytes.

    If the application must reject oversized responses, use the max + 1 pattern instead:

    body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))
    if err != nil {
    	return err
    }
    
    if len(body) > maxBodySize {
    	return errBodyTooLarge
    }

    Key Engineering Points

    1. io.LimitReader limits how much downstream code can read from the underlying Reader.
    2. It does not accumulate data itself; memory usage comes from the consumer, such as io.ReadAll.
    3. io.LimitReader returns an io.Reader, not an io.ReadCloser.
    4. The underlying resp.Body remains the caller's responsibility to close.
    5. A successful io.ReadAll does not prove that the original response body was within the configured limit.
    6. If the application must reject oversized input, read max + 1 bytes and explicitly check the result.

    Production Scenario: Reading a Fixed Protocol Boundary

    io.LimitReader can also be useful when a protocol defines a maximum length for a field or frame.

    For example, suppose a protocol defines the next field as containing at most 1024 bytes:

    field := io.LimitReader(conn, 1024)

    The downstream parser can consume data through field without reading beyond the configured boundary.

    This is different from io.ReadFull:

    io.ReadFull
    
        └── "I need exactly N bytes."
    
    io.LimitReader
    
        └── "You may read at most N bytes."

    If the protocol requires exactly 1024 bytes, use io.ReadFull.

    If the protocol allows up to 1024 bytes and another mechanism determines the actual end of the field, io.LimitReader may be appropriate.

    The distinction is important:

    io.ReadFull enforces an exact-length requirement. io.LimitReader enforces a maximum-length boundary.


    Common Failure Mode

    Failure: Treating Limit-Induced EOF as Real EOF

    Risky Code

    package main
    
    import (
    	"fmt"
    	"io"
    	"strings"
    )
    
    func main() {
    	body := strings.NewReader(
    		`{"name":"gobase","role":"example","extra":"ignored"}`,
    	)
    
    	data, err := io.ReadAll(io.LimitReader(body, 24))
    	if err != nil {
    		fmt.Printf("read failed: %v\n", err)
    		return
    	}
    
    	fmt.Printf("accepted: %s\n", data)
    }

    Why It Fails

    The code looks correct because io.ReadAll returns err == nil.

    However, the successful read only proves that the limited Reader was consumed successfully.

    It does not prove that the original input ended within 24 bytes.

    If the original input is larger, the extra data remains unread from the underlying Reader.

    This can lead to:

    • silently truncated input;
    • incomplete JSON or protocol data being accepted;
    • incorrect signature or checksum validation;
    • corrupted or incomplete uploads;
    • incorrect business decisions based on partial data;
    • unexpected behavior when the underlying stream is reused.

    The problem is especially easy to miss because ordinary tests usually cover valid, small inputs. The failure appears only when the input exceeds the configured boundary.


    Correct Approach

    If the application must reject oversized input, use max + 1:

    package main
    
    import (
    	"errors"
    	"fmt"
    	"io"
    	"strings"
    )
    
    var errBodyTooLarge = errors.New("body too large")
    
    func readAtMost(r io.Reader, max int64) ([]byte, error) {
    	if max < 0 {
    		return nil, errors.New("negative limit")
    	}
    
    	data, err := io.ReadAll(io.LimitReader(r, max+1))
    	if err != nil {
    		return nil, err
    	}
    
    	if int64(len(data)) > max {
    		return nil, errBodyTooLarge
    	}
    
    	return data, nil
    }
    
    func main() {
    	body := strings.NewReader(
    		`{"name":"gobase","role":"example","extra":"rejected"}`,
    	)
    
    	data, err := readAtMost(body, 24)
    	if err != nil {
    		fmt.Printf("rejected: %v\n", err)
    		return
    	}
    
    	fmt.Printf("accepted: %s\n", data)
    }

    For a fully defensive reusable helper, the math.MaxInt64 boundary should also be handled as shown in the previous section.

    Here, the extra byte is enough to prove that the input exceeds the 24-byte limit.

    The application does not need to consume the entire oversized input.

    It only needs enough information to establish:

    len(input) <= max

    or:

    len(input) > max

    This is the fundamental difference between limiting input and validating input size.


    Blocking, Cancellation, and Resource Lifetime

    io.LimitReader only controls the number of bytes that may be read. It does not control when a read completes.

    It does not:

    • support context.Context;
    • set read deadlines;
    • cancel a blocked read;
    • close the underlying Reader.

    If the underlying Reader blocks, the limited Reader can also block.

    Timeouts, cancellation, and resource lifetime must be handled by the underlying object or by the surrounding system.

    For example:

    • HTTP clients can use request contexts and client timeouts.
    • Network connections can use read deadlines.
    • Resources implementing io.Closer must be closed by the caller.

    This distinction is important:

    io.LimitReader limits bytes, not time or resource lifetime.


    io.LimitReader vs. http.MaxBytesReader

    These two APIs both impose read limits, but they solve different problems.

    io.LimitReader is a generic Reader wrapper. It simply limits how many bytes can be read through the returned Reader.

    http.MaxBytesReader is an HTTP server-specific request-body enforcement mechanism. It limits reads from an HTTP request body and returns an error when the configured limit is exceeded. It is designed to work with an HTTP server's ResponseWriter and request lifecycle.

    The distinction is:

    io.LimitReader
    
        └── Generic byte limit
            └── Reaching the limit appears as EOF
    
    http.MaxBytesReader
    
        └── HTTP request-body limit
            └── Exceeding the limit is reported as an error

    Therefore, they should not be treated as interchangeable.

    Use io.LimitReader when you need a generic maximum read boundary.

    Use http.MaxBytesReader when enforcing request-body limits in an HTTP server.


    API Boundary Comparison

    The following APIs solve different boundary problems:

    RequirementAPICore GuaranteeOversize DetectionTypical Scenario
    Read at most N bytesio.LimitReaderStops the limited Reader after N bytesNoRestrict downstream reads
    Read all available dataio.ReadAllReads until the Reader returns EOFNoSmall responses, configuration, in-memory data
    Read exactly N bytesio.ReadFullReturns nil only after filling len(buf) bytesN/AFixed-length headers and binary fields
    Enforce an HTTP request-body limithttp.MaxBytesReaderLimits request-body reads and returns an error when the limit is exceededYesHTTP server request limits
    Limit and detect oversized inputio.LimitReader(r, max+1)Reads enough to determine whether the limit was exceededYesGeneric Readers and response-body validation

    The key distinction is:

    io.ReadFull
        → Exactly N bytes
    
    io.LimitReader
        → At most N bytes
    
    io.ReadAll
        → Until EOF
    
    io.LimitReader(r, max+1) + length check
        → Detect whether input exceeds max
    
    http.MaxBytesReader
        → Enforce an HTTP server request-body limit

    Key Takeaways

    io.LimitReader is a small API with an important production boundary.

    Its core contract is:

    Read no more than N bytes from the underlying Reader.

    It does not guarantee:

    The underlying input contains no more than N bytes.

    Therefore:

    • Use io.LimitReader when you need a maximum read boundary.
    • Use io.ReadFull when you need an exact number of bytes.
    • Use io.ReadAll when you intentionally want to consume the input until EOF.
    • Use io.LimitReader(r, max+1) when you need to detect whether the input exceeds a maximum size.
    • Do not interpret io.EOF from a limited Reader as proof that the underlying Reader has reached EOF.
    • Remember that io.LimitReader limits bytes, not memory, time, cancellation, or resource lifetime.
    • Use http.MaxBytesReader when enforcing HTTP server request-body limits rather than treating io.LimitReader as an HTTP-specific enforcement mechanism.

    The most important production rule is simple:

    A read limit is not the same thing as input-size validation.

    That distinction is where io.LimitReader is most useful—and where it is most often misunderstood.