Using Python Generator send() in a For Loop: Controlling Graph Traversal by Skipping Node Edges

Python generators are celebrated for their ability to handle large data streams efficiently by yielding values on-the-fly, reducing memory overhead. While most developers use generators for simple iteration with yield, few leverage their full potential—especially the send() method, which enables two-way communication between the generator and the caller.

In this blog, we’ll explore how to use generator.send() within a loop to dynamically control graph traversal. Traditional graph traversal algorithms like DFS or BFS follow a rigid path, but with send(), we can dynamically skip specific edges based on runtime conditions (e.g., node metadata, external signals, or user input). This unlocks flexible, adaptive traversal logic that’s hard to achieve with static methods.

Table of Contents#

  1. Understanding Python Generators and send()
  2. Graph Traversal Basics: Limitations of Traditional Methods
  3. The Challenge: Dynamic Edge Skipping
  4. Using send() in a Loop: A New Approach to Traversal
  5. Step-by-Step Example: Controlled Graph Traversal
  6. Advanced Use Cases and Considerations
  7. Conclusion
  8. References

1. Understanding Python Generators and send()#

What Are Generators?#

Generators are special functions that return an iterator, allowing you to iterate over a sequence of values without storing the entire sequence in memory. They use the yield keyword to pause execution and return a value, resuming from where they left off when next() is called.

Example of a simple generator:

def simple_generator():
    yield 1
    yield 2
    yield 3
 
gen = simple_generator()
print(next(gen))  # Output: 1
print(next(gen))  # Output: 2

The send() Method: Two-Way Communication#

The send() method extends generator functionality by allowing the caller to pass a value back into the generator while it’s paused at a yield statement. This transforms generators from one-way iterators into interactive state machines.

How send() works:

  • When you call generator.send(value), the generator resumes execution, and the yield expression returns value.
  • The generator must be started first (with next(generator) or generator.send(None)) before sending values.

Example of send() in action:

def echo_generator():
    while True:
        received = yield  # Pause and wait for input
        yield f"Echo: {received}"
 
gen = echo_generator()
next(gen)  # Start the generator (required before send())
print(gen.send("Hello"))  # Output: Echo: Hello
print(gen.send("Python"))  # Output: Echo: Python

2. Graph Traversal Basics: Limitations of Traditional Methods#

What Is Graph Traversal?#

A graph is a collection of nodes (vertices) connected by edges. Traversal algorithms (e.g., DFS, BFS) visit nodes systematically. For example:

  • DFS (Depth-First Search): Explores as far as possible along a branch before backtracking (uses a stack).
  • BFS (Breadth-First Search): Explores all neighbors at the current depth before moving to deeper levels (uses a queue).

Limitation: Static Edge Handling#

Traditional traversal methods are static: they process edges based on predefined rules (e.g., “visit all neighbors”). If you want to dynamically skip edges (e.g., skip edges to blocked users in a social graph, or edges with high latency in a network graph), you must:

  • Modify the traversal logic (inflexible for runtime changes).
  • Pre-filter edges before traversal (loses dynamic decision-making).

Example of rigid DFS:

def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    visited.add(start)
    print(start, end=" ")
    for neighbor in graph[start]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)
    return visited
 
# Sample graph (adjacency list)
graph = {
    'A': ['B', 'C', 'D'],
    'B': ['E', 'F'],
    'C': ['G'],
    'D': ['H', 'I'],
    'E': [], 'F': [], 'G': [], 'H': [], 'I': []
}
 
dfs(graph, 'A')  # Output: A B E F C G D H I (fixed order)

Here, DFS visits all neighbors of A (B, C, D). To skip C, you’d need to hardcode a filter (e.g., if neighbor != 'C'), which isn’t dynamic.

3. The Challenge: Dynamic Edge Skipping#

Imagine you’re traversing a graph where edge relevance changes at runtime:

  • Social Network Graph: Skip edges to users who blocked the current user (determined by querying a database during traversal).
  • Supply Chain Graph: Skip edges to warehouses with low stock (checked in real-time via an API).

In these cases, predefining edges to skip won’t work—you need to decide during traversal which edges to exclude.

4. Using send() in a Loop: A New Approach#

By combining generators with send(), we can create a dynamic traversal system where:

  1. The generator yields a node for processing.
  2. The caller analyzes the node, decides which edges to skip, and sends this “skip list” back via send().
  3. The generator resumes, filters edges using the skip list, and proceeds with traversal.

Why This Works#

Generators maintain state between yields, so they can remember the traversal progress (e.g., visited nodes, current stack/queue). The send() method lets the caller inject runtime logic (edge skips) into this stateful process.

Looping with send()#

Traditional for loops iterate over generators by calling next() repeatedly, but they don’t support send(). Instead, we use a while loop to mimic a for loop’s behavior while integrating send():

  1. Start the generator with next() or send(None).
  2. Yield a node, then pause to receive a skip list via send().
  3. Filter edges, update traversal state, and repeat until all nodes are visited.

5. Step-by-Step Example: Controlled Graph Traversal#

Let’s build a dynamic DFS traversal using send() to skip edges. We’ll use the sample graph from earlier and add logic to skip edges based on runtime decisions.

