io.BufferedReader.peek() Leaves .raw Mid-Read-Ahead (tested)
io.BufferedReader.peek(size) is documented to "return bytes from the stream without advancing the position" -- and through the BufferedReader itself, that's exactly what happens: calling peek() then read() returns the same bytes you peeked. But BufferedReader implements peek() by reading a full internal buffer's worth of data ahead from the underlying raw stream, not just the bytes requested. Unlike TextIOWrapper, which needs an explicit detach() call to expose its raw stream, BufferedReader exposes its raw stream directly as the .raw attribute, no call needed -- so reaching for br.raw.read() right after a peek(), which looks like a harmless way to drop down to binary access, silently skips over everything the read-ahead already consumed.
1. peek() through the BufferedReader itself behaves exactly as documented
import io
raw = io.BytesIO(b"0123456789" * 1000) # 10000 bytes
br = io.BufferedReader(raw, buffer_size=64)
peeked = br.peek(5)
print("peeked:", peeked[:5])
print("read(5) right after peek:", br.read(5))
peeked: b'01234'
read(5) right after peek: b'01234'
Measured: going through br consistently, peek() does not advance anything -- the subsequent read(5) returns the exact same bytes that were peeked, matching the documentation.
2. The raw stream underneath has already moved
raw2 = io.BytesIO(b"0123456789" * 1000)
br2 = io.BufferedReader(raw2, buffer_size=64)
br2.peek(5)
print("raw2.tell() after a single peek(5):", raw2.tell())
raw2.tell() after a single peek(5): 64
Measured: a single peek(5) call reads a full buffer_size (64 bytes here) ahead from the raw stream to fill BufferedReader's internal buffer, even though only 5 bytes were requested and returned. br2 itself hides this -- .tell() on br2 still correctly reports position 0 -- but the raw stream's own position has moved to 64.
3. Reading br.raw directly afterward silently skips data
raw3 = io.BytesIO(b"0123456789" * 1000)
br3 = io.BufferedReader(raw3, buffer_size=64)
br3.peek(5)
print("br3.raw.read(5) right after peek:", br3.raw.read(5))
br3.raw.read(5) right after peek: b'45678'
Measured: .raw is a plain attribute on every BufferedReader, accessible with no detach() call at all. Reading 5 bytes from it right after a peek(5) returns b'45678', not the next unread bytes a caller would reasonably expect -- the first 64 bytes are already gone from the raw stream's perspective, 59 of which peek() never showed anyone.
4. The fix: never touch .raw after calling peek() (or any read) on the wrapper
raw4 = io.BytesIO(b"0123456789" * 1000)
br4 = io.BufferedReader(raw4, buffer_size=64)
br4.peek(5)
print("stay on the wrapper:", br4.read(10)) # correct: still goes through br4's buffer
stay on the wrapper: b'0123456789'
Measured: continuing to read through br4 itself returns the correct, expected bytes, because the wrapper's buffer (already holding the read-ahead data) is what actually gets consulted. Once any peek() or read() has happened on a BufferedReader, treat its .raw attribute as off-limits for direct reads -- there's no supported way to know how far ahead the buffer has already consumed.
Frequently asked questions
Does peek() itself lie about the stream position?
No -- we measured that BufferedReader.tell() stays correct and read() after peek() returns the right bytes. The mismatch is specifically between the wrapper's own bookkeeping (correct) and the raw stream's actual position (already advanced), which only becomes visible if you bypass the wrapper and touch .raw directly.
Is this the same bug as TextIOWrapper's detach() position mismatch?
Same root cause -- read-ahead buffering -- but a different trigger and a sharper version of it: TextIOWrapper requires an explicit detach() call before its raw stream is reachable, while BufferedReader exposes .raw as an always-present attribute, so there's no explicit step warning you that you're about to touch a stream with buffered-ahead state.
How much does a single peek() call actually read ahead?
We measured exactly buffer_size bytes (the BufferedReader's configured internal buffer size, 64 in this repro) consumed from the raw stream on the very first peek(), regardless of how many bytes were requested from peek() itself.
Is there a safe way to get back to the raw stream after using a BufferedReader?
Not directly -- we measured no API that reports how many bytes are sitting unread in the internal buffer versus consumed from raw. The safe pattern is to keep reading through the BufferedReader itself for the whole stream, rather than switching to .raw partway through.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in io.BufferedReader wrapping a io.BytesIO raw stream, comparing what peek() returns against what reading br.raw directly returns immediately afterward. PlainHeaders is our extension; Python's io module is built in.