How does io.LimitReader report that its byte limit was reached, and why is that a trap?
answer
- the ceiling is silent
- the cap looks like a normal ending
- a counter that runs down to zero
- ask for one byte more than you accept
- length check after the read
basics
~20 sio.LimitReader reports its cap as a plain end of stream, exactly as a genuine ending looks, with no distinct error. Oversized input is therefore silently truncated rather than rejected, which is dangerous for anything you then parse.
solid answer
~50 s`io.LimitReader(r, n)` returns a reader that passes through at most `n` bytes from `r` and then reports `io.EOF` — the same signal the underlying reader would give at a real end. There is no distinct "limit exceeded" error, so a caller cannot tell a complete small input from the first `n` bytes of a huge one, and it will happily parse half a message as if it were whole. The standard fix is to read with a limit of `maxBytes+1` and treat a result longer than `maxBytes` as too large, which converts silent truncation into an explicit rejection. Alternatively, keep the concrete `*io.LimitedReader` that the function returns and inspect its exported `N` field afterwards: `N` is the number of bytes still permitted, so `N == 0` tells you the budget was consumed. It also only limits reads; it is not a Closer and does not close `r`.
code
go · 10 linesconst maxBytes = 1 << 20
data, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, errors.New("input exceeds size limit")
}
return data, nilgo deeper
Know what io.LimitReader is for and remember the key surprise: reaching the cap looks exactly like the stream ending, so nothing complains.
Explain the countdown in the LimitedReader's N field, and be able to write the maxBytes+1 idiom that turns truncation into an explicit rejection.
Show where the wrapper goes in a real read path — before any buffering or parsing — and what you do to the connection once an input is rejected rather than draining it.
Decide whether over-limit input is a client error to surface or an attack to drop, and make sure the limit and the resulting rejection are documented as part of the service's contract.
## What io.LimitReader is ```go func LimitReader(r Reader, n int64) Reader ``` It returns a reader that reads from `r` but stops with end of stream after `n` bytes. It is the standard way to put a ceiling on how much an unbounded helper — io.ReadAll, a decoder, a copy — can pull from a source you do not trust. The concrete type behind that interface is exported: ```go type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } ``` So `io.LimitReader` is really `&io.LimitedReader{R: r, N: n}`, and `N` counts **down** as bytes are read. ## The trap When `N` reaches zero, the wrapper stops calling the underlying reader and reports `io.EOF`. That is the identical signal you would get if `r` had genuinely run out. The wrapper has no way to say "there was more, I cut you off" — the `io.Reader` interface has no vocabulary for it, and io.LimitReader deliberately does not invent one. The consequence is silent truncation. Consider: ```go data, err := io.ReadAll(io.LimitReader(r, 1<<20)) ``` If `r` holds 5 MiB, this returns the first 1 MiB with a **nil error**. Every subsequent line of code believes it has a complete input. Whether that is harmless or catastrophic depends on what happens next: - A strict parser usually saves you, because half a document is a syntax error. That is luck, not design. - A lenient or streaming consumer may accept the prefix and act on it. Half a batch of records gets processed, and nothing anywhere logs a problem. - Anything that hashes, signs, or compares the bytes now works on the wrong bytes. So the limit did its real job — the memory is bounded — but the outcome is a quiet correctness bug instead of a loud rejection. ## Getting an explicit rejection **The n+1 idiom.** Ask for one byte more than you are willing to accept and check the length: ```go const maxBytes = 1 << 20 data, err := io.ReadAll(io.LimitReader(r, maxBytes+1)) if err != nil { return nil, err } if int64(len(data)) > maxBytes { return nil, errors.New("input exceeds size limit") } ``` If the input really was at most `maxBytes`, you read all of it and the length check passes. If it was larger, you read exactly `maxBytes+1` bytes, know the input was over budget, and can reject it with a real error. The extra byte costs nothing and buys certainty. **Inspecting N.** Keep the concrete type instead of the interface: ```go lr := &io.LimitedReader{R: r, N: maxBytes} data, err := io.ReadAll(lr) // lr.N == 0 means the whole budget was consumed ``` This is a slightly weaker test than the n+1 idiom: `N == 0` also happens when the input was exactly `maxBytes` long and ended naturally. Whether that ambiguity matters depends on your protocol; the n+1 idiom has no such edge case, which is why it is the usual advice. ## Other things worth knowing - **The limit is in bytes, not items.** A limit protects memory. It says nothing about how expensive the bytes are to process, so a small input that expands enormously when decoded is a different problem with a different defence. - **It is not a Closer.** The returned reader has no Close method, so wrapping does not change who is responsible for closing `r`. If `r` is a connection or a file, closing it is still yours. - **It composes in one direction only.** Wrapping bounds what a *consumer* can pull. It does not stop the peer from sending; the sender keeps writing and, depending on the source, those bytes still occupy kernel buffers or still cost you bandwidth. If you have rejected the input, close the source rather than politely draining it. - **Order matters.** The limit must be applied before the expensive step, not after. Wrapping a reader you have already fully buffered protects nothing.
- Why read maxBytes+1 rather than maxBytes?Because at exactly maxBytes you cannot tell a complete input of that size from the truncated prefix of a larger one — both end the same way. Reading one extra byte makes the two cases distinguishable by length alone: a result of maxBytes+1 bytes proves there was more, and anything shorter proves the input fitted.
- Does wrapping a connection in io.LimitReader stop the peer from sending more?No. It only stops your side from pulling more; the peer keeps writing and those bytes still cost network and kernel buffer. Once you have decided the input is over budget, close the connection rather than draining it politely, otherwise a hostile peer gets to keep you busy for free.
- Does io.LimitReader close the reader it wraps?No. It returns a reader with no Close method, so ownership of the underlying source is unchanged: whoever opened the file or connection still has to close it. Wrapping is purely a read-side ceiling and carries no lifecycle meaning.
It is a jug that stops pouring at the brim and says nothing. From the glass, a jug that ran dry and a jug that was cut off look identical.
saying these in an interview costs you the question
- Expects a distinct error when the limit is hit
- Treats a nil error from a limited read as proof the input was complete
- Believes the wrapper closes or drains the underlying reader
- Applies the limit after the input has already been buffered
- Thinks a byte limit also bounds decoding cost