Step 1: Define the Graph#

We’ll use the same adjacency list as before:

graph = {
    'A': ['B', 'C', 'D'],
    'B': ['E', 'F'],
    'C': ['G'],
    'D': ['H', 'I'],
    'E': [], 'F': [], 'G': [], 'H': [], 'I': []
}

Step 2: Build the Controlled DFS Generator#

This generator yields nodes and accepts skip lists via send() to filter edges:

def controlled_dfs(start):
    visited = set()
    stack = [start]  # DFS uses a stack (LIFO)
    while stack:
        node = stack.pop()  # Get the next node from the stack
        if node not in visited:
            visited.add(node)
            # Yield the node and wait for skip_edges from the caller
            skip_edges = yield node  # Pause here; resume when send() is called
            # Default: skip no edges if send() isn't called yet
            if skip_edges is None:
                skip_edges = []
            # Get neighbors, filter out skipped edges and visited nodes
            neighbors = graph[node]
            filtered_neighbors = [
                n for n in neighbors 
                if n not in skip_edges and n not in visited
            ]
            # Push filtered neighbors to stack (reverse to maintain order)
            stack.extend(reversed(filtered_neighbors))

Step 3: Traverse with Dynamic Edge Skipping#

We’ll use a while loop to simulate a for loop, sending skip lists based on runtime logic (e.g., “skip nodes starting with ‘C’”):

def traverse_with_skips():
    gen = controlled_dfs('A')  # Initialize the generator
    try:
        # Start the generator (required before sending values)
        current_node = next(gen)  # Equivalent to gen.send(None)
        print(f"Visiting: {current_node}")
        
        while True:
            # Runtime logic: Decide which edges to skip (example: skip 'C' and 'H')
            # Here, we'll skip nodes starting with 'C' or 'H'
            skip_edges = [n for n in graph[current_node] if n.startswith(('C', 'H'))]
            print(f"→ Sending skip list: {skip_edges}")
            
            # Send skip_edges to the generator and get the next node
            current_node = gen.send(skip_edges)
            print(f"Visiting: {current_node}")
            
    except StopIteration:
        print("\nTraversal complete!")
 
# Run the traversal
traverse_with_skips()

Step 4: Traversal Walkthrough#

Let’s trace the execution to see how send() controls the path:

  1. Start Generator: next(gen) yields 'A' (current_node = 'A').
  2. Skip Logic for 'A': Neighbors are ['B', 'C', 'D']. We skip nodes starting with 'C' → skip_edges = ['C'].
  3. Send Skip List: gen.send(['C']) filters neighbors to ['B', 'D'] (since 'C' is skipped). Stack becomes ['D', 'B'] (reversed for LIFO order).
  4. Next Node: Stack pops 'B' (current_node = 'B').
  5. Skip Logic for 'B': Neighbors are ['E', 'F']. No skips (neither starts with 'C'/'H') → skip_edges = [].
  6. Send Skip List: gen.send([]) filters neighbors to ['E', 'F']. Stack becomes ['D', 'F', 'E'].
  7. Next Node: Stack pops 'E' (current_node = 'E'). No neighbors → stack becomes ['D', 'F'].
  8. Continue: Traversal proceeds, skipping 'H' when visiting 'D' (neighbors ['H', 'I'] → skip 'H' → process 'I').

Output#

Visiting: A
→ Sending skip list: ['C']
Visiting: B
→ Sending skip list: []
Visiting: E
→ Sending skip list: []
Visiting: F
→ Sending skip list: []
Visiting: D
→ Sending skip list: ['H']
Visiting: I
→ Sending skip list: []
Traversal complete!

Result: Nodes visited in order: A → B → E → F → D → I (skipping 'C' and 'H').

6. Advanced Use Cases and Considerations#

Advanced: Complex Instructions via send()#

Instead of sending a simple skip list, you can send complex instructions (e.g., prioritize edges, weight edges, or pause/resume traversal). For example:

# Send a dictionary with skip_edges and priority_edges
instruction = {
    'skip': ['C'],
    'prioritize': ['D']  # Process 'D' before 'B'
}
current_node = gen.send(instruction)

Handling Cycles#

To avoid infinite loops in cyclic graphs, extend the generator to track visited nodes (as shown in controlled_dfs).

Pitfalls to Avoid#

  • Forgetting to Start the Generator: Always call next(gen) or gen.send(None) before sending values (otherwise, TypeError).
  • Uncontrolled Edge Skipping: Over-skipping edges may leave nodes unvisited (add logging to debug).
  • State Leakage: Generators are single-use; create a new generator for each traversal.

7. Conclusion#

Python’s generator.send() method transforms static traversal algorithms into dynamic, interactive tools. By enabling two-way communication between the caller and generator, you can:

  • Skip edges based on runtime conditions (e.g., user input, external data).
  • Build adaptive traversal logic for graphs, trees, or streams.
  • Reduce memory overhead by processing nodes on-the-fly.

Next time you need flexible iteration, remember: generators are more than iterators—they’re stateful machines waiting to communicate.

8. References#