How do you detect a cycle in a directed graph?
Updated Feb 20, 2026
Short answer
Run a DFS while tracking two states per vertex: whether it has ever been visited, and whether it is on the current recursion stack. Encountering a vertex that is on the current stack means you have found a back edge, which is exactly a cycle. The alternative is Kahn's algorithm — perform a topological sort and, if fewer vertices come out than went in, the leftovers form a cycle. Both run in O(V+E).
Deep explanation
The subtlety that separates a correct answer from a plausible one is that a visited vertex is not evidence of a cycle in a directed graph. Consider A → B, A → C, B → D, C → D. DFS from A visits D via B, then reaches D again via C. D is visited, but there is no cycle — that is a cross edge, not a back edge.
You need three states, conventionally white (unvisited), grey (on the current recursion stack), and black (fully explored):
WHITE, GREY, BLACK = 0, 1, 2
def has_cycle(graph): state = {v: WHITE for v in graph}
def visit(v): state[v] = GREY # entering: on the current path for nbr in graph[v]: if state[nbr] == GREY: # back edge -> cycle return True if state[nbr] == WHITE and visit(nbr): return True state[v] = BLACK # leaving: fully explored return False
return any(state[v] == WHITE and visit(v) for v in graph)Note the outer loop: a graph may be disconnected, so you must start a DFS from every unvisited vertex or you will miss cycles in components you never reach.
Kahn's algorithm answers the same question iteratively and avoids recursion depth limits:
def has_cycle_kahn(graph): indeg = {v: 0 for v in graph} for v in graph: for nbr in graph[v]: indeg[nbr] += 1 queue = deque(v for v in graph if indeg[v] == 0) removed = 0 while queue: v = queue.popleft(); removed += 1 for nbr in graph[v]: indeg[nbr] -= 1 if indeg[nbr] == 0: queue.append(nbr) return removed != len(graph) # leftovers are in a cyclePrefer DFS colouring when you want to report the cycle — the grey vertices on the stack are the cycle itself. Prefer Kahn when you want a topological order anyway, or when the graph is deep enough to threaten the call stack.
For undirected graphs the rule is different: any visited neighbour that is not the immediate parent implies a cycle.
Real-world example
A build system must reject circular dependencies before it starts compiling.
build_graph = { 'app': ['ui', 'auth'], 'ui': ['components'], 'auth': ['crypto', 'ui'], 'components': ['utils'], 'crypto': ['utils'], 'utils': [],}No cycle here, so a topological order exists and the build proceeds bottom-up. If someone adds utils → app, DFS enters app (grey), descends to ui, components, utils, and from utils finds app still grey — the current path is app → ui → components → utils → app, reported verbatim as the offending cycle.
This is exactly how module bundlers surface circular imports and how package managers reject unsatisfiable dependency graphs.
Common mistakes
- - Treating any visited vertex as a cycle, which produces false positives on cross edges and forward edges in a DAG.
- - Forgetting to restart DFS from every unvisited vertex, so cycles in disconnected components go undetected.
- - Never resetting a vertex from grey to black on exit, which corrupts the state for subsequent traversals.
- - Applying the undirected rule — 'visited and not the parent' — to a directed graph, where the notion of parent does not constrain edges the same way.
- - Recursing on a very deep graph and overflowing the stack
- Kahn's algorithm or an explicit stack avoids it.
- - Reporting only that a cycle exists when the caller needs to know *which* vertices form it, which the grey set gives you for free.
Follow-up questions
- How does cycle detection differ for undirected graphs?
- What is the relationship between cycle detection and topological sorting?
- Can you detect a cycle without extra state per vertex?
- How would you find every cycle rather than just one?