Skip to main content

Detect dependency cycles with Python graphlib

2026-11-12 12:34:57.987122+00:00
Detect dependency cycles with Python graphlib

A release script may need to fetch inputs, build an artifact and test it. A hand-written list works until another task adds a prerequisite and the list silently drifts out of order. Describe each task by its predecessors, ask graphlib for an order, and reject a cycle before performing side effects.

The Python 3.13 graphlib documentation defines a graph mapping where each key is a node and its values are that node's predecessors. A predecessor mentioned only in a value is included as a node. In the example below, fetch is included even though it is not a dictionary key.

The example requires fetch before build and build before test.

Check the whole plan first

from graphlib import TopologicalSorter

def order_tasks(graph):
    return tuple(TopologicalSorter(graph).static_order())

plan = {'build': ['fetch'], 'test': ['build']}
print(order_tasks(plan))  # ('fetch', 'build', 'test')

Converting the iterator to a tuple completes the sort before a caller begins executing tasks. For this graph, the output is ('fetch', 'build', 'test'). The only required ordering is that each prerequisite precedes its dependent. Independent tasks can have more than one valid order, so tests should assert the dependency relationships instead of relying on an arbitrary order among peers. The documentation notes that ordering among nodes on the same level can depend on insertion order.

A cycle has no complete topological order. order_tasks({'a': ['b'], 'b': ['a']}) raises graphlib.CycleError when the iterator is consumed. Handle that error as a configuration problem and show the operator enough information to fix the dependencies. Do not start the first task and wait to discover the problem halfway through the release. The exception can include a detected cycle, but it is not a list of every cycle in a graph.

Model the edges deliberately

The mapping points from a task to things it needs, which is easy to reverse accidentally. Write a small test with a known chain and assert the index of fetch is less than that of build, and the index of build is less than that of test. Add a two-node cycle and assert that it raises. For the failure case, pass {'a': ['b'], 'b': ['a']} and check for CycleError before dispatching anything.

This function returns a plan; it does not run tasks or decide how to recover from failed execution. If a build fails, a caller must stop its dependents and record the failure. If tasks run concurrently, use the documented prepare, get_ready and done protocol, calling done only after a task has actually completed according to the application's success policy. The sorter does not implement retries, persistence or resource limits. Validate task names against a trusted registry before dispatch, since a valid graph alone does not make an unknown task safe to execute.

Keep planning separate from dispatch

Build the complete dependency map from configuration before invoking order_tasks. If configuration changes after planning, treat the returned tuple as a snapshot and rebuild it before the next run. A valid order says nothing about whether the input artifact exists or whether a task has permission to write its output. Check those requirements in the execution layer, then record actual task outcomes separately from the planned order. This distinction makes a failed run understandable without pretending that a planned task necessarily ran.