Python Lazy Evaluation
last modified February 24, 2025
Lazy evaluation is a programming technique where the evaluation of an expression is delayed until its value is actually needed. This can lead to significant performance improvements, especially when working with large datasets or computationally expensive operations. In this tutorial, we will explore lazy evaluation in Python using generators and compare it with non-lazy approaches using profiling.
Generating Fibonacci Sequence
This example demonstrates the difference between lazy and non-lazy approaches for generating a Fibonacci sequence.
import time import itertools from memory_profiler import memory_usage # Non-lazy approach def fibonacci_non_lazy(n): result = [] a, b = 0, 1 for _ in range(n): result.append(a) a, b = b, a + b return result # Lazy approach def fibonacci_lazy(n): a, b = 0, 1 for _ in range(n): yield a a, b = b, a + b # Profiling def profile_non_lazy(): start_time = time.time() result = fibonacci_non_lazy(100_000) # Print the first 20 elements for e in result[:20]: print(e, end=' ') print() duration = time.time() - start_time return duration def profile_lazy(): start_time = time.time() slice = itertools.islice(fibonacci_lazy(100_000), 20) # Print the first 20 elements for e in slice: print(e, end=' ') print() duration = time.time() - start_time return duration def profile_non_lazy_memory(): result = fibonacci_non_lazy(100_000) # Monitor memory usage in the loop for e in memory_usage((print, [result[:100]])): pass def profile_lazy_memory(): slice = itertools.islice(fibonacci_lazy(100_000), 100) # Monitor memory usage in the loop for e in memory_usage((print, [list(slice)])): pass if __name__ == "__main__": # Profile non-lazy and lazy approaches with print non_lazy_me = memory_usage((profile_non_lazy_memory, )) print('-------------------------------------') lazy_mem = memory_usage((profile_lazy_memory, )) # Profile without print statements non_lazy_delta = profile_non_lazy() lazy_delta = profile_lazy() print(f"Non-lazy approach: {non_lazy_me[0]} MiB used in {non_lazy_delta:.2f} seconds") print('-------------------------------------') print(f"Lazy approach: {lazy_mem[0]} MiB used in {lazy_delta:.2f} seconds")
In this example, the non-lazy approach generates the entire Fibonacci sequence and stores it in a list, while the lazy approach uses a generator to yield values on-the-fly. The lazy approach is more memory-efficient and faster for large sequences.
The range function
The built-in range
function is evaluated lazily.
import time from memory_profiler import memory_usage # Non-lazy custom range function def custom_non_lazy_range(start, end): result = [] current = start while current < end: result.append(current) current += 1 return result # Profiling functions def profile_builtin_range(): start_time = time.time() result = range(1_500_000) # Print the first 3000 elements for e in result[:3000]: print(e, end=' ') print() duration = time.time() - start_time return duration def profile_custom_non_lazy_range(): start_time = time.time() result = custom_non_lazy_range(0, 1_500_000) # Print the first 3000 elements for e in result[:3000]: print(e, end=' ') print() duration = time.time() - start_time return duration if __name__ == "__main__": # Profile built-in range and custom non-lazy range builtin_range_memory = memory_usage((profile_builtin_range, )) print('-------------------------------------') custom_non_lazy_range_memory = memory_usage((profile_custom_non_lazy_range, )) # Print memory usage and durations builtin_range_duration = profile_builtin_range() custom_non_lazy_range_duration = profile_custom_non_lazy_range() print(f"Built-in range: {builtin_range_memory[0]} MiB used in {builtin_range_duration:.2f} seconds") print('-------------------------------------') print(f"Custom non-lazy range: {custom_non_lazy_range_memory[0]} MiB used in {custom_non_lazy_range_duration:.2f} seconds")
In the example, we compare the built-in function with a custom one, which is non-lazy. We create a sequence of 1.5 mil values lazily and non-lazily. Then we pick up the first 3000. In the end, we compare the time and memory usage of both approaches.
Reading Large Files
This example compares lazy and non-lazy approaches for reading large files.
import time # Non-lazy approach def read_file_non_lazy(filename): with open(filename, 'r') as file: return file.readlines() # Lazy approach def read_file_lazy(filename): with open(filename, 'r') as file: for line in file: yield line # Profiling start_time = time.time() read_file_non_lazy('large_file.txt') print(f"Non-lazy approach: {time.time() - start_time} seconds") start_time = time.time() list(read_file_lazy('large_file.txt')) print(f"Lazy approach: {time.time() - start_time} seconds")
The non-lazy approach reads the entire file into memory, which can be inefficient for large files. The lazy approach reads the file line-by-line, reducing memory usage and improving performance.
Filtering Data
This example demonstrates lazy evaluation for filtering data.
import time import itertools # Non-lazy approach def filter_non_lazy(data): return [x for x in data if x % 2 == 0] # Lazy approach def filter_lazy(data): for x in data: if x % 2 == 0: yield x # Profiling data = range(10_000_000) start_time = time.time() res = filter_non_lazy(data) for e in res[:10]: print(e) print(f"Non-lazy approach: {time.time() - start_time} seconds") start_time = time.time() res = filter_lazy(data) for e in itertools.islice(res, 10): print(e) print(f"Lazy approach: {time.time() - start_time} seconds")
The non-lazy approach filters the entire dataset at once, while the lazy approach filters elements on-the-fly. The lazy approach is more memory-efficient and faster for large datasets.
Infinite Sequences
This example demonstrates lazy evaluation for generating infinite sequences.
import time # Non-lazy approach (not feasible for infinite sequences) # Lazy approach def infinite_sequence(): num = 0 while True: yield num num += 1 # Profiling start_time = time.time() sequence = infinite_sequence() for _ in range(1000000): next(sequence) print(f"Lazy approach: {time.time() - start_time} seconds")
The lazy approach allows us to generate an infinite sequence without consuming infinite memory. This is not feasible with a non-lazy approach.
Chaining Iterators
This example demonstrates lazy evaluation for chaining iterators.
import time from itertools import chain # Non-lazy approach def chain_non_lazy(iter1, iter2): return list(iter1) + list(iter2) # Lazy approach def chain_lazy(iter1, iter2): return chain(iter1, iter2) # Profiling iter1 = range(1000000) iter2 = range(1000000) start_time = time.time() chain_non_lazy(iter1, iter2) print(f"Non-lazy approach: {time.time() - start_time} seconds") start_time = time.time() list(chain_lazy(iter1, iter2)) print(f"Lazy approach: {time.time() - start_time} seconds")
The non-lazy approach combines two iterators into a single list, while the lazy approach chains them without creating an intermediate list. The lazy approach is more memory-efficient.
Processing Large Datasets
This example demonstrates lazy evaluation for processing large datasets.
import time # Non-lazy approach def process_non_lazy(data): return [x * 2 for x in data] # Lazy approach def process_lazy(data): for x in data: yield x * 2 # Profiling data = range(1000000) start_time = time.time() process_non_lazy(data) print(f"Non-lazy approach: {time.time() - start_time} seconds") start_time = time.time() list(process_lazy(data)) print(f"Lazy approach: {time.time() - start_time} seconds")
The non-lazy approach processes the entire dataset at once, while the lazy approach processes elements on-the-fly. The lazy approach is more memory-efficient and faster for large datasets.
Source
Python itertools Documentation
In this article, we have explored lazy evaluation in Python and demonstrated its effectiveness through practical examples and profiling comparisons.
Author
List all Python tutorials.