io.TextIOWrapper tell()/seek() Cookie Arithmetic Silently Skips Characters (tested)
io.TextIOWrapper.tell() returns a plain Python int, which invites treating it like a byte offset you can do arithmetic on -- for example, subtracting a few to "go back a little" or adding one to "skip ahead". The documentation calls it an opaque number, but doesn't spell out what actually happens if you ignore that: on UTF-8 text with multi-byte characters, adding 1 to a real cookie can land you mid-character and raise a loud UnicodeDecodeError -- but adding 2 can land you exactly on the start of the next character, which is a perfectly valid position. seek() accepts it with no error or warning, and the character in between is simply never read, with nothing indicating a character went missing.
1. The only supported pattern: seek back to an exact tell() value
import io
s = "héllo wörld"
buf = io.BytesIO(s.encode("utf-8"))
t = io.TextIOWrapper(buf, encoding="utf-8")
t.read(7) # consumes "héllo w"
cookie = t.tell()
t.seek(cookie) # seek back to the exact cookie value
print(t.read())
örld
Measured: seeking to the exact value tell() returned round-trips correctly -- this is the only pattern the documentation actually supports.
2. Adding 1 to the same cookie crashes loudly
buf2 = io.BytesIO(s.encode("utf-8"))
t2 = io.TextIOWrapper(buf2, encoding="utf-8")
t2.read(7)
cookie2 = t2.tell()
try:
t2.seek(cookie2 + 1)
print(t2.read())
except UnicodeDecodeError as e:
print("cookie+1 ->", e)
cookie+1 -> 'utf-8' codec can't decode byte 0xb6 in position 0: invalid start byte
Measured: cookie + 1 lands one byte into the two-byte UTF-8 encoding of ö, which isn't a valid character start -- seek() itself succeeds (it doesn't validate), but the next read() raises UnicodeDecodeError. This case is at least loud: something clearly broke.
3. Adding 2 to the same cookie succeeds and silently skips a character
buf3 = io.BytesIO(s.encode("utf-8"))
t3 = io.TextIOWrapper(buf3, encoding="utf-8")
t3.read(7)
cookie3 = t3.tell()
t3.seek(cookie3 + 2)
print(repr(t3.read()))
'rld'
Measured: cookie + 2 happens to land exactly on the byte where the character after ö starts -- a perfectly valid position as far as the decoder is concerned. seek() raises nothing, read() raises nothing, and the result is simply missing the ö character, with no indication anywhere that a character was skipped. Whether nudging a cookie crashes or silently corrupts depends entirely on exactly where the arithmetic happens to land relative to character boundaries in the underlying encoding.
4. The fix: never do arithmetic on a tell() cookie
buf4 = io.BytesIO(s.encode("utf-8"))
t4 = io.TextIOWrapper(buf4, encoding="utf-8")
t4.read(7)
checkpoint = t4.tell() # save the exact cookie
t4.read(2) # move forward by reading, not by math
t4.seek(checkpoint) # go back using the saved cookie, unmodified
print(t4.read())
örld
Measured: storing the exact value tell() returned and passing it back to seek() unmodified round-trips correctly every time. To move by a specific number of characters, use read(n) to advance or re-open/re-seek from a known-good cookie -- never add or subtract from the cookie itself.
Frequently asked questions
Is TextIOWrapper.tell() allowed to return something other than a byte offset?
Yes -- we measured it returning values that matched the UTF-8 byte offset in our simple test, but the documentation explicitly calls it an opaque number, and CPython's implementation can encode extra decoder state in it for some encodings. Treat it as an opaque token, not a byte count, even when it happens to look like one.
Does seek() validate the cookie before accepting it?
We measured no validation at seek() time -- it accepts any integer. The decode error, when one happens, only surfaces on the next read() call, and some invalid-as-intended values don't error at all because they happen to coincide with a valid character boundary.
Why did adding 2 not raise an error if the cookie is opaque?
We measured that byte offset 2 past the start of the 2-byte character \u00f6 lands exactly on the start of the following single-byte character -- the decoder has no way to know a character was skipped, because from its position alone that's a completely valid place to resume decoding.
Is this specific to multi-byte characters?
The silent-skip failure mode needs a multi-byte character to land in the middle of, so ASCII-only text won't show it this way -- but the underlying problem, treating an opaque cookie as arithmetic-safe, isn't specific to any one encoding and isn't guaranteed safe for ASCII either, since the cookie format isn't part of the documented contract.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in io.TextIOWrapper, reading from a utf-8-encoded io.BytesIO containing multi-byte characters, comparing exact tell()-returned cookies against the same value plus or minus a small integer. PlainHeaders is our extension; Python's io module is built in.