Mike Dedys
12/04/2025, 3:58 PMMarvin
12/04/2025, 3:58 PMMarvin
12/04/2025, 4:04 PMtask.map([...]) returns a list of futures. The parent flow only waits for futures that you:
- explicitly wait on (.wait() or .result()), or
- include in the flow’s return value, or
- chain into downstream tasks that the flow waits on.
If your mapped tasks call a subflow:
- Calling a subflow from within a task is synchronous for that task — the task won’t finish until the subflow finishes.
- However, the parent flow will only wait if it’s waiting on those task futures (per the rules above). If you submit mapped tasks and ignore the futures, the flow can finish before those task/subflow runs complete.
To ensure the parent waits for all child subflows started by mapped tasks, do one of:
futs = my_task.map(items)
futs.wait() # waits for all tasks (and thus their subflows)
or return the futures:
return my_task.map(items) # flow will wait as it resolves returned futures
or chain them into downstream work the flow awaits.
Clarifying question: when you say the tasks “spawn a new flow,” are you calling another @flow directly inside the task, or triggering a deployment with run_deployment? If it’s deployments, you can control waiting with the timeout parameter (e.g., wait by default/with timeout=None, or fire-and-forget with timeout=0).Mike Dedys
12/04/2025, 4:26 PM@flow
def c():
sleep(5)
return "done"
@task
def b():
return c()
@flow(
name="test-flow",
)
def a():
b()
How would I ensure a wait for complete of all of c?Marvin
12/04/2025, 4:26 PMMarvin
12/04/2025, 4:27 PMa() calls the task b() and then exits without using the returned future, so it may finish before b (and therefore c) completes.
Use one of these patterns to ensure a waits for c to finish:
1) Return the task call (simplest)
@flow
def a():
return b() # Prefect will wait and resolve this before marking the flow complete
2) Explicitly wait on the task future
@flow
def a():
fut = b() # returns a PrefectFuture
fut.wait() # block until b (and its subflow c) completes
Or, if you need the value:
@flow
def a():
result = b().result()
return result
3) Call the subflow directly from the flow (bypasses the task wrapper)
@flow
def a():
return c() # subflow calls are synchronous; a waits for c to finish
Because b returns c(), waiting on b ensures the subflow c has fully completed.Mike Dedys
12/04/2025, 5:27 PMMarvin
12/04/2025, 5:27 PMMarvin
12/04/2025, 5:31 PMreturn b()), which makes the flow’s result a nested State structure instead of simple data. The UI “Runs” list can struggle to render unexpectedly nested State payloads and may appear to not load.
Two safe fixes:
- Return plain data, not the future/state:
@flow
def a():
return b().result() # ensures the flow returns "done", not a State
- Or wait but return something simple:
@flow
def a():
fut = b()
fut.wait() # waits for c
return "ok" # simple return value for the UI
Alternatively, call the subflow directly:
@flow
def a():
return c() # returns "done" directly
Could you share which exact change you made (e.g., return b() vs b().result()), your Prefect version, and whether the single run detail page loads? If you can, also check your browser dev tools Network/Console when opening the Runs page for any API errors—those clues will confirm this is a result-payload rendering issue.Mike Dedys
12/04/2025, 6:26 PMMarvin
12/04/2025, 6:26 PMMarvin
12/04/2025, 6:29 PMfrom prefect import flow, task
@task
def t(x): return x + 1
@flow
def f():
futs = t.map([1,2,3]) # creates TaskRun records
futs.wait()
return futs.result()
- If you call subflows, call them from a task or from the flow but also have at least one task so there’s a parent/child relationship to render.
- Check a specific run’s Graph tab (not just the list) and confirm TaskRuns exist under the “Tasks” section of the run details.
- Confirm your Prefect version and server/UI are up to date.
- If you still see nothing, open browser dev tools on the run page and check for errors fetching GET /flow_runs/{id}/graph-v2. That response will indicate why there are no nodes.
If you can share a minimal flow that reproduces the empty graph (your current flow code and Prefect version), I can point out exactly why it yields no nodes and how to adjust it.Mike Dedys
12/04/2025, 6:43 PM@flow
def c():
sleep(5)
return "done"
@task
def b():
return c(integration=integration)
@flow(
name="a",
)
def a():
get_run_logger().info("Starting Loopio sync flow.")
connected_integrations = tasks.b()
fut = b.map()
fut.wait()
return fut.result()Marvin
12/04/2025, 6:43 PMMarvin
12/04/2025, 6:44 PMc() is a flow with no tasks inside; it just sleeps. Its own Graph tab will be empty by design. If you want a node in c, wrap the work in a task.
- You’re calling c() from inside a task (b). Even if that does create a subflow run, you generally won’t see a separate “c” node in the parent flow’s graph when the subflow is invoked from within a task. The graph will show the task (b) node, not a separate subflow node for c.
- In a(), you call tasks.b(), which looks like a different function than your @task def b(...) above. If tasks.b() isn’t decorated with @task, it won’t create any TaskRun nodes.
- The mapped function start_etl_for_connected_integration must be an @task. If it’s a plain function, .map on it won’t create TaskRuns, and the graph will be empty.
How to make the graph populate
- Ensure anything you want to appear as a node is an @task (or a subflow called from a flow).
- If c is meant to be a subflow, call it from the flow a (not from b) or put its work in a task.
Example A: keep c as a flow but give it a task to render, and call it from the flow
from time import sleep
from prefect import flow, task, get_run_logger
@task
def wait_task():
sleep(5)
@flow
def c():
wait_task()
@task
def start_etl_for_connected_integration(integration):
# do work here
return f"done {integration}"
@flow(name="a")
def a():
logger = get_run_logger()
<http://logger.info|logger.info>("Starting Loopio sync flow.")
connected_integrations = ["int-1", "int-2"] # or produce via a task
# Call subflow directly from the flow (shows as a node)
c()
futs = start_etl_for_connected_integration.map(integration=connected_integrations)
futs.wait()
return futs.result()
Example B: if c doesn’t need to be a flow, make it a task
from time import sleep
from prefect import flow, task, get_run_logger
@task
def c():
sleep(5)
@task
def start_etl_for_connected_integration(integration):
# do work here
return f"done {integration}"
@flow(name="a")
def a():
get_run_logger().info("Starting Loopio sync flow.")
connected_integrations = ["int-1", "int-2"] # or produce via a task
# This will appear in the graph
c()
futs = start_etl_for_connected_integration.map(integration=connected_integrations)
futs.wait()
return futs.result()
Debug checklist
- Is start_etl_for_connected_integration decorated with @task? If not, it won’t show.
- Is tasks.b() actually your @task def b(...)? If not, it won’t show.
- On the run details page, do you see TaskRuns listed under “Tasks”? If not, the graph will be empty.
- In browser dev tools, check the response of GET /flow_runs/{id}/graph-v2 — if nodes is an empty list, the API isn’t finding any TaskRuns for that run.
If you can share the definitions of tasks.b and start_etl_for_connected_integration, and confirm your Prefect version, I can pinpoint the exact reason your graph is empty.Mike Dedys
12/04/2025, 6:47 PM@task
def task_a(val):
return val + 1
@task
def task_b(val):
return val * 2
@flow
def c(target):
result1 = task_a(target)
result2 = task_b(result1)
return result2
@task
def b(target):
# do work here
return c(target)
@flow(name="a")
def a():
connected_integrations = ["int-1", "int-2"] # or produce via a task
futs = b.map(connected_integrations)
futs.wait()
return futs.result()Marvin
12/04/2025, 6:47 PMMarvin
12/04/2025, 6:48 PMfuts = b.map(target=connected_integrations)
- If b isn’t actually the same @task you showed (e.g., calling something like tasks.b that’s not decorated), there will be no TaskRun nodes for a.
- If c has no tasks (in your earlier examples it only slept), its Graph will be empty by design.
- If nothing in a creates TaskRun records (e.g., b isn’t a task or mapping didn’t run), the a Graph will be empty even though logs exist.
How to make the Graph clearly populate
- Keep your sample but use keyword mapping:
@task
def task_a(val): return val + 1
@task
def task_b(val): return val * 2
@flow
def c(target):
r1 = task_a(target)
r2 = task_b(r1)
return r2
@task
def b(target):
return c(target)
@flow(name="a")
def a():
connected_integrations = ["int-1", "int-2"]
futs = b.map(target=connected_integrations) # keyword arg
futs.wait()
return futs.result()
- Graph for a: 2 task nodes (b[0], b[1]).
- Graph for each c run: task_a -> task_b.
If you want subflow c to appear as nodes in a’s Graph, call it from the flow (not inside a task):
@flow(name="a")
def a():
connected_integrations = ["int-1", "int-2"]
for integ in connected_integrations:
c(integ) # subflow nodes will appear on a’s graph
Note: flows don’t support .map — use a loop.
Quick debug checklist
- In the run details for a, do you see TaskRuns listed? If not, the graph will be empty.
- Confirm b is the same @task you defined (not a different module’s function).
- Open your browser dev tools on the run page and check the response for GET /flow_runs/{id}/graph-v2. If nodes: [], the API isn’t finding any TaskRuns for that run, which usually means the task wasn’t invoked as a Prefect task (or mapping didn’t submit).
If this still shows an empty Graph, share your Prefect version and a specific run ID; we can inspect what the API returns for /flow_runs/{id}/graph-v2 and pinpoint the cause.Mike Dedys
12/04/2025, 6:49 PMMarvin
12/04/2025, 6:49 PMMarvin
12/04/2025, 6:50 PMc returns result2 which is a task future; Prefect resolves that into a State. Since b returns c(target) and you map b, the task runs may end up returning State objects nested inside State records. That can trip up the UI before it even asks for the graph.
- Version mismatch (self-hosted). If your UI and API versions don’t match, the UI may not call the v2 graph endpoint (or may fail before it does).
What to try right now
1) Make sure flows/tasks return plain data, not futures/states, to avoid nested State payloads:
@flow
def c(target):
r1 = task_a(target)
r2 = task_b(r1)
return r2.result() # ensure c returns data, not a future/state
@task
def b(target):
return c(target) # now returns plain data
2) Confirm mapped tasks are actually Prefect tasks:
futs = b.map(target=connected_integrations) # keyword mapping
futs.wait()
return futs.result()
With the change above, you should see TaskRuns for b on a’s graph, and c’s inner graph on c’s run page.
3) Check the Network/Console on the run page:
- In browser dev tools, select the Graph tab for a specific run, then look for:
- GET /flow_runs/<run_id>/graph-v2
- Any Console errors before the graph request fires (these usually point to result/state parsing issues).
4) If self-hosted, confirm versions match:
- prefect version on the server
- UI build version (footer or /about)
- If they differ, upgrade to the latest 3.x on both and try again.
If after returning plain data you still don’t see a graph request, please share:
- Prefect version and whether you’re on Cloud or self-hosted
- A specific run ID and a screenshot or text of any Console error
- The Network response (or lack thereof) for /flow_runs/<id>/graph-v2
With that, I can tell you whether it’s a UI bug we should escalate or a data/compatibility issue.Mike Dedys
12/04/2025, 6:51 PMMarvin
12/04/2025, 6:51 PMMarvin
12/04/2025, 6:52 PM