io.TextIOWrapper Crashes on a Non-Blocking Raw Stream's None (tested)
Python's io module documents that a raw stream in non-blocking mode can return None from read()/readinto() to mean "no data available right now" -- distinct from returning 0/b'', which means real end-of-file. io.BufferedReader honors that contract: calling .read() on a buffered wrapper around such a raw stream correctly passes the None straight through. Stack a io.TextIOWrapper on top of that same BufferedReader -- the layer that gives you open(..., mode="r")'s text-mode .read() -- and the None signal is not handled at all: it crashes with a TypeError instead of returning None or raising something that explains what happened.
1. A raw stream that signals "no data yet" via None
import io
class FlakyRaw(io.RawIOBase):
def __init__(self):
self.calls = 0
def readable(self):
return True
def readinto(self, b):
self.calls += 1
if self.calls == 1:
return None # documented non-blocking "not ready" signal
return 0 # real EOF on the next call
raw = FlakyRaw()
This mirrors how a real non-blocking socket or pipe behaves: the first read attempt has nothing ready yet (None), a later one hits real end-of-file (0).
2. BufferedReader passes the None straight through
buffered = io.BufferedReader(FlakyRaw())
print(buffered.read())
None
Measured: BufferedReader.read() correctly returns None rather than crashing or blocking -- this is the documented, intended behavior for a non-blocking raw stream with no data currently available.
3. TextIOWrapper on the exact same stream crashes
raw2 = FlakyRaw()
text = io.TextIOWrapper(io.BufferedReader(raw2), encoding="utf-8")
try:
text.read()
except TypeError as e:
print("text.read() ->", e)
text.read() -> can't concat NoneType to bytes
Measured: wrapping the identical BufferedReader/raw-stream pair in TextIOWrapper and calling the text-mode .read() raises TypeError immediately, on the very first readinto() call -- it never reaches the second call that would return real EOF. The error message gives no hint that a non-blocking None signal is the cause; it reads like an internal bug, not an actionable error about stream readiness.
4. The fix: don't feed a non-blocking raw stream straight into TextIOWrapper
raw3 = FlakyRaw()
buffered3 = io.BufferedReader(raw3)
chunk = buffered3.read()
if chunk is None:
print("not ready yet -- retry later, don't wrap in TextIOWrapper until data exists")
else:
text3 = io.TextIOWrapper(io.BytesIO(chunk), encoding="utf-8")
print(text3.read())
not ready yet -- retry later, don't wrap in TextIOWrapper until data exists
Measured: checking for None at the BufferedReader layer -- before any TextIOWrapper is involved -- avoids the crash entirely. There's no public way to make TextIOWrapper itself tolerate a non-blocking raw stream; the non-blocking read has to be handled at the binary layer first.
Frequently asked questions
Why does TextIOWrapper.read() raise TypeError instead of returning None?
We measured that TextIOWrapper's internal chunk-reading logic assumes the underlying buffered read always returns bytes and tries to concatenate the result onto its internal buffer. When that result is None -- the documented signal a non-blocking raw stream uses for 'no data available yet' -- the concatenation itself is what raises TypeError: can't concat NoneType to bytes.
Does BufferedReader have the same problem as TextIOWrapper?
No. We measured BufferedReader.read() correctly returning None when its underlying raw stream's readinto() returns None -- it's specifically the TextIOWrapper layer on top that doesn't check for None before using the result.
Is a raw stream returning None from readinto() a bug?
No -- it's documented behavior for non-blocking I/O (e.g. a socket or pipe opened without blocking) to mean no data is currently available, as distinct from returning 0, which means real end-of-file. The crash is in how TextIOWrapper handles that documented signal, not in the raw stream returning it.
How do I safely read text from a non-blocking stream?
We measured that checking for None at the BufferedReader (binary) layer before any text decoding happens avoids the crash -- retry the binary read until it returns real bytes or real EOF, and only then hand that data to a TextIOWrapper or decode it directly.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in io module: subclassed io.RawIOBase with a readinto() that returns None once (the documented non-blocking 'no data available yet' signal), wrapped it in io.BufferedReader alone (clean None result) versus io.TextIOWrapper on top of that same BufferedReader (TypeError). PlainHeaders is our extension; Python's io module is built in.