Ben Griffiths
04/23/2026, 12:41 PMMarvin
04/23/2026, 12:41 PMMarvin
04/23/2026, 12:43 PMasync so you can run them concurrently with asyncio.gather() or an anyio task group.
That keeps everything in the same Python process/container while still creating child flow runs in Prefect.
Recommended pattern
python
import asyncio
from prefect import flow
from prefect.concurrency.asyncio import concurrency
@flow
async def subflow(item: int):
# Optional: limit access to some shared resource
async with concurrency("subflow-slots", occupy=1):
await asyncio.sleep(2)
return item * 10
@flow
async def main_flow():
results = await asyncio.gather(
*(subflow(i) for i in range(10))
)
return results
if __name__ == "__main__":
asyncio.run(main_flow())
A few important notes:
* subflow(...) called directly from a flow is a nested flow.
* Nested flows run in the same container/pod as the parent flow.
* Using asyncio.gather() lets those nested async subflows run concurrently.
* The concurrency(...) context manager can enforce a shared limit.
If you want to cap concurrency at, say, 3, create a global concurrency limit first:
bash
prefect gcl create subflow-slots --limit 3
I verified the CLI exists in Prefect 3.x via prefect gcl --help / prefect gcl create --help.
So with 10 subflows started concurrently, only 3 will be inside the guarded section at a time.
Why this is the right approach
Direct nested subflow calls
- stay in the same pod/container
- create child flow runs visible in the UI
- are simplest if you want parent/child structure
Deployment-based subflow execution like run_deployment(...)
- does not guarantee same container/pod
- usually creates a separate flow run to be picked up by a worker
- is better when you want isolation or independent scheduling, but not for your requirement
What not to do
* Don’t use deployment-based execution if “same pod” is required.
* Don’t expect .submit() on flows — that is for tasks, not flows.
* Don’t rely on task-runner concurrency alone for this specific pattern; task runners control task concurrency, not nested flow scheduling.
Best practice summary
If your goal is:
- same pod/container
- concurrent subflows
- bounded concurrency
then use:
1. @flow async def ... for parent and child flows
2. asyncio.gather(...) to launch subflows concurrently
3. prefect.concurrency.asyncio.concurrency(...) to enforce the limit
If you want, I can also give you:
* a full example with a Semaphore-only approach
* a version using anyio.create_task_group()
* or guidance on whether this should actually be modeled as tasks instead of subflows for better performance.Ben Griffiths
04/23/2026, 3:20 PMMarvin
04/23/2026, 3:20 PMMarvin
04/23/2026, 3:20 PMThreadPoolTaskRunner, plus a task-level concurrency limit if needed.
Why:
* asyncio won’t help much with blocking Pandas work.
* Nested sync subflows called directly will generally block the parent unless you add your own thread/process orchestration.
* Prefect’s built-in concurrency model is much stronger for tasks than for flows.
Recommended approach
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import wait
@task
def process_partition(partition_id: int):
# blocking pandas work here
# df = ...
# transformed = ...
return partition_id * 10
@flow(task_runner=ThreadPoolTaskRunner(max_workers=3))
def main_flow():
futures = [process_partition.submit(i) for i in range(10)]
wait(futures)
return [f.result() for f in futures]
if __name__ == "__main__":
main_flow()
That gives you:
* concurrency in the same container/pod
* a hard cap via max_workers=3
* proper Prefect orchestration primitives
If you really need “subflow-like” grouping
If those units are currently subflows because you want structure/logging/retries, you have two options:
1. Refactor the heavy Pandas unit into tasks and keep the parent as a flow
2. Keep a thin subflow wrapper, but do the concurrent work as tasks inside it
Example:
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import wait
@task
def pandas_work(item):
return item * 10
@flow
def subflow(item):
# sequential wrapper around task(s)
return pandas_work(item)
@flow(task_runner=ThreadPoolTaskRunner(max_workers=3))
def main_flow():
futures = [pandas_work.submit(i) for i in range(10)]
wait(futures)
return [f.result() for f in futures]
But if your real need is concurrent execution, submit tasks, not subflows.
When threads are enough vs processes
For Pandas:
* If operations release the GIL or are I/O-heavy, threads can work well.
* If the workload is truly CPU-bound Python-heavy logic, threads may not scale much.
In practice, many Pandas/numpy operations do okay with threads, so I’d start with ThreadPoolTaskRunner.
If you absolutely must keep them as subflows
Then Prefect doesn’t have a built-in “submit subflows concurrently in the same pod” primitive like tasks have. You’d be in “manual Python concurrency” territory, e.g. concurrent.futures.ThreadPoolExecutor calling sync subflows directly.
That can work, but it’s not the cleanest/recommended Prefect pattern.
Conceptually:
python
from concurrent.futures import ThreadPoolExecutor
from prefect import flow
@flow
def subflow(x):
# blocking pandas work
return x * 10
@flow
def main_flow():
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(subflow, i) for i in range(10)]
return [f.result() for f in futures]
I’d treat this as a fallback, not the first choice.
My recommendation
For blocking Pandas work in the same pod:
Prefer tasks + ThreadPoolTaskRunner(max_workers=N) over concurrent subflows.
Docs and APIs:
* Prefect task runners: https://docs.prefect.io/v3/develop/task-runners
* Prefect concurrency concepts: https://docs.prefect.io/v3/develop/global-concurrency-limits
* Flows: https://docs.prefect.io/v3/develop/write-flows
If you want, I can help convert your current “main flow + subflows” design into the closest equivalent task-based pattern while preserving retries, logging, and result handling.Ben Griffiths
04/23/2026, 3:22 PMMarvin
04/23/2026, 3:22 PMMarvin
04/23/2026, 3:26 PM@flow
- subflow is sync / blocking Pandas
- parent flow wants to launch many of them concurrently
- same pod/container
- do not convert the subflow to a task
there is no clean built-in Prefect primitive that does this the way task.submit() does for tasks.
What is supported
- @flow called from another flow => nested flow, same process/container, but blocking
- async nested flows with asyncio.gather(...) => concurrent
- tasks with ThreadPoolTaskRunner => concurrent in same pod
What is not a recommended/supported pattern
Using ThreadPoolExecutor to call sync subflows concurrently, like this:
python
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(process_file_flow, f) for f in files]
This is problematic because Prefect uses context variables for flow-run context, and new threads do not automatically inherit that nested-flow context correctly. So this is not a reliable / recommended Prefect pattern.
Best practical pattern if you want to preserve a standalone flow entrypoint
The usual compromise is:
1. Put the actual file-processing logic in a plain Python function
2. Wrap that function in a standalone @flow for independent execution
3. From the parent flow, run the plain function concurrently as tasks or threads
That preserves a standalone flow for one-file runs, while using a concurrency-friendly execution path in the parent.
Conceptually:
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import wait
def process_file_logic(path: str):
# blocking pandas work
return path
@flow
def process_file_flow(path: str):
return process_file_logic(path)
@task
def process_file_task(path: str):
return process_file_logic(path)
@flow(task_runner=ThreadPoolTaskRunner(max_workers=4))
def process_many_files_flow(files: list[str]):
futures = [process_file_task.submit(f) for f in files]
wait(futures)
return [f.result() for f in futures]
This gives you:
- a standalone flow for single-file processing
- concurrent same-pod execution from the parent
- proper Prefect-supported concurrency
I realize you said you do not want to change the flow to a task — this pattern does not remove the standalone flow. It just extracts the shared logic so the parent can use Prefect’s supported concurrency model.
If you insist on flow-to-flow concurrency without converting anything
You would be in unsupported/manual territory. There are hacky approaches involving manual context hydration across threads, but I would not recommend them for production unless you’re willing to own the complexity and edge cases.
So the honest answer is
You have three options:
1. Best supported: refactor shared logic into a plain function, keep the single-file flow wrapper, and use tasks for concurrent parent execution
2. If possible: make the subflow async and use asyncio.gather(...)
3. Unsupported fallback: manually thread sync subflow calls, with Prefect context workarounds
For most users, option 1 is the right answer.
Relevant docs:
- Flows / nested flows: https://docs.prefect.io/v3/develop/write-flows
- Task runners: https://docs.prefect.io/v3/develop/task-runners
- Global concurrency limits: https://docs.prefect.io/v3/develop/global-concurrency-limits
If you want, I can show you the smallest refactor that preserves your current process_file_flow(...) API while adding a parent flow that runs N files concurrently in the same pod.Ben Griffiths
04/23/2026, 3:37 PMMarvin
04/23/2026, 3:37 PMMarvin
04/23/2026, 3:38 PMrun_deployment().
run_deployment() creates a separate flow run that is meant to be picked up by deployment infrastructure / workers, so it will typically launch separate execution environments rather than stay inside the current pod.
So your requirement is:
- one parent flow running in one pod
- process up to 10 files concurrently
- each file currently corresponds to a “subflow”
- keep everything in the same pod
- avoid spawning a new pod per file
For that, the honest answer is:
Prefect does not have a built-in supported way to execute sync subflows in parallel in the same pod the way tasks can be executed in parallel.
So if you stay with flow-per-file and want parallelism in one pod, you’re fighting the model a bit.
What run_deployment() is doing
- creates independent flow runs
- those runs are scheduled/executed by workers
- they are isolated from the parent process
- therefore you get a new pod/container per file in Kubernetes-style setups
That behavior is expected.
What would keep execution in the same pod
Only work that executes inside the parent process:
- direct nested flow calls
- tasks
- plain Python concurrency
But:
- direct sync nested flow calls are blocking
- concurrent sync nested flows are not a first-class/supported Prefect pattern
- tasks are the supported same-process concurrency primitive
So, given your constraints, you have two realistic paths:
Option 1: Best supported
Keep the “single-file” unit as a standalone flow for standalone use, but have the parent flow call shared processing logic concurrently in-process.
Example structure:
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import wait
def process_file_logic(path: str):
# heavy blocking pandas logic
return path
@flow
def process_file_flow(path: str):
return process_file_logic(path)
@task
def process_file_task(path: str):
return process_file_logic(path)
@flow(task_runner=ThreadPoolTaskRunner(max_workers=10))
def process_many_files_flow(files: list[str]):
futures = [process_file_task.submit(path) for path in files]
wait(futures)
return [f.result() for f in futures]
This preserves:
- process_file_flow(path) as a standalone flow
- parallel processing of 10 files at a time
- all execution in the same pod/container
This is the cleanest answer.
Option 2: Manual Python concurrency around plain functions
If you absolutely refuse to use tasks for the parent-side parallelism, you can use ThreadPoolExecutor on a plain Python function.
python
from concurrent.futures import ThreadPoolExecutor
from prefect import flow
def process_file_logic(path: str):
# blocking pandas logic
return path
@flow
def process_file_flow(path: str):
return process_file_logic(path)
@flow
def process_many_files_flow(files: list[str]):
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(process_file_logic, files))
return results
This also keeps everything in the same pod.
But note:
- now the parallel units are not subflow runs
- you lose per-file child-flow observability in Prefect
- this is Python concurrency, not Prefect orchestration
What I would not recommend
Trying to concurrently invoke the sync @flow itself in threads, e.g. executor.submit(process_file_flow, path)
That is the problematic part. Prefect flow context is not designed for that as a clean supported nested-flow concurrency mechanism.
Bottom line
If you need 10 files at a time in one pod, you have to keep the parallel work inside the current process.
That means one of:
1. Preferred: parent flow submits tasks that call shared file-processing logic
2. Fallback: parent flow uses Python threads/processes on shared file-processing logicMarvin
04/23/2026, 3:38 PMrun_deployment() because it creates separate runs/infrastructure
4. Not really supported: concurrent sync nested subflows in one pod
So the shortest possible answer is:
You cannot get “parallel subflow runs in the same pod” via run_deployment(). If you want same-pod parallelism, move the concurrent unit of work to tasks or plain Python functions inside the parent process.
Docs:
- Flows: https://docs.prefect.io/v3/develop/write-flows
- Task runners: https://docs.prefect.io/v3/develop/task-runners
- Deployments / flow runs: https://docs.prefect.io/v3/deploy/index
If you want, I can sketch the exact refactor pattern so that:
- your existing single-file flow remains unchanged for standalone runs
- your multi-file flow gets a minimal change
- concurrency is capped at 10
- everything stays in the same pod.Nate
04/23/2026, 3:47 PMNate
04/23/2026, 3:49 PM@flow(task_runner=ThreadPoolTaskRunner(max_workers=10))
def do_things()
return process.map(inputs).result()
is what i'd recommend. but if you don't feel like making them tasks (which are the things that intentionally have the concurrency primitives) then a semaphore is what i'd use. no run_deployment, just giving flows the stdlib asyncio treatmentBen Griffiths
04/23/2026, 4:01 PM.submit() the task and use the ThreadPoolTaskRunner to limit concurrency. This worked, but is adds an extra layer of DAG bars to drop down in the UI to get to the tasks for a given subflow - not an issue by any means, was just wondering if there was a neater way to do it.
Currently I have something like this, but since changing infrastructure a bit I don't want to spawn a new pod for every file, so I need to call the subflow directly.
Claude suggested changing run_deployment to <http://anyio.to|anyio.to>_thread.run_sync, which appears to had the desired effect.
@flow
def subflow(file_id):
df = download_file(file_id)
df = processing_task_1(df)
df = processing_task_2(df)
@flow
async def parent_flow():
file_ids = get_file_ids()
# semaphore limits how many subflows run concurrently
semaphore = anyio.Semaphore(10)
async def process_file(file_id) -> dict:
async with semaphore:
await run_deployment(...) # runs subflow
await gather(
*(lambda id=id: process_file(id) for id in file_ids)
)