io.TextIOWrapper.readline() Silently Treats None as EOF (tested)
Python's io module documents None from a raw stream's readinto() as a non-blocking "no data available right now" signal, distinct from real end-of-file. A separately-measured finding already showed TextIOWrapper.read() crashing with TypeError on that signal. We measured what .readline() and plain iteration (for line in f) do on the identical setup: neither crashes. Both silently treat the transient None as end-of-file -- readline() returns '', and iteration raises StopIteration immediately -- even though a complete, valid line of text is sitting one read call away. Two methods on the same class handle the exact same signal in two opposite, equally broken ways: one is a loud crash, the other is a silent, undetectable data loss.
1. The source: real text, one call away, behind a single transient None
import io
class FlakyRaw(io.RawIOBase):
def __init__(self, payload):
self.calls = 0
self.sent = False
self.payload = payload
def readable(self):
return True
def readinto(self, b):
self.calls += 1
if self.calls == 1:
return None # transient: no data ready yet
if not self.sent:
self.sent = True
n = len(self.payload)
b[:n] = self.payload
return n # the real text data, one call later
return 0 # real EOF after that
payload = b"a,b,c\n1,2,3\n"
2. readline() returns '' -- identical to a real empty file
raw1 = FlakyRaw(payload)
tw1 = io.TextIOWrapper(io.BufferedReader(raw1))
print("readline():", repr(tw1.readline()))
print("raw calls made:", raw1.calls)
readline(): ''
raw calls made: 1
Measured: readline() made exactly one call to the raw stream, got None, and returned '' -- the same value Python uses for a genuinely empty file. No exception, no retry, and the real line of text ("a,b,c\n") is simply never read.
3. Iteration stops immediately -- no StopIteration-worthy condition actually exists
raw2 = FlakyRaw(payload)
tw2 = io.TextIOWrapper(io.BufferedReader(raw2))
it = iter(tw2)
try:
print(next(it))
except StopIteration:
print("StopIteration raised immediately")
print("raw calls made:", raw2.calls)
StopIteration raised immediately
raw calls made: 1
Measured: the standard for line in f: idiom stops on the very first iteration, having made only one raw read call. Code written this way would silently process zero lines and move on -- no error, no log entry, nothing to signal that the stream ever had data in it at all.
4. Contrast: .read() on the identical setup crashes instead
raw3 = FlakyRaw(payload)
tw3 = io.TextIOWrapper(io.BufferedReader(raw3))
try:
tw3.read()
except TypeError as e:
print(".read() ->", type(e).__name__, e)
.read() -> TypeError can't concat NoneType to bytes
Measured: same class, same raw stream, same transient None -- but .read() hits a different internal code path that concatenates the result directly, producing a loud crash instead of a silent empty result. A codebase that mixes .read() in some places and .readline()/iteration in others would see a hard crash from one and complete silent data loss from the other, for the exact same underlying condition.
5. Real-world consequence: csv.reader silently returns zero rows
import csv
raw4 = FlakyRaw(payload)
tw4 = io.TextIOWrapper(io.BufferedReader(raw4))
rows = list(csv.reader(tw4))
print("rows:", rows)
print("raw calls made:", raw4.calls)
rows: []
raw calls made: 1
Measured: csv.reader iterates its underlying file object line by line, so it inherits TextIOWrapper's iteration behavior directly: a transient non-blocking hiccup on the very first read makes csv.reader report zero rows for a file that genuinely has two, with no exception anywhere in the chain.
6. The fix: resolve None at the binary layer before any text reading
raw5 = FlakyRaw(payload)
buffered = io.BufferedReader(raw5)
chunk = buffered.read()
while chunk is None:
chunk = buffered.read() # retry: not ready yet, not EOF
text = io.TextIOWrapper(io.BytesIO(chunk), encoding="utf-8")
print("safe rows:", list(csv.reader(text)))
safe rows: [['a', 'b', 'c'], ['1', '2', '3']]
Measured: checking for None and retrying at the BufferedReader (binary) layer, before any TextIOWrapper or csv.reader is involved, reads both lines correctly. There is no way to make .readline(), iteration, or .read() themselves tolerate a non-blocking raw stream -- the retry has to happen one layer below, in binary mode.
Frequently asked questions
Why does .read() crash but .readline() doesn't?
We measured that TextIOWrapper's internal .read() path concatenates the buffered result directly onto its decode buffer, so a None blows up immediately as a TypeError. .readline() and iteration go through a different internal path that checks for an empty-ish result to decide whether a line is complete or the stream ended, and None satisfies that check the same way a genuine b'' would -- so it's read as 'nothing more to give,' not 'not ready yet.'
Is this the same bug as the already-documented TextIOWrapper.read() TypeError?
Related, not identical. That finding showed .read() crashing loudly on the same None signal. This finding shows that .readline() and iteration on the exact same stream don't crash at all -- they fail silently instead, which is arguably worse since there's no exception to even notice.
Does this affect readlines() (plural) too?
We measured .readline() and iteration directly, both of which readlines() and most line-based consumers (including csv.reader, as shown above) rely on internally; any API built on top of TextIOWrapper's line-reading path inherits the same silent-EOF behavior on a transient None.
What's the safe general rule here?
We measured that the only reliable fix is to never hand a non-blocking raw stream directly to a TextIOWrapper, csv.reader, or any line-based reader. Resolve the None/EOF distinction at the binary BufferedReader layer first, and only construct the text layer once you have real, complete bytes in hand.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in io.TextIOWrapper wrapping an io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once (no data ready yet) before returning real text data, comparing .read(), .readline() and for-loop iteration on identically-configured wrappers. PlainHeaders is our extension; Python's io module is built in.