Test Text Streams with Python StringIO
Pass a StringIO stream into a function and rewind it before a second read.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Test Text Streams with Python StringIO
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
Read an in-memory text stream twice. Will the second read print Ada and Grace again, or an empty string? The first run reveals a cursor bug without writing any file to disk. The broken terminal shows the full text in its first repr call and an empty string in its second. StringIO has not lost Ada or Grace. read moved its cursor to the end, and the second read starts there. This is the same behavior that surprises code reading a real file object twice. This is easy to reproduce in a test suite: one assertion inspects a StringIO object by reading it, then passes that same object to the function under test.
- 00:41
The function receives a stream already at EOF and appears broken, even though its own loop is correct. Recreating the object or rewinding it before the second consumer fixes the test setup. The important interface is not just the stored characters; it includes a mutable position. A helper that accepts a stream should document whether it consumes that stream and whether the caller may reuse it afterward. The diagnosis prints the cursor position before reading, after reading, and then getvalue. The position moves from zero to the text length, while getvalue still contains the original string. That contrast separates stored content from current read position.
- 01:23
A caller can seek back to zero when it wants another pass. Seeking to zero after this read would make the next call begin at Ada again; seeking to four in this ASCII sample would begin at Grace. These moves change position without editing getvalue. You can test that with tell before and after each operation. Avoid assuming every text-stream position is a UTF-8 byte index: encoded file streams may use opaque positions, while this in-memory ASCII example makes the numbers look simple. A consumer that only needs the original buffer can use getvalue, but doing so bypasses the current cursor and changes the function's intended stream behavior.
- 02:06
The corrected count_lines iterates the text stream once, yielding two lines for Ada and Grace. It then calls seek zero and counts again, printing two a second time. That is a clearer contract than hiding a rewind inside count_lines, because callers can see when a stream is consumed. The generator counts one yielded line at a time instead of allocating a list of all lines, so its extra memory does not grow with the document length. It deliberately consumes the stream. That design also works for a nonseekable source, such as a pipe, as long as the caller only needs one pass. StringIO lets a unit test exercise that same text-stream interface without
- 02:47
creating a temporary file. A test can pass an in-memory stream to a helper typed as a text reader and inspect its return value; the helper does not need to know whether bytes originally came from disk. If repeatability matters, make a fresh StringIO for each test or explicitly reset a seekable one. Do not have a reusable helper quietly rewind a caller-owned stream after counting. The cursor also controls writes. In a fresh StringIO containing the two lines Ada and Grace, seek to zero and write Eve. Those three characters replace Ada; they do not insert a new line or shift the rest of the buffer.
- 03:29
To append instead, seek to the end before writing. This matters in tests that reuse one stream for both reading and writing: a helper can appear to return the right count while silently replacing earlier text. If callers provide the stream, its write position and ownership should be as explicit as its read position. The final run counts an empty stream as zero and a two-line string without a final newline as two. Splitting on newline and measuring the resulting list would give the wrong zero count for empty text and can create a misleading extra item at a trailing newline. StringIO is for text; BytesIO serves byte- oriented code.
- 04:11
Imagine a parser reads one header line before handing the stream to count_lines. The counter sees only the remaining lines. That is useful if it is counting records and wrong if it is supposed to count the entire document. A test should place the cursor deliberately and assert the chosen contract. Blank lines count here because iteration yields them; if the real requirement is nonblank records, add a predicate that checks stripped content rather than changing the stream mechanics. An empty stream yields no lines, while a final line without a newline is still yielded once. Be careful with text-file positions: for encoded files, a value returned by tell
- 04:51
may be an opaque cookie, so use it with seek rather than treating it as a UTF-8 byte offset. The stream's position belongs to the caller's observable state; document who may advance or reset it. End of file is different from a closed stream. After read reaches the end, seek zero can make the original content readable again. After close, read, seek, and getvalue raise ValueError. A with block closes StringIO when the block exits, which is useful cleanup, but a test that needs the collected output must call getvalue before leaving that block. An empty read at EOF is a normal result; the exception from a closed object signals
- 05:33
that the stream can no longer be used. Testing both states avoids confusing an exhausted parser input with a lifecycle bug.
Understand the concept
A parser accepts a text file-like object, but a small test should not have to create a real file on disk. io.StringIO holds text in memory and offers familiar read, write, seek, and getvalue methods. It is useful for exercising stream-oriented code quickly and deterministically.
The browser starter reads the stream and splits on newline characters; a trailing newline creates an extra empty item, so it incorrectly counts three lines. The video shows a separate cursor mistake: reading a StringIO advances its position, so a second read without seek(0) returns an empty string. Iterating the stream once counts logical lines and leaves the cursor at the end; the caller can rewind it when needed.
Print tell() before and after read() to see the cursor advance. Then call seek(0) and read again. A function that receives a stream should document whether it consumes the stream or restores the caller's position. This lesson chooses a simple function that consumes input once and lets the caller decide when to rewind. Implement count_lines(stream) as a single pass over the provided text stream. StringIO makes it easy to test that behavior without filesystem setup. To repeat the call on the same object, explicitly rewind with seek(0), then compare the count again.
An empty in-memory stream has zero lines. A trailing newline does not create an extra empty line during iteration, so count_lines should count actual iterator items rather than assume split(' ') has the desired semantics. The examples show both the cursor and the line-count rule. StringIO is a text stream, not a byte stream; use BytesIO when code expects bytes. It is also a test aid, not evidence that a function handles disk permissions or very large files. Those are separate integration concerns.
- A parser accepts a text file-like object, but a small test should not have to create a real file on disk.
- Reading a StringIO stream advances its cursor.
- Implement count_lines(stream) as a single pass over the provided text stream.
- An empty in-memory stream has zero lines.
See it step by step
Read the code, predict the output, then compare it with the result.
01. Observe the cursor
from io import StringIO
s = StringIO('Ada\nGrace\n')
print(s.tell())
print(s.read().count('\n'))
print(s.tell())
0
2
10The cursor moves from zero to the end after the read.
02. Rewind and read again
from io import StringIO
s = StringIO('Ada\n')
s.read()
s.seek(0)
print(s.read().strip())
Adaseek(0) makes the original text available for a second pass.
03. Count logical lines by iteration
from io import StringIO
print(sum(1 for _ in StringIO('Ada\nGrace\n')))
print(sum(1 for _ in StringIO('')))
2
0Iteration counts two actual lines and zero lines for an empty stream.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Observe the first result
A parser accepts a text file-like object, but a small test should not have to create a real file on disk. io.StringIO holds text in memory and offers familiar read, write, seek, and getvalue methods. It is useful for exercising stream-oriented code quickly and deterministically.
The cursor moves from zero to the end after the read. Print tell() before and after read() to see the cursor advance. Then call seek(0) and read again. A function that receives a stream should document whether it consumes the stream or restores the caller's position. This lesson chooses a simple function that consumes input once and lets the caller decide when to rewind.
from io import StringIO
s = StringIO('Ada\nGrace\n')
print(s.tell())
print(s.read().count('\n'))
print(s.tell())
0
2
10- Predict the first sample's printed value and type.
- Run it, then compare with the expected output.
Change the rule deliberately
The browser starter reads the stream and splits on newline characters; a trailing newline creates an extra empty item, so it incorrectly counts three lines. The video shows a separate cursor mistake: reading a StringIO advances its position, so a second read without seek(0) returns an empty string. Iterating the stream once counts logical lines and leaves the cursor at the end; the caller can rewind it when needed.
seek(0) makes the original text available for a second pass. Implement count_lines(stream) as a single pass over the provided text stream. StringIO makes it easy to test that behavior without filesystem setup. To repeat the call on the same object, explicitly rewind with seek(0), then compare the count again. Use sum(1 for _ in stream). Do not split the entire contents merely to count lines.
from io import StringIO
s = StringIO('Ada\n')
s.read()
s.seek(0)
print(s.read().strip())
Ada- Identify the line that changes the rule.
- Run the corrected sample with the same input.
Verify the boundary
Print tell() before and after read() to see the cursor advance. Then call seek(0) and read again. A function that receives a stream should document whether it consumes the stream or restores the caller's position. This lesson chooses a simple function that consumes input once and lets the caller decide when to rewind. Implement count_lines(stream) as a single pass over the provided text stream. StringIO makes it easy to test that behavior without filesystem setup. To repeat the call on the same object, explicitly rewind with seek(0), then compare the count again.
Iteration counts two actual lines and zero lines for an empty stream. An empty in-memory stream has zero lines. A trailing newline does not create an extra empty line during iteration, so count_lines should count actual iterator items rather than assume split(' ') has the desired semantics. The examples show both the cursor and the line-count rule. StringIO is a text stream, not a byte stream; use BytesIO when code expects bytes. It is also a test aid, not evidence that a function handles disk permissions or very large files. Those are separate integration concerns.
from io import StringIO
print(sum(1 for _ in StringIO('Ada\nGrace\n')))
print(sum(1 for _ in StringIO('')))
2
0- Try the documented edge case.
- Explain why the output satisfies the function contract.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Use sum(1 for _ in stream). Do not split the entire contents merely to count lines.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
The visible failure
stream.read(); stream.read()stream.read(); stream.seek(0); stream.read()What happens: Reading a StringIO stream advances its cursor.
Implement count_lines(stream) as a single pass over the provided text stream. StringIO makes it easy to test that behavior without filesystem setup. To repeat the call on the same object, explicitly rewind with seek(0), then compare the count again.
Missing the edge condition
len(stream.read().split('\n'))sum(1 for _ in stream)What happens: An empty in-memory stream has zero lines.
StringIO is a text stream, not a byte stream; use BytesIO when code expects bytes. It is also a test aid, not evidence that a function handles disk permissions or very large files. Those are separate integration concerns.