On this article, you’ll learn the way Python’s iterators and mills can be utilized to construct memory-efficient knowledge processing pipelines that deal with massive information with out loading them into reminiscence .
Matters we’ll cowl embrace:
- How the iterator protocol works and why it’s the basis of lazy, streaming knowledge processing in Python.
- Learn how to construct a multi-stage pipeline utilizing generator capabilities and generator expressions that course of one document at a time.
- Learn how to measure peak reminiscence utilization with
tracemallocto confirm {that a} pipeline is genuinely lazy and determine the place keen materialisation creeps again in.

The script that summarizes the funds log ran high-quality for a yr. It opened the file, constructed an inventory of parsed data, filtered the errors and printed a desk. Then log rotation modified, per week of visitors landed in a single 9 GB file, and the nightly job died with a MemoryError earlier than it had summarized something. Nothing within the parsing logic was improper. The bug was the record.
|
with open(“app.log”) as f: data = [parse(line) for line in f] # each document exists directly
errors = [r for r in records if r[“level”] == “ERROR”] sluggish = [r for r in errors if int(r[“latency_ms”]) > 1000] |
No person would flag any of these strains in evaluation. Collectively they construct three lists, and the primary one has to carry each document within the file earlier than the filter can begin. If every step solely wants one document at a time, why make each document exist directly? Python has had the instruments to keep away from that for over twenty years. Iterators, generator capabilities, and generator expressions let a pipeline pull one document by way of each stage and launch it earlier than the subsequent one arrives. The trick is maintaining the entire path lazy moderately than sprinkling yield into one operate. Lazy has to carry from the file object on the high to the batch written on the backside, as a result of a single keen step in between brings the record again.
The tutorial stays in pure Python. Pandas chunking, Spark, Dask, and async streams all remedy neighbouring issues. None of them is required for a log file, an export, or a line-oriented dataset dump that matches on disk however not in RAM.
Iterator vs Iterable: The Contract That Makes Streaming Work
Python’s for loop hides a small contract, and the entire streaming story is dependent upon it. An iterable is one thing you may ask for an iterator: an inventory, a string, a dictionary, an open file. The iterator is the factor that truly remembers the place you might be. When for line in f runs, Python calls iter(f) as soon as, then calls subsequent() on the consequence time and again till it raises StopIteration. StopIteration is the loop’s regular ending, not an error. The iterator protocol is simply two strategies. __iter__() arms again the iterator itself, and __next__() both produces the subsequent merchandise or raises StopIteration as a result of there isn’t one.
|
numbers = iter([10, 20, 30])
subsequent(numbers) # 10 subsequent(numbers) # 20 subsequent(numbers) # 30 subsequent(numbers) # raises StopIteration |
The record nonetheless holds all three values. The iterator will not be a replica of it however a cursor over it, and the cursor solely strikes ahead. That one property is what makes streaming attainable, as a result of a cursor over a 9 GB file wants a small learn buffer moderately than 9 GB of strings. The forward-only cursor can be what catches individuals out. As soon as an iterator has raised StopIteration, the documentation is blunt: it should hold elevating it on subsequent calls. An exhausted iterator doesn’t rewind. When a generator seems to return nothing the second time a operate loops over it, it wasn’t empty. The primary loop consumed it, and the primary loop is usually a len() examine, a debug print, or a record() name somebody added whereas investigating. The repair is to iterate as soon as, or to name the operate that creates the generator once more.
Begin With the File Object Earlier than Writing a Generator
Earlier than writing any generator code, take the reminiscence win the usual library already provides away. A file object is its personal iterator, and the tutorial’s description of looping over it’s that it’s reminiscence environment friendly, quick, and results in easy code. f.learn() pulls your complete file into one string, and f.readlines() builds an inventory with one string per line. for line in f reads a buffered chunk at a time and arms you one line.
|
# keen: the entire file turns into an inventory of strings first with open(“app.log”) as f: strains = f.readlines() for line in strains: deal with(line)
# lazy: one line in reminiscence at a time with open(“app.log”) as f: for line in f: deal with(line) |
For a log with a timestamp, a degree, a service title, and a handful of key=worth fields, that’s already sufficient to parse and discard irrelevant strains with out holding something. The parser sees one line, decides whether or not the extent or the service issues, and strikes on. Discover what didn’t change: the algorithm. Line-by-line studying solves the reminiscence drawback solely when the work is itself line-by-line. If the abstract wants each document sorted by latency, or deduplicated throughout the entire week, a file iterator alone doesn’t rescue it. Pretending in any other case is how the record comes again at step two.
Flip Parsing Steps Into Generator Features
The file iterator handles studying, however parsing and filtering nonetheless stay in a single loop physique, which is the place scripts develop lengthy and untestable. A generator operate splits them into named phases that every stream. Any operate containing yield turns into a generator operate. Name it and nothing within the physique runs but. What comes again is a generator object, and the physique advances one yield at a time because the caller asks for values. That’s simple to overlook the primary time a print assertion inside a generator by no means fires.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import re
LINE = re.compile(r“^(?P
def read_lines(paths): for path in paths: with open(path, encoding=“utf-8”) as f: yield from f
def parse_records(strains): for line in strains: match = LINE.match(line) if match is None: proceed # skip, depend or log it; by no means cease the stream document = match.groupdict() document[“fields”] = dict( merchandise.cut up(“=”, 1) for merchandise in document.pop(“relaxation”).cut up() if “=” in merchandise ) yield document
def only_level(data, degree): for document in data: if document[“level”] == degree: yield document |
read_lines makes use of yield from at hand each line of every rotated file as much as the caller. Three log information due to this fact behave like one stream, and every file is closed earlier than the subsequent one opens. parse_records turns a line right into a dictionary and yields it, only_level passes by way of those that matter, and not one of the three is aware of or cares how massive the enter is.
What makes this work is suspension. When parse_records reaches yield, the language reference says all native state is retained: the present bindings of native variables, the instruction pointer, the inner analysis stack. The operate is paused mid-loop with match and document nonetheless in scope, and it resumes on the subsequent request as if yield had been an unusual name. Suspension at yield is why a generator holds one document at a time. File dimension by no means enters into it. The operate hasn’t reached the subsequent line of the file, so the subsequent document doesn’t exist but, in reminiscence or anyplace else. When the loop runs out and the operate returns, the generator raises StopIteration on the caller’s behalf, and the phases stacked above it drain out one after one other.
Every stage may also be examined by itself with an inventory of three strains. That could be a higher deal than testing a forty-line loop physique by operating it in opposition to manufacturing knowledge.
Maintain the Center of the Pipeline Lazy
Wire the phases collectively and the pipeline turns into a sequence of assignments that do no work till the tip:
|
from itertools import islice
strains = read_lines([“app.log.2”, “app.log.1”, “app.log”]) data = parse_records(strains) errors = only_level(data, “ERROR”) sluggish = (r for r in errors if int(r[“fields”].get(“latency_ms”, 0)) > 1000) rows = ((r[“ts”], r[“service”], r[“fields”].get(“request_id”, “-“)) for r in sluggish)
for row in islice(rows, 5): print(row) |
The 2 parenthesized expressions are generator expressions, the one-line cousins of generator capabilities. The place an inventory comprehension in sq. brackets builds the entire record, the identical expression in parentheses builds a generator that evaluates one factor per request. They belong within the small transformations between named phases: a filter with one situation, a projection right down to the fields the sink wants.
One level about laziness is price realizing exactly: the language reference states that the iterable within the leftmost for clause is evaluated instantly, and every part else lazily. So (r for r in errors if ...) evaluates the title errors instantly, which is innocent as a result of errors is already a generator. Write (r for r in load_everything() if ...) and load_everything() runs in full in the meanwhile the expression is outlined, not when the primary worth is requested. The generator expression didn’t make the supply lazy, solely the filtering.
islice is the device for trying with out consuming. That five-row loop is the quickest sanity examine obtainable: 5 rows come by way of the entire chain, the loop stops, and the opposite 9 GB by no means will get touched. Run the five-row examine earlier than each different change to the pipeline. Most of itertools follows the identical rule of returning an iterator moderately than an inventory, so chain can glue two sources collectively and takewhile can reduce a stream off at a situation with out both one holding something. Use them after they exchange a stage you’d in any other case write by hand, and like a plain generator operate with title at any time when that reads higher, which it usually does.
The traps sit on the different finish of the chain, and most of them appear like tidy code. record(rows) is the plain one. sorted(rows, key=...) is identical entice with higher manners, since a kind can’t return its first factor till it has seen the final. A dictionary keyed by request_id, for counting or deduplication, grows with the variety of distinct IDs. In a funds log that’s near the variety of data, so it’s the record from the opening in a distinct container. Then there are the library calls that materialise in your behalf: construct a DataFrame from data, or name json.dumps(record(rows)), and your complete stream is sitting in reminiscence below a friendlier title. Velocity is a separate query. Mills aren’t quicker than lists, and sum() over a generator does the identical work as sum() over an inventory, generally somewhat extra. The profit is bounded reminiscence and phases you may rearrange, which is a distinct promise from velocity.
The 4 phases of the pipeline and what every one holds at any second. Each stage passes one document alongside and retains nothing; solely the sink materialises, and solely a batch at a time. Alt textual content: left-to-right diagram with a log file feeding a file iterator, a parse generator, a filter and projection generator expression, and a bounded batch sink, every labelled with the reminiscence it holds. Credit score: unique diagram based mostly on the Python iterator, yield expression and itertools documentation.
Batch Solely on the Boundary
Laziness will not be a faith. Someplace the rows must land: a file, a database, a mannequin that scores them, an API that accepts a POST. Most of these customers need a group moderately than a single row, and a bounded group is the proper factor to materialise. The rule is that the batch has a dimension you selected, and nothing upstream of it’s ever complete.
Since Python 3.12 the usual library has had itertools.batched(), which is that this sample carried out correctly. It pulls simply sufficient from the enter to fill a tuple of n, arms the tuple over, and solely then pulls the subsequent one, with a shorter ultimate tuple when the enter runs out.
|
from itertools import batched # Python 3.12+
def write_batches(rows, path, dimension=1000): with open(path, “w”, encoding=“utf-8”) as out: for batch in batched(rows, dimension): out.writelines(f“{ts}t{service}t{rid}n” for ts, service, rid in batch) out.flush()
write_batches(rows, “slow_errors.tsv”) |
On 3.11 or earlier, the identical behaviour is a four-line recipe over islice:
|
def batched(iterable, n): it = iter(iterable) whereas batch := tuple(islice(it, n)): yield batch |
A batch of a thousand rows is, after all, nonetheless a list-shaped object sitting in reminiscence. The distinction from the opening script is {that a} thousand is a quantity you picked. It stays a thousand whether or not the file has ten thousand strains or a billion, and the tuple is rubbish the second the subsequent one arrives. Identify the boundary within the code as properly. A operate referred to as write_batches or send_batches tells the subsequent reader the place the stream intentionally stops being a stream. That issues greater than it sounds when somebody provides a sorted() to it six months later. Small knowledge is the opposite legit record. For a file that matches comfortably in reminiscence and desires random entry or a number of passes, an inventory is the fitting construction and a generator is ceremony.
One sincere caveat: some jobs can’t be streamed, and no quantity of generator plumbing adjustments that. A worldwide type, actual deduplication throughout the entire file, a percentile over each latency worth: something the place the reply for the primary document is dependent upon the final wants each document. The alternate options are an algorithm that approximates from a pattern, or an exterior device that kinds on disk. Recognise that early, as a result of a pipeline that streams superbly right into a sorted() on the finish has the unique reminiscence bug with extra capabilities in entrance of it.
Measure the Reminiscence Story Earlier than Trusting It
A pipeline that appears lazy and a pipeline that’s lazy are completely different objects, and the one option to inform them aside is to measure. The usual library’s tracemalloc traces the reminiscence blocks Python allocates and stories the present and peak dimension as a two-value tuple.
|
import tracemalloc
tracemalloc.begin() write_batches(rows, “slow_errors.tsv”) present, peak = tracemalloc.get_traced_memory() tracemalloc.cease() print(f“present {present / 1e6:.1f} MB, peak {peak / 1e6:.1f} MB”) |
Of the 2, peak is the one to observe. Present reminiscence after the job finishes tells you whether or not one thing leaked, which is beneficial however a distinct query. Peak is what the method wanted at its worst second, and it’s the quantity a container restrict or an OOM killer compares in opposition to. Working each variations in opposition to an artificial 200,000-line log of about 17 MB on one laptop computer, the keen script from the opening peaked at 198 MB. The streaming model peaked at 0.4 MB, and that second quantity barely strikes because the file grows. These are one run’s figures moderately than a benchmark, and the form of the comparability is the purpose: the keen peak scales with the file, the lazy peak scales with the batch.
Two caveats hold the measurement grounded. tracemalloc sees allocations made by way of Python’s allocators, so a C extension managing its personal buffers, the working system’s file cache, and something occurring in one other course of are outdoors its view. Tracing additionally has a value of its personal, which grows with the variety of frames you ask it to retailer. For a whole-process determine, watch resident reminiscence from the surface as properly.
When peak reminiscence refuses to fall after a refactor, the trigger is sort of at all times one in all two issues. Both a reference is escaping the stream, or a downstream name is materialising it internally. The escaping reference is usually an inventory or dictionary outdoors the generator that retains each document alive, comparable to a seen set of request IDs or a debug record of fascinating rows. Test the three locations so as. The supply ought to be a file iterator and never a readlines(). No stage within the center ought to return an inventory or name sorted() on the stream. Lastly, the sink ought to settle for an iterable moderately than demand a sequence, which is a query price asking of any library the rows are handed to.
The code shapes that materialise a stream and those that hold it bounded, with the case the place every is suitable. Alt textual content: four-column desk evaluating readlines, record comprehensions, sorted and dictionary accumulation in opposition to file iteration, generator capabilities, generator expressions and bounded batches, with what every shops, its reminiscence danger and when it’s acceptable. Credit score: unique desk based mostly on the Python iterator, itertools and tracemalloc documentation.
Maintain Laziness All of the Means By the Knowledge Path
The working rule is staged. Iterate the file object earlier than writing any code of your individual. Flip parsing and filtering into generator capabilities with names. Compose the small transformations with generator expressions and itertools, remembering that the leftmost iterable runs instantly. Materialise solely on the sink, in batches of a dimension you selected, inside a operate whose title says so. Then measure the height, as a result of one keen step anyplace in that chain — a stray record(), a sorted(), a library that wishes a sequence — is the entire reminiscence bug again once more.
The payoff extends previous logs. The identical 4 phases deal with a CSV export that outgrew the spreadsheet it got here from, an occasion dump pulled from a message queue, or the JSONL file feeding a preprocessing or fine-tuning step. That final case is the place most “the dataset doesn’t match” issues in machine studying work really begin. Streaming is about how data transfer, and as soon as a document has been parsed and trusted, structured utility knowledge is a separate query about what it turns into.
An excellent streaming pipeline can be a readable one. Somebody opening the module ought to be capable of level at the place knowledge enters, the place it’s filtered, the place it’s remodeled, and the one place it’s deliberately made complete. If they’ll’t, the subsequent reminiscence bug is already within the module; it simply hasn’t met a big sufficient file.

