Understanding Lazy Evaluation in Python

Python evaluates expressions eagerly by default — immediately, upfront, no questions asked. And yet some of the language's most powerful patterns depend on doing exactly the opposite: deferring computation until the moment a value is actually needed. This is lazy evaluation, and Python has been steadily adopting more of it with every major release.

Understanding where Python is lazy, where it is eager, and how to control the boundary between them is fundamental to writing efficient, scalable code. This article traces the history and mechanics of lazy evaluation in Python across the PEPs that made it possible, with real code and real explanations. No hand-waving.

Eager vs. Lazy: The Core Distinction

In computer science, lazy evaluation (also called call-by-need) is a strategy that delays the evaluation of an expression until its value is actually required. The concept was introduced for lambda calculus by Christopher Wadsworth and later independently brought into programming languages by Peter Henderson, James H. Morris, Daniel P. Friedman, and David S. Wise.

Languages like Haskell are lazy by default. Every expression is deferred until something forces its evaluation. Python is the opposite: it evaluates expressions eagerly, executing them as soon as they are encountered.

Here is a simple demonstration of Python's eagerness:

import time

# Python evaluates ALL elements immediately, even though
# we only access the first one
result = [time.sleep(1), time.sleep(1), time.sleep(1)][0]
# This takes 3 seconds, not 1

Python built the entire list before letting you index into it. A truly lazy language would only evaluate the first element, since that is all you asked for.

Note

Python's eagerness is not a flaw. It makes the language predictable: you know exactly when your code executes and in what order. Side effects happen where you expect them. Lazy evaluation works naturally in Haskell because Haskell functions have no side effects. In Python, where side effects are common and expected, eager evaluation keeps behavior transparent.

But eagerness has a cost. If you build a list of ten million items just to check if any of them meet a condition, you have wasted memory and time on 9,999,999 items you never needed. Python's answer is not to become a lazy language, but to give you explicit tools for lazy evaluation when you choose to use them.

Python 2 vs. Python 3: The Lazy Shift

One of the most significant but underappreciated changes in Python 3 was the shift toward lazy evaluation in built-in functions. PEP 3100 — Miscellaneous Python 3.0 Plans, which collected the changes Guido van Rossum had chosen as goals for Python 3.0, included a direct directive: "Make built-ins return an iterator where appropriate (e.g. range(), zip(), map(), filter(), etc.)."

In Python 2, range(1000000) built an entire list of one million integers in memory. If you wanted lazy behavior, you used a separate function called xrange(). Similarly, map(), filter(), and zip() all returned lists eagerly. Python 3 flipped the defaults:

# Python 3
numbers = range(10_000_000)
print(type(numbers))  # <class 'range'>
print(numbers[5])     # 5 -- computed on demand, no list created

pairs = zip([1, 2, 3], ['a', 'b', 'c'])
print(type(pairs))    # <class 'zip'>
# Tuples are generated only when you iterate

squares = map(lambda x: x**2, range(100))
print(type(squares))  # <class 'map'>
# No computation has happened yet

This change also extended to dictionary methods. In Python 2, dict.keys(), dict.values(), and dict.items() returned lists. Python 3 replaced these with views — lightweight objects that reflect the dictionary's current state without copying all the data. PEP 3100 explicitly noted this: "Return iterable views instead of lists where appropriate for atomic type methods."

The old eager versions (xrange, the dict.iterkeys()/itervalues()/iteritems() methods, and ifilter/izip from itertools) were removed entirely. The lazy versions took their place as the defaults. For a program processing large datasets, the difference between range() returning a list and returning an iterator can mean the difference between using gigabytes of RAM and using almost none.

PEP 234: The Iterator Protocol

The foundation that makes all lazy evaluation in Python possible is the iterator protocol, formalized in PEP 234 — Iterators, authored by Ka-Ping Yee and Guido van Rossum for Python 2.2.

The protocol is simple. An object is iterable if it implements __iter__(), which returns an iterator. An iterator implements __next__(), which returns the next value or raises StopIteration when exhausted. Every for loop in Python works through this protocol.

