Ihor Ramskyi
05/13/2026, 1:43 PMMarvin
05/13/2026, 1:43 PMMarvin
05/13/2026, 1:45 PMflow.visualize(), Prefect runs the flow in a special visualization mode and tracks task calls. The core logic lives in Prefect’s visualization utilities.
At a high level, for each task call Prefect:
1. Collects the bound parameter values for that task call
2. Looks at each parameter value
3. Checks whether that value corresponds to a previously visualized task
4. If yes, it records that previous task as an upstream dependency
5. The graph renderer draws arrows from those upstream tasks to the current one
The important detail is that Prefect uses two concrete checks for each task parameter:
text
if parameter value is a VizTask:
it's an upstream dependency
elif id(parameter value) is in object_id_to_task:
it's an upstream dependency
So the visualizer is driven by:
* direct VizTask objects
* or Python object identity (id(...)) of tracked return values
What this means in practice
If you do this:
python
@flow
def f():
x = task_a()
y = task_b(x)
then x is still a tracked object from task_a, so task_b gets an edge from task_a.
But if you do this:
python
@flow
def f():
x = task_a()
y = x + 1
task_b(y)
the edge may disappear.
Why? Because x + 1 creates a new plain Python object, and that new object is not necessarily associated with task_a anymore. The visualizer does not infer “`y` came from `x`”; it only sees that task_b got y, and y is not one of the tracked task-output objects.
That’s the main reason it looks inconsistent.
The internal data structures
Prefect maintains a tracker something like this:
* `tasks`: all visualized tasks
* `object_id_to_task`: a map from Python object id to the task that produced it
* `dynamic_task_counter`: used to generate names like task_a-0, task_a-1
Each visualized task is represented by a small object containing roughly:
* name
* upstream_tasks
Then the renderer just draws an edge for each upstream task.
So the graph is not computed by analyzing your Python source code. It is built from these recorded runtime relationships.
Why arrows can disappear unexpectedly
Here are the main failure modes.
1. You transform the result in regular Python code
python
@flow
def f():
a = task_a()
b = a["x"]
task_b(b)
or
python
b = a + 1
b = some_function(a)
b = [a]
b = {"v": a}
These often break edge detection because Prefect does not recursively analyze or preserve provenance through arbitrary Python operations.
2. The returned object is not trackable by identity
Some Python values are bad candidates for identity-based tracking, especially singleton/cached values.
Examples include:
* None
* True / False
* Ellipsis
* NotImplemented
* often small integers like 0, 1, 2, etc. because CPython reuses them
If a task returns one of those, Prefect may not safely register it as a unique output object for dependency tracking.
So this can fail:
python
@task
def task_a():
return 1
@flow
def f():
x = task_a()
task_b(x)
Conceptually there is a dependency, but the visualizer may not show it reliably because 1 is not a good unique tracked object.
3. The dependency is hidden inside a collectionMarvin
05/13/2026, 1:45 PMpython
@flow
def f():
a = task_a()
task_b([a])
This may not create an edge, because the parameter itself is the list, not the original tracked task-return object.
4. The relationship exists conceptually, but not as a direct parameter
If task B depends on task A only because some intermediate flow code used A’s result to compute something else, the visualizer may miss it.
It tracks task-call inputs, not abstract data lineage.
5. Some execution styles are unsupported in visualization
Certain task invocation patterns are not supported cleanly by the visualizer, such as some async/submission/mapping-style patterns. In those cases Prefect may error or simply not show the dependency structure you expect.
Best mental model
The most accurate mental model is:
The visualizer draws an arrow only when a downstream task receives a parameter value that Prefect can directly match to an upstream task’s tracked visualization object or tracked return object identity.
Not:
“an arrow appears whenever task B logically depends on task A.”
That distinction explains most of the inconsistencies.
Why direct passing works best
This is the most reliable pattern:
python
@flow
def f():
a = extract()
b = transform(a)
c = load(b)
This is less reliable for visualization:
python
@flow
def f():
a = extract()
b = a["records"]
c = b[:10]
load(c)
Even though load clearly depends on extract, the visualizer may lose the connection because the original tracked object got replaced by ordinary Python-derived values.
If you want more consistent arrows
A practical workaround is to keep transformations inside tasks instead of in the flow body.
Instead of:
python
@flow
def f():
a = extract()
b = a["records"]
load(b)
prefer:
python
@task
def select_records(data):
return data["records"]
@flow
def f():
a = extract()
b = select_records(a)
load(b)
Now the dependencies are explicit task-to-task, and the visualizer can represent them much more consistently.
Relevant source/docs
I don’t have a public docs page that explains this exact internal routine in detail, but the behavior comes from Prefect’s visualization internals in the Prefect 3 codebase. General task/flow concepts are here:
* Prefect flows: docs.prefect.io/v3/concepts/flows
* Prefect tasks: docs.prefect.io/v3/concepts/tasks
* Prefect repository: github.com/PrefectHQ/prefect
If you want, I can also give you:
* a minimal reproducible set of examples showing exactly when edges appear/disappear, or
* a line-by-line walkthrough of the relevant visualization code path.Ihor Ramskyi
05/13/2026, 1:53 PMMarvin
05/13/2026, 1:54 PMMarvin
05/13/2026, 1:57 PMa -> b -> c -> d -> e.
Important: wait_for will not fix the visualization
wait_for affects execution ordering, but during flow.visualize() it is not what the graph uses to infer edges. The visualizer looks at task parameters, not execution dependencies. So adding wait_for may change runtime behavior, but it will not reliably remove those extra arrows from the graph.
Why the extra arrows happen
If you have something like:
python
@flow
def f():
a_out = a()
b_out = b(a_out)
c_out = c(b_out, a_out)
d_out = d(c_out, b_out)
e_out = e(d_out, c_out, a_out)
then the visualizer is doing exactly what it was built to do:
* c received a_out and b_out → draw a -> c and b -> c
* d received b_out and c_out → draw b -> d and c -> d
* e received a_out, c_out, d_out → draw a -> e, c -> e, d -> e
So if an old upstream result is still present in the call signature anywhere downstream, that old upstream gets an edge.
That means if you want only:
text
a -> b -> c -> d -> e
then each task call must receive only the immediate predecessor’s tracked result.
How to prevent extra edges
You need to structure the flow so that old tracked outputs are not passed into later tasks unless you want a direct edge.
The safest rule is:
Only pass the exact upstream task result(s) you want shown as incoming edges.
So instead of this:
python
@flow
def f():
x = a()
y = b(x)
z = c(y, x) # this creates a -> c
q = d(z, y) # this creates b -> d
e(q, z, x) # this creates a -> e and c -> e
prefer this:
python
@flow
def f():
x = a()
y = b(x)
z = c(y)
q = d(z)
e(q)
If c still needs information originally produced by a, then incorporate that into `b`’s output or move the combining logic into a task so only one tracked object continues downstream.
Best patterns to use
1. Collapse intermediate data into one downstream payload
Instead of carrying multiple upstream task outputs forward, have one task package what the next task needs.
python
@task
def a():
return {"raw": 1}
@task
def b(data):
return {"for_c": data["raw"] + 1}
@task
def c(data):
return {"for_d": data["for_c"] * 2}
@task
def d(data):
return {"for_e": data["for_d"] - 3}
@task
def e(data):
return data["for_e"]
@flow
def chain():
x = a()
y = b(x)
z = c(y)
q = d(z)
e(q)
That will visualize much closer to a strict chain.
2. Don’t pass old outputs “just in case”
A common cause of graph clutter is a task signature like:
python
def c(current, original, metadata, previous_step):
If original or previous_step came from upstream tasks, each of those creates edges.
If you want a clean chain, avoid carrying those old task outputs forward directly.
3. Do preprocessing inside a task, not in the flow, if you want one edge
If c logically depends only on b, but b and a are both being passed because c needs fields from both, introduce a task that consolidates them:
python
@task
def merge_for_c(b_result, a_result):
return {
"x": b_result["x"],
"y": a_result["y"],
}
@flow
def f():
ar = a()
br = b(ar)
c_input = merge_for_c(br, ar)
cr = c(c_input)
Note though: this gives you:
text
a -> merge_for_c
b -> merge_for_c
merge_for_c -> c
not a -> b -> c.
If your goal is specifically a chain-shaped graph, then the data really needs to be chain-shaped too.Marvin
05/13/2026, 1:57 PMa_out is passed into b, c, and e, then a will connect to all of them. That is expected.
To avoid that, stop passing a_out directly after b if you don’t want a to show up everywhere.
What won’t help much
These usually won’t solve the “extra edge” problem in a robust way:
* wait_for
* hoping Prefect will do transitive reduction automatically
* using the same object but expecting only the “closest” edge to be shown
Prefect’s visualizer is not doing graph simplification. It shows direct detected parameter dependencies.
Practical checklist
If you want the graph to look like a -> b -> c -> d -> e, make sure:
* b(...) receives `a`’s result
* c(...) receives `b`’s result, and not `a`’s result
* d(...) receives `c`’s result, and not `a`’s or `b`’s result
* e(...) receives `d`’s result, and not any earlier result
If a task takes multiple prior task outputs, the graph will branch backward accordingly.
Bottom line
The way to make extra edges not appear is:
Do not pass earlier tracked task outputs into later tasks unless you want a direct arrow from those earlier tasks.
If you want, paste a small version of your flow and I can point out exactly which parameters are causing the extra edges and how to rewrite it so the visualizer becomes a clean chain.Ihor Ramskyi
05/13/2026, 2:04 PMMarvin
05/13/2026, 2:04 PMMarvin
05/13/2026, 2:07 PMD only receives the object returned by C
* and Prefect still draws A -> D
then the most likely explanation is this:
C is returning the same Python object instance that originally came from A
possibly mutated in place and passed through `B`/`C`.
In that case, as far as the visualizer is concerned, D is still receiving A’s tracked object identity.
Why that happens
Prefect visualization tracks dependencies by Python object identity using id(...).
So if you do something like this:
python
@task
def a(x):
return {"value": x}
@task
def b(data):
data["b"] = 1
return data
@task
def c(data):
data["c"] = 2
return data
@task
def d(data):
return data
then b and c are mutating and returning the same dict object created by a.
That means:
* output of a has identity id_123
* b returns same object, still id_123
* c returns same object, still id_123
* d receives object with id_123
So the visualizer can still associate that object with upstream tasks in ways that create the extra edges you’re seeing.
Important subtlety
The visualizer does not recursively inspect nested data to find A inside `C`’s output.
But it does inspect the top-level parameter object identity.
So:
* if C returns a new container object that merely contains data from A, then A -> D should not appear
* if C returns the same object instance that originated earlier, then A -> D can appear
That’s the distinction.
What to do to prevent extra arrows
If you want only ... -> C -> D, then C must return a new object instance, not the same mutable object passed through from upstream.
So instead of mutating in place:
python
@task
def c(data):
data["c"] = 2
return data
do something like:
python
@task
def c(data):
return {**data, "c": 2}
or for models / dataclasses / pydantic objects, create a copy:
python
@task
def c(data):
new_data = data.copy()
new_data["c"] = 2
return new_data
The exact method depends on your object type:
* dict → new = dict(old) or {**old, ...}
* list → new = list(old)
* dataclass → replace(...)
* pydantic model → model_copy(update=...)
* custom class → construct a fresh instance
Rule of thumb
To avoid inherited/transitive-looking edges in visualization:
Never mutate and return the same object instance across tasks if you want the graph to show only the latest step.
Instead:
* create a new object in each task
* return that new object
* pass only that new object downstream
Example
This tends to create extra edges:
python
@task
def a():
return {"steps": ["a"]}
@task
def b(data):
data["steps"].append("b")
return data
@task
def c(data):
data["steps"].append("c")
return data
@task
def d(data):
return data
This is safer for visualization:
python
@task
def a():
return {"steps": ["a"]}
@task
def b(data):
return {"steps": [*data["steps"], "b"]}
@task
def c(data):
return {"steps": [*data["steps"], "c"]}
@task
def d(data):
return data
Now each task returns a fresh dict/list structure, so Prefect sees each step as a new object identity.
One more thing
If you are already returning fresh objects and still seeing A -> D, then I’d suspect one of these:
* your “copy” is shallow, but the tracked top-level object is still the same
* a custom object is being mutated and returned in place
* viz_return_value is involved somewhere
* one of the tasks is actually forwarding the same object reference
If you want, paste a tiny 4-task example with the object type you’re passing (dict, dataclass, pydantic model, custom class, etc.), and I can tell you exactly whether you’re preserving object identity and why Prefect is drawing A -> D.