The key insight for lazy evaluation: an iterator does not need to have all its values computed ahead of time. It only needs to know how to produce the next one. This is what separates a list (which stores all values) from an iterator (which computes them on demand):

import sys

# Eager: all values stored in memory
eager_list = [x ** 2 for x in range(1_000_000)]
print(sys.getsizeof(eager_list))  # ~8.4 MB

# Lazy: values computed on demand
lazy_iter = (x ** 2 for x in range(1_000_000))
print(sys.getsizeof(lazy_iter))   # ~200 bytes

The generator expression on the last line uses approximately 200 bytes regardless of whether it represents a million values or a billion. It holds only enough state to produce the next value when asked. This is lazy evaluation in its purest form within Python.

PEP 255: Generators and the yield Statement

PEP 255 — Simple Generators, written by Neil Schemenauer, Tim Peters, and Magnus Lie Hetland, was accepted for Python 2.2 and introduced the yield statement. This single keyword is arguably Python's most important tool for lazy evaluation.

The PEP describes what happens at yield with precision: when a yield statement is encountered, the state of the function is frozen — all local state is retained, including the current bindings of local variables, the instruction pointer, and the internal evaluation stack. This freezing is what makes lazy evaluation work. The generator does not compute its next value until someone asks for it. It suspends execution mid-function, remembers exactly where it was, and resumes only on demand.

Here is a practical example — reading and processing a massive log file without loading it into memory:

def read_log_entries(filepath):
    """Lazily yield parsed log entries from a file."""
    with open(filepath) as f:
        for line in f:
            timestamp, level, message = line.strip().split(" | ", 2)
            yield {
                "timestamp": timestamp,
                "level": level,
                "message": message
            }

def errors_only(entries):
    """Lazily filter to error-level entries."""
    for entry in entries:
        if entry["level"] == "ERROR":
            yield entry

def first_n(iterable, n):
    """Lazily yield the first n items."""
    count = 0
    for item in iterable:
        if count >= n:
            return
        yield item
        count += 1

# Process a 50 GB log file using almost no memory
entries = read_log_entries("/var/log/application.log")
errors = errors_only(entries)
recent_errors = first_n(errors, 10)

for error in recent_errors:
    print(error["message"])

Each function in this chain is a generator. None of them do any work until the for loop at the bottom starts pulling values. When first_n asks errors_only for the next error, errors_only asks read_log_entries for log entries one at a time, skipping non-errors. The moment ten errors have been found, first_n returns and the entire chain stops — even if the file has millions of remaining lines. You describe what you want, and only the minimum necessary work is performed to deliver it.

PEP 289: Generator Expressions

PEP 289 — Generator Expressions, authored by Raymond Hettinger and accepted for Python 2.4, brought lazy evaluation into a compact inline syntax. The difference between a list comprehension and a generator expression is one character:

# Eager: builds the entire list, then sums
total = sum([x ** 2 for x in range(10_000_000)])

# Lazy: generates values one at a time as sum() consumes them
total = sum(x ** 2 for x in range(10_000_000))

The first version allocates a list of ten million integers before sum() even starts. The second version never builds a list at all. It produces each squared value, hands it to sum(), and then forgets it.

Generator expressions are especially powerful when combined with reduction functions:

import os

# Find the largest file in a directory tree -- lazily
largest = max(
    os.path.getsize(os.path.join(dirpath, f))
    for dirpath, dirnames, filenames in os.walk("/home")
    for f in filenames
)

# Check if any line in a file contains "ERROR" -- stops at the first match
with open("server.log") as f:
    has_errors = any("ERROR" in line for line in f)

# Sum file sizes only for Python files
total_python = sum(
    os.path.getsize(os.path.join(dp, f))
    for dp, dn, fn in os.walk(".")
    for f in fn
    if f.endswith(".py")
)

The any() example is especially notable: it stops iteration the moment it finds a match, meaning it might read only a handful of lines from a million-line file. None of these traversals build an intermediate list — values are produced on demand and processed one at a time.

Lazy Evaluation and itertools

The itertools module in the standard library is a toolkit for composing lazy operations. Every function in itertools returns an iterator, meaning no work is done until you start consuming values. Raymond Hettinger, who designed itertools, described its functions as an "iterator algebra" — composable building blocks for constructing efficient iteration patterns:

import itertools

# Lazily chain multiple files together as one stream
def multifile_reader(*filepaths):
    """Read lines from multiple files as a single lazy stream."""
    return itertools.chain.from_iterable(
        open(fp) for fp in filepaths
    )

# Lazily take the first 100 items from any iterable
first_hundred = itertools.islice(range(10**9), 100)
# range(10**9) is lazy, islice is lazy -- nothing is computed yet

# Lazily group consecutive identical elements
data = "AAABBBCCDDDDEE"
for key, group in itertools.groupby(data):
    print(f"{key}: {list(group)}")

Here is a complete example showing how itertools enables lazy data processing pipelines:

import itertools
import csv

def lazy_csv_rows(filepath):
    """Yield rows from a CSV file lazily."""
    with open(filepath) as f:
        reader = csv.DictReader(f)
        yield from reader

def process_sales_data(filepath):
    """Process sales data lazily -- handles files of any size."""
    rows = lazy_csv_rows(filepath)

    # Filter to completed sales (lazy)
    completed = (row for row in rows if row["status"] == "completed")

    # Extract amounts (lazy)
    amounts = (float(row["amount"]) for row in completed)

    # Accumulate running totals (lazy)
    running_totals = itertools.accumulate(amounts)

    # Only now does computation happen, one row at a time
    for i, total in enumerate(running_totals):
        if i % 10_000 == 0:
            print(f"After {i:,} sales: ${total:,.2f}")

Every stage in this pipeline is lazy. The CSV file is read one row at a time, filtered, transformed, and accumulated without ever loading the entire dataset into memory. You could run this against a file with a hundred million rows on a machine with 1 GB of RAM.

Lazy Attributes: The property Descriptor and Cached Properties

Lazy evaluation is not limited to iterators. Python's property descriptor lets you defer computation of an object's attributes until they are accessed:

class DataAnalysis:
    def __init__(self, filepath):
        self.filepath = filepath
        self._data = None
        self._statistics = None

    @property
    def data(self):
        """Load data lazily on first access."""
        if self._data is None:
            print("Loading data from disk...")
            with open(self.filepath) as f:
                self._data = [float(line.strip()) for line in f]
        return self._data

    @property
    def statistics(self):
        """Compute statistics lazily on first access."""
        if self._statistics is None:
            print("Computing statistics...")
            d = self.data  # This may trigger data loading too
            self._statistics = {
                "mean": sum(d) / len(d),
                "min": min(d),
                "max": max(d),
                "count": len(d)
            }
        return self._statistics

analysis = DataAnalysis("measurements.txt")
# No data loaded yet, no statistics computed

print(analysis.statistics["mean"])
# NOW: "Loading data from disk..." then "Computing statistics..."

print(analysis.statistics["max"])
# Returns instantly -- already computed and cached

Python 3.8 formalized this pattern with functools.cached_property, which combines lazy evaluation with caching in a single decorator:

from functools import cached_property
import hashlib

class Document:
    def __init__(self, content):
        self.content = content

    @cached_property
    def checksum(self):
        """Computed only once, on first access."""
        return hashlib.sha256(self.content.encode()).hexdigest()

    @cached_property
    def word_count(self):
        """Computed only once, on first access."""
        return len(self.content.split())

doc = Document("A long document " * 100_000)
# checksum and word_count are not computed yet

print(doc.word_count)     # Computed now, cached for future access
print(doc.word_count)     # Returned from cache -- no recomputation

The cached_property decorator replaces itself on the instance dictionary after the first access, so subsequent lookups bypass the descriptor protocol entirely. It is both lazy (deferred until first use) and memoized (computed only once).

The Gotchas: Where Laziness Bites

Lazy evaluation is powerful, but it introduces subtleties that catch programmers off guard. Understanding these pitfalls is as important as understanding the benefits.

Gotcha 1: Iterators are exhaustible

Unlike lists, iterators can only be consumed once:

numbers = (x for x in range(5))

print(list(numbers))  # [0, 1, 2, 3, 4]
print(list(numbers))  # [] -- exhausted!

If you need to iterate multiple times, either convert to a list (sacrificing laziness) or create a new generator each time.

Gotcha 2: Lazy evaluation and mutable state

When lazy evaluation interacts with mutable data, the results can be surprising:

data = [1, 2, 3, 4]
filtered = filter(lambda x: x % 2 == 0, data)

# Mutate the source BEFORE consuming the iterator
data.append(10)
data.append(12)

print(list(filtered))  # [2, 4, 10, 12] -- includes items added AFTER filter() was called!
Warning

Because filter() is lazy in Python 3, it does not examine the list until you consume the iterator. By that time, the list may have changed. Your future actions affect the results of your past actions — which makes code difficult to reason about. This is precisely why languages that rely heavily on lazy evaluation (like Haskell) also enforce immutability.

Gotcha 3: Late binding in closures and generator expressions

Python closures bind to the variable itself, not its value at the time of creation. This interacts with lazy evaluation in non-obvious ways, because the variable may change between when a generator or lambda is defined and when it is actually evaluated:

funcs = [lambda: x for x in range(3)]
print([f() for f in funcs])  # [2, 2, 2] -- all reference the FINAL value of x

# Fix with a default argument to force eager binding:
funcs = [lambda x=x: x for x in range(3)]
print([f() for f in funcs])  # [0, 1, 2]

Gotcha 4: Debugging is harder

When evaluation is deferred, errors are also deferred. A bug in a generator function will not surface until someone tries to consume its values, which may happen far from where the generator was created:

def parse_numbers(text_lines):
    for line in text_lines:
        yield int(line)  # ValueError won't happen until iteration

# This line succeeds -- no parsing happens yet
numbers = parse_numbers(["1", "2", "abc", "4"])

# The error surfaces HERE, potentially far from the generator definition
for n in numbers:
    print(n)
# 1
# 2
# ValueError: invalid literal for int() with base 10: 'abc'

This deferred error behavior is a fundamental trade-off of lazy evaluation. It is also one of the reasons Python remains an eager language by default — you want errors to surface as close to their cause as possible.

PEP 690 and PEP 810: Lazy Imports and __lazy_modules__

The concept of lazy evaluation has recently extended to Python's import system itself, through one of the most debated proposals in recent Python history.

PEP 690 — Lazy Imports, authored by Germán Méndez Bravo and Carl Meyer, proposed transparently deferring module imports until the imported name was first used. The motivation was significant: Python programs commonly import many more modules than a single invocation actually needs, and all of those imports execute eagerly at startup. Meta's reference implementation (in their Cinder fork of CPython) demonstrated up to 70% faster startup and 40% reduced memory usage on Instagram Server.

The Python Steering Council rejected PEP 690 in December 2022. Gregory P. Smith, writing on behalf of the council, acknowledged that faster startup time is desirable but identified a fundamental problem: the -L flag approach would create a split between projects that rely on import-time code execution and those that forbid it. Hudson River Trading, which maintains its own CPython fork with PEP 690's lazy imports, shared a candid assessment: while they found substantial performance benefits, they also encountered subtle bugs from deferred or skipped import side effects, such as decorator registration and __init_subclass__ hooks causing silent behavior changes.

PEP 810 — Explicit Lazy Imports, authored by Pablo Galindo Salgado, Germán Méndez Bravo, Thomas Wouters, Dino Viehland, Brittany Reynoso, Noah Kim, and Tim Stumbaugh, took a fundamentally different approach. Instead of making all imports implicitly lazy, it introduced an explicit lazy keyword:

# Standard eager import -- loads immediately
import json

# Lazy import -- defers loading until first use
lazy import pandas
lazy from matplotlib import pyplot

# pandas is not loaded yet -- just a lightweight proxy
# When you first USE it, the real import happens:
df = pandas.DataFrame({"a": [1, 2, 3]})  # NOW pandas loads

PEP 810 also acknowledges the challenge of supporting both old and new Python versions. The PEP intentionally chose lazy as a soft keyword — one that only carries special meaning directly before an import statement, leaving it usable as an ordinary identifier name everywhere else. This means no existing code breaks when upgrading to Python 3.15.

However, library authors who need to support both old and new Python versions cannot simply use the lazy import syntax unconditionally. On Python versions before 3.15, the parser does not recognize lazy as a keyword, and the statement would be misinterpreted — the interpreter would treat lazy as an expression referencing an undefined variable, raising a NameError at runtime. For codebases that must run on both Python 3.14 and 3.15, PEP 810 introduces the __lazy_modules__ global as a transitional mechanism. A module declares a list of import names that should be treated as lazy, and on Python 3.15 those imports become lazy automatically. On earlier versions, the declaration is silently ignored and imports remain eager:

# Works as lazy on Python 3.15+, falls back to eager on earlier versions
__lazy_modules__ = ['pandas', 'numpy', 'matplotlib']

import pandas
import numpy
from matplotlib import pyplot

For projects that cannot yet require Python 3.15, the importlib.util.LazyLoader (available since Python 3.5) provides a manual alternative:

import importlib.util

def _lazy_import(name):
    spec = importlib.util.find_spec(name)
    loader = importlib.util.LazyLoader(spec.loader)
    spec.loader = loader
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module

pandas = _lazy_import("pandas")
numpy = _lazy_import("numpy")

Once a project sets Python 3.15 as its minimum supported version, it can replace both approaches with the canonical lazy import keyword syntax.

Pro Tip

PEP 810 was accepted unanimously by the Python Steering Council on November 3, 2025 — just one month after its publication on October 2. Barry Warsaw wrote the acceptance notice on behalf of the council. Because Pablo Galindo Salgado was a co-author, he did not vote; the remaining four council members voted unanimously in favor. PEP 810 is targeted for Python 3.15, whose final release is scheduled for October 1, 2026. The explicit opt-in design mirrors the gradual adoption model that worked for type hints — a mechanism that can be introduced progressively, without forcing projects to commit all at once.

Building a Lazy Evaluation Wrapper

To understand lazy evaluation at a deeper level, you can build your own lazy evaluation primitive. The idea is simple: wrap a computation in a callable, and do not execute it until the result is needed:

class Lazy:
    """A lazy evaluation wrapper that defers computation until first access."""

    def __init__(self, func):
        self._func = func
        self._value = None
        self._computed = False

    @property
    def value(self):
        if not self._computed:
            self._value = self._func()
            self._computed = True
            self._func = None  # Release the closure for garbage collection
        return self._value

    def __repr__(self):
        if self._computed:
            return f"Lazy(computed={self._value!r})"
        return "Lazy(pending)"


# Usage
import time

expensive = Lazy(lambda: sum(range(10_000_000)))
print(expensive)           # Lazy(pending) -- no computation yet

print(expensive.value)     # 49999995000000 -- computed now
print(expensive)           # Lazy(computed=49999995000000)

print(expensive.value)     # 49999995000000 -- cached, no recomputation

You can also build a lazy decorator for functions that should defer their results:

from functools import wraps

def lazy_call(func):
    """Decorator that makes a function return a Lazy wrapper."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        return Lazy(lambda: func(*args, **kwargs))
    return wrapper

@lazy_call
def expensive_query(database, query):
    """Simulate an expensive database query."""
    print(f"Executing: {query}")
    time.sleep(2)
    return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

# The query is NOT executed here
result = expensive_query("mydb", "SELECT * FROM users")
print("Query prepared but not executed")

# The query executes NOW, when we access .value
print(result.value)

This pattern is used extensively in ORMs like SQLAlchemy and Django, where database queries are constructed lazily and only executed when you iterate over the results or access specific attributes. Django's QuerySet is one of the best-known examples of lazy evaluation in a Python library.

How PEP 810 Lazy Objects Work Internally

When you write lazy import pandas in Python 3.15, Python does not simply skip loading the module. It creates a lazy object — a lightweight proxy that stands in place of the real module until first use. Understanding this distinction matters for debugging and for reasoning about when errors will surface.

The lazy object is a lightweight proxy that captures enough information to complete the import later: the module name, the target namespace, and the import context. When any attribute is accessed on the lazy object, the proxy triggers reification — it performs the actual import via the standard __import__ mechanism, replaces itself with the real module in the namespace, and returns the requested attribute. This replacement happens exactly once:

# Python 3.15+
lazy import pandas  # pandas is a LazyObject proxy -- no disk I/O yet

# sys.modules does NOT contain pandas yet
import sys
print("pandas" in sys.modules)   # False

# First attribute access triggers reification
df = pandas.DataFrame({"x": [1, 2, 3]})  # pandas now loads, proxy replaced

print("pandas" in sys.modules)   # True
print(type(pandas))               # <class 'module'> -- no longer a proxy

One implication: ImportError is deferred. If pandas is not installed, the error will not be raised at the lazy import line. It will surface when you first use the name. Notably, reification calls __import__ using the state of the import system (such as sys.path and sys.meta_path) at reification time, not the state when the lazy import statement was originally evaluated. This is a trade-off that PEP 810 documents explicitly and considers acceptable for the startup gains involved.

There is also a noteworthy interaction with sys.modules. A lazy import does not add the module to sys.modules until reification. This means code that probes sys.modules to check if a module is loaded — a pattern used in some plugin systems and test isolation frameworks — will not see lazy-imported modules until they have been used. PEP 810 addresses this through its filter API (sys.set_lazy_imports()) and a global lazy imports control, which let teams configure and introspect lazy import behavior programmatically for cases where the default per-import opt-in is not granular enough.

Warning

Star imports (from module import *) cannot be lazy. The set of names to be imported is determined by loading the module, so a lazy star import would require loading the module immediately anyway. PEP 810 explicitly disallows lazy from module import * and will raise a SyntaxError. Lazy imports are also only permitted at module scope — using lazy inside a function body, class body, or try/except/finally block also raises a SyntaxError. This restriction is intentional: module-scope imports are the ones responsible for slow startup, and keeping laziness at module scope keeps its behavior predictable.

Lazy Evaluation and Thread Safety

Lazy evaluation introduces a category of bug that eager evaluation does not have: the race between the moment a lazy object is created and the moment it is reified. In single-threaded code this is invisible. In multi-threaded code, two threads might both discover that a cached property or lazy import has not yet been computed and race to compute it simultaneously.

Python's functools.cached_property is not thread-safe by design. The CPython documentation explicitly notes this: the descriptor does not use a lock, meaning concurrent access to an uninitialized cached property can result in the computation running more than once, or in inconsistent state if the computation has side effects:

from functools import cached_property
import threading

class Config:
    @cached_property
    def settings(self):
        print("Loading settings...")  # Could print twice in multithreaded code
        return {"debug": False, "timeout": 30}

config = Config()

# In a multithreaded server, two requests hitting config.settings
# simultaneously before first initialization can both trigger loading
threads = [threading.Thread(target=lambda: print(config.settings)) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()

The fix depends on your requirements. For properties where double-initialization is harmless (pure functions with no side effects), cached_property is acceptable. For properties where it matters, use a threading.Lock:

import threading

class ThreadSafeLazy:
    """A thread-safe lazy property that guarantees single initialization."""

    def __init__(self):
        self._lock = threading.Lock()
        self._cache = {}

    def lazy_property(self, name, compute_fn):
        if name not in self._cache:
            with self._lock:
                # Double-checked locking: re-test inside the lock
                if name not in self._cache:
                    self._cache[name] = compute_fn()
        return self._cache[name]

The double-checked locking pattern (test outside the lock, acquire the lock, test again inside) avoids the overhead of acquiring the lock on every read once the value is cached, while still guaranteeing that only one thread performs the initialization.

PEP 810's lazy import mechanism does handle thread safety. The PEP's specification notes that reification is thread-safe: if two threads simultaneously access a lazy import proxy, the import system's existing locking mechanism ensures the module is loaded exactly once. You do not need to add your own locking around lazy import statements.

This distinction matters: Python's import system switched from a single global import lock to per-module locks in Python 3.3. PEP 810 is designed to work within this existing lock structure, so lazy imports inherit the same thread safety guarantees as ordinary imports — just deferred to first use.

Advanced Lazy Evaluation Patterns

The standard introductions to lazy evaluation stop at generators and cached_property. Here are the patterns that show up in serious production code but rarely appear in tutorials.

Lazy dependency injection

Frameworks that support dependency injection often use lazy resolution: a component declares a dependency, but the dependency is not instantiated until the component is first used. This decouples object graph construction from object graph evaluation. You can build the entire dependency tree as a set of deferred promises, then let the framework resolve them on demand:

class Provider:
    """A lazily-resolved dependency provider."""

    def __init__(self, factory):
        self._factory = factory
        self._instance = None

    def get(self):
        if self._instance is None:
            self._instance = self._factory()
        return self._instance

    def reset(self):
        """Clear the cached instance (useful for testing)."""
        self._instance = None

# Build the dependency graph without executing anything
db_conn = Provider(lambda: connect_to_database("postgres://localhost/mydb"))
user_repo = Provider(lambda: UserRepository(db_conn.get()))
auth_service = Provider(lambda: AuthService(user_repo.get()))

# Resolution happens only when the top-level component is first requested
# If auth_service is never used (e.g., during unit testing), the database
# connection is never established
user = auth_service.get().login("alice", "secret")

This is the same basic structure that dependency injection frameworks build on. SQLAlchemy's session management defers database connections until a query is actually executed. FastAPI's Depends() mechanism resolves dependencies only for the requests that actually need them, avoiding setup work for code paths that are never reached.

Lazy sequence with lookahead

Sometimes you need lazy evaluation but also need to peek at future values without consuming them. Python does not provide this natively, but you can build it with itertools.chain and a small peek buffer:

import itertools

class PeekableIterator:
    """An iterator that supports non-consuming lookahead."""

    def __init__(self, iterable):
        self._iter = iter(iterable)
        self._peeked = []

    def peek(self, n=1):
        """Look ahead n items without consuming them."""
        while len(self._peeked) < n:
            try:
                self._peeked.append(next(self._iter))
            except StopIteration:
                break
        return self._peeked[:n]

    def __next__(self):
        if self._peeked:
            return self._peeked.pop(0)
        return next(self._iter)

    def __iter__(self):
        return self

# Parse a token stream lazily, with lookahead for context-sensitive decisions
tokens = PeekableIterator(tokenize_source_file("myprogram.py"))
for token in tokens:
    if token.type == "DEF" and tokens.peek(1)[0].value == "__init__":
        handle_constructor(token, tokens)
    else:
        handle_generic(token)

Lazy memoization with expiry

The cached_property pattern computes once and caches forever. In production systems, you often want lazy computation with a time-to-live: compute on demand, cache the result, but re-compute after some interval. This is particularly useful for configuration values, feature flags, and rate-limited API calls:

import time
from functools import wraps

def lazy_ttl(ttl_seconds):
    """Decorator for lazy properties with time-to-live caching."""
    def decorator(func):
        cache_attr = f"_ttl_cache_{func.__name__}"
        expiry_attr = f"_ttl_expiry_{func.__name__}"

        @property
        @wraps(func)
        def wrapper(self):
            now = time.monotonic()
            if (not hasattr(self, cache_attr) or
                    now > getattr(self, expiry_attr, 0)):
                setattr(self, cache_attr, func(self))
                setattr(self, expiry_attr, now + ttl_seconds)
            return getattr(self, cache_attr)

        return wrapper
    return decorator

class FeatureFlags:
    @lazy_ttl(ttl_seconds=60)
    def flags(self):
        """Reload feature flags from the config server at most once per minute."""
        return fetch_flags_from_config_server()

flags = FeatureFlags()
print(flags.flags)  # Fetches from server, cached
print(flags.flags)  # Returns cache (within 60 seconds)
# After 60 seconds, next access will re-fetch

When to Be Lazy and When to Be Eager

Lazy evaluation is a tool, not a goal. Here is a practical decision framework:

Use lazy evaluation when:

  • You are working with large or unbounded datasets where loading everything into memory is impractical. Processing a 50 GB log file, streaming data from a network socket, or iterating over database results row by row are all cases where lazy evaluation is essential.
  • You are building multi-stage processing pipelines where intermediate results are not needed as complete collections. Generator pipelines let you process data one item at a time through as many stages as you need.
  • You want to avoid paying the cost of computation that might never be needed. Lazy properties, deferred imports, and conditional loading all defer work that may turn out to be unnecessary.

Use eager evaluation when:

  • You need to access the same data multiple times. Converting a generator to a list is the right call when you need random access or repeated iteration.
  • You need predictable timing for side effects. If your code writes to files, sends network requests, or modifies shared state, eager evaluation makes the timing of those actions explicit and debuggable.
  • You need the data to be in memory for algorithms that require it (sorting, random access, binary search). These operations are inherently eager.

A Brief History of Laziness in Python

The trajectory is clear. Python has been getting lazier over time, but deliberately and with explicit control:

  • Python 2.2 (2001): PEP 234 introduced the iterator protocol. PEP 255 introduced generators with yield.
  • Python 2.4 (2004): PEP 289 introduced generator expressions for inline lazy evaluation.
  • Python 2.5 (2006): PEP 342 enhanced generators with send(), enabling lazy coroutine pipelines.
  • Python 3.0 (2008): PEP 3100 converted range(), map(), filter(), zip(), and dictionary views from eager to lazy.
  • Python 3.3 (2012): PEP 380 introduced yield from for composing lazy generators cleanly.
  • Python 3.5 (2015): importlib.util.LazyLoader was added, providing an opt-in mechanism to defer module loading before the language had a keyword for it.
  • Python 3.6 (2016): PEP 525 introduced async generators, bringing lazy evaluation to asynchronous code.
  • Python 3.8 (2019): functools.cached_property was added, formalizing the lazy-and-cache pattern.
  • Python 3.15 (October 1, 2026, scheduled): PEP 810 will introduce the lazy keyword for explicit lazy imports, accepted unanimously by the Steering Council on November 3, 2025.

Each step gave programmers more fine-grained control over when computation happens. Python never abandoned its eager defaults. Instead, it built a comprehensive vocabulary of lazy tools that you opt into when the situation calls for it.

Guido van Rossum framed Python's design as "an experiment in how much freedom programmers need. Too much freedom and nobody can read another's code; too little and expressiveness is endangered."

Lazy evaluation in Python fits this philosophy precisely — it is available when you need it, explicit when you use it, and out of the way when you do not.

Conclusion

Lazy evaluation is not exotic or academic. Every time you write a generator function, use a generator expression, iterate over range(), or chain itertools operations, you are using lazy evaluation. Every time Django defers a database query, or SQLAlchemy builds a query object without executing it, or your CLI tool defers an import with the upcoming lazy keyword, lazy evaluation is doing the heavy lifting.

The key insight is that laziness in Python is always a choice. You decide when to defer and when to compute. You decide when a generator is the right tool and when a list is more appropriate. This explicit control is what makes Python's approach to lazy evaluation practical rather than surprising, powerful rather than confusing.

Understand when to be lazy, and your programs will use less memory, start faster, and process more data than you thought possible. Understand when to be eager, and your programs will remain predictable, debuggable, and correct. The best Python code does both.

back to articles