oleander-whirlwind
06/08/2026, 8:45 AMMarvin
06/08/2026, 8:45 AMMarvin
06/08/2026, 8:47 AMpython
from prefect import flow, task
@task
def parallel_step(x):
return x * 2
@task(tags=["serial-step"])
def serial_step(x):
print(f"serial processing {x}")
return x + 1
@flow
def my_flow(items):
a = parallel_step.map(items)
b = serial_step.map(a.result())
return b.result()
Then create a concurrency limit of 1 for that tag.
I’d verify the CLI first, and in Prefect 3 the command is:
bash
prefect concurrency-limit --help
If you want, I can give you the exact command for creating that limit in your environment.
### Important behavior
If you do this inside a task:
python
@task
def outer(x):
return inner(x)
that works, but it’s effectively synchronous.
If you do:
python
@task
def outer(x):
fut = inner.submit(x)
return fut.result()
that also works, but can be awkward if you’re using a bounded task runner, because nested submissions can create blocking behavior.
### Best practice
For your use case, I’d recommend:
- keep orchestration at the flow level when possible
- use .map() or .submit() for parallel work
- use a concurrency limit = 1 on the serial task
That gives you:
- parallel upstream tasks
- only one execution of the bottleneck task at a time
- cleaner dependency management
### Alternative: explicit chaining
If you truly need strict serial order across all mapped items, you can chain with `wait_for`:
python
from prefect import flow, task
@task
def serial_step(x):
return x + 1
@flow
def my_flow(items):
futures = []
previous = None
for item in items:
if previous is None:
f = serial_step.submit(item)
else:
f = serial_step.submit(item, wait_for=[previous])
futures.append(f)
previous = f
return [f.result() for f in futures]
That enforces one-after-another ordering, not just “max concurrency 1”.
### Short answer
- Yes, tasks can call tasks.
- Yes, you can mix mapped concurrency with one serial step.
- Best solution: put a concurrency limit of 1 on the serial task instead of trying to manage it purely through nested task calls.
Helpful docs:
- Tasks
- Run work concurrently
If you want, I can show the exact Prefect 3 pattern for:
1. serializing one mapped task with a tag limit, or
2. preserving the original item order strictly.oleander-whirlwind
06/08/2026, 9:13 AMMarvin
06/08/2026, 9:13 AMMarvin
06/08/2026, 9:13 AMparallel_compute -> serial_write
- many computes can be in flight
- but writes must happen one at a time
- and compute for item N+1 should not get too far ahead of the serialized write queue
That means you need backpressure, not just a serial task.
### Important point
If you allow 100 compute tasks to run at once, and the write step is single-file, then you can still build up a queue of 100 results waiting to be written. A concurrency limit on the write task alone does not solve that.
### What to do instead
You want to limit the whole pipeline width so only a small number of items are in progress at once.
A good pattern is:
- one task does the “parallel” compute for a single item
- one task does the serialized write
- the flow only keeps a small number of item pipelines active at a time
For example, process items in small batches:
python
from prefect import flow, task
@task
def compute(item):
# expensive / parallelizable step
return f"result-{item}"
@task(tags=["serial-write"])
def write_result(result):
# must run one at a time
print(f"writing {result}")
@flow
def my_flow(items, window_size=5):
for i in range(0, len(items), window_size):
batch = items[i:i + window_size]
computed = compute.map(batch)
write_result.map(computed.result())
This does not launch all 100 at once.
It launches only window_size computes, then writes those, then moves on.
### If results must be written immediately
Then the safest design is actually not to map all compute tasks at once.
Instead, submit only a few item pipelines at a time:
python
from prefect import flow, task
@task
def compute(item):
return f"result-{item}"
@task(tags=["serial-write"])
def write_result(result):
print(f"writing {result}")
@flow
def my_flow(items, in_flight=3):
for i in range(0, len(items), in_flight):
chunk = items[i:i + in_flight]
futures = [compute.submit(item) for item in chunk]
results = [f.result() for f in futures]
write_futures = [write_result.submit(r) for r in results]
[f.result() for f in write_futures]
This gives you bounded parallelism:
- only in_flight computes happen at once
- writes remain serialized via tag concurrency limit
- you never accumulate a huge stale backlog
### If each item must write before more work starts
If you mean literally:
- item A compute
- item A write
- only then item B compute
then that is no longer parallel across items. That’s just serial processing:
python
from prefect import flow, task
@task
def compute(item):
return f"result-{item}"
@task
def write_result(result):
print(f"writing {result}")
@flow
def my_flow(items):
for item in items:
result = compute.submit(item).result()
write_result.submit(result).result()
### Best mental model
You likely want one of these:
Option A: small bounded pipeline window
- a few computes in flight
- one write at a time
- prevents stale results
Option B: fully serial per item
- safest
- least throughput
### What not to do
Avoid this shape if results expire quickly:
python
computed = compute.map(all_items)
write_result.map(computed.result())
because it creates a barrier:
- all compute first
- then all writes
That’s exactly the stale-result problem you described.
### My recommendation
Use bounded chunking / windowing plus a serial write task.
If you want, I can give you a Prefect 3 pattern that does a rolling window of, say, 5 items at a time, so compute and write overlap without letting the write queue grow unbounded.oleander-whirlwind
06/08/2026, 9:14 AMMarvin
06/08/2026, 9:14 AMMarvin
06/08/2026, 9:15 AMpython
from prefect import flow, task
@task
def compute(item):
return f"result-{item}"
@task(tags=["serial-write"])
def write_result(result):
print(f"writing {result}")
@task
def process_one(item):
result = compute(item) # nested task call
write_result(result) # nested task call
return result
@flow
def my_flow(items):
futures = [process_one.submit(item) for item in items]
return [f.result() for f in futures]
### Why that may or may not help
Here process_one runs concurrently for many items.
But inside each `process_one`:
- compute(item) runs
- then write_result(result) runs
If write_result is constrained to one-at-a-time, then many process_one task runs can still pile up waiting for that serialized step.
So nested tasks do express the dependency:
compute -> write
What they do not automatically guarantee is:
- bounded queue size
- freshness protection
- backpressure strong enough for your timeout constraints
### The real distinction
Nested tasks solve:
- “can item B’s write depend on item B’s compute?” → yes
Nested tasks do not by themselves solve:
- “how do I stop too many computed results from sitting around waiting for the single writer?”
That’s why I was steering toward bounded concurrency/windowing.
### If you want nested tasks anyway
Then the pattern is:
python
from prefect import flow, task
@task
def compute(item):
return f"result-{item}"
@task(tags=["serial-write"])
def write_result(result):
print(f"writing {result}")
return True
@task
def process_one(item):
result = compute(item)
write_result(result)
return result
@flow
def my_flow(items):
futures = process_one.map(items)
return futures.result()
### But one warning
If you map process_one over 100 items, you still have 100 item-pipelines active.
If all computes finish quickly and writes are slow, those 100 mapped parent tasks can still sit around waiting on the serialized write step.
So yes:
- nested task calls are valid
- they may even be a clean way to model your pipeline
- but they are not enough if your real issue is “results expire while waiting for the serial bottleneck”
### Short answer
You absolutely can call a task from a task.
I wasn’t discouraging nested tasks because they’re unsupported — I was warning that they don’t fully solve the timeout/backpressure problem unless you also limit how many process_one pipelines are active.
If you want, I can show you the exact nested-task version with bounded concurrency, which sounds like the thing you actually want:
- nested compute -> write
- only N item pipelines active at once
- serialized writer.oleander-whirlwind
06/08/2026, 9:17 AMMarvin
06/08/2026, 9:17 AMMarvin
06/08/2026, 9:19 AMpython
@task(tags=["serial-write"])
def write_result(x):
...
@task
def process_one(x):
y = compute(x)
write_result(y)
and "serial-write" is already occupied, then write_result(y) will wait for the tag slot.
### What happens to the parent task?
That depends on how the nested task is called:
#### 1. Direct nested call
python
write_result(y)
Then the parent task waits too.
process_one is blocked until write_result can acquire the concurrency slot and finish.
#### 2. Submitted nested task
python
f = write_result.submit(y)
f.result()
This is similar from your code’s perspective: the parent still waits at f.result().
If you do:
python
f = write_result.submit(y)
return f
then the parent can finish without waiting, and the child waits on the concurrency slot independently.
### Important subtlety
If the parent task itself also holds a concurrency-limited slot while waiting for the child, you can create a deadlock.
For example:
python
@task(tags=["serial-write"])
def parent(x):
child(x) # child also needs "serial-write"
If the limit for "serial-write" is 1, then:
- parent acquires the only slot
- parent calls child
- child tries to acquire the same slot
- child waits forever, because parent is holding it
So:
If parent and child share the same occupied concurrency limit, and parent waits for child, you can deadlock.
### For your case
If your goal is:
- compute(item) can run concurrently
- write(item) must be serial
then this is okay:
python
@task
def compute(item):
return item
@task(tags=["serial-write"])
def write(item):
...
@task
def process_one(item):
result = compute(item)
write(result)
Here:
- process_one tasks may run in parallel
- each one blocks when it reaches write
- only one write runs at a time
- others wait
That part works.
### But the practical consequence
Even though the nested child waits correctly, you can still end up with:
- many parent tasks sitting around blocked
- many computed results waiting for the serial writer
So the behavior is:
- not fail
- not skip
- not auto-retry in the user sense
- it waits until a slot opens
### CLI for tag concurrency limits
Verified CLI:
bash
prefect concurrency-limit --help
Useful docs:
- Tasks
- Run work concurrently
If you want, I can sketch the exact safe nested-task pattern for your pipeline, including how to avoid deadlocking the parent while still forcing write to serialize.oleander-whirlwind
06/08/2026, 9:21 AMMarvin
06/08/2026, 9:21 AMMarvin
06/08/2026, 9:23 AM@task decorator directly.
Task decorator tags are static:
python
@task(tags=["fixed-tag"])
def my_task():
...
If you want a tag equal to the current flow run name, use the tags(...) context manager at runtime.
### Example
python
from prefect import flow, task, tags
from prefect.runtime import flow_run
@task
def my_task():
pass
@flow
def my_flow():
run_name = flow_run.name
with tags(run_name):
my_task()
That applies the current flow run name as a dynamic tag to my_task.
### If you want both static and dynamic tags
They combine:
python
from prefect import flow, task, tags
from prefect.runtime import flow_run
@task(tags=["serial-write"])
def my_task():
pass
@flow
def my_flow():
with tags(flow_run.name):
my_task()
So the task run gets both:
- serial-write
- <current flow run name>
### Caveat
If the flow run name contains characters you don’t want to use as a tag, sanitize it first:
python
from prefect import flow, task, tags
from prefect.runtime import flow_run
@task
def my_task():
pass
@flow
def my_flow():
run_tag = flow_run.name.replace(" ", "-").lower()
with tags(run_tag):
my_task()
### If you need this around .submit() or .map()
That works too:
python
@flow
def my_flow(items):
with tags(flow_run.name):
futures = my_task.map(items)
### Key point
Use:
- prefect.runtime.flow_run.name to get the run name
- with tags(...) to apply it dynamically
Helpful references:
- Tasks
- `tags` context manager
If you want, I can show how to use the flow run ID or name as a per-flow concurrency tag for your nested serial-write pattern.oleander-whirlwind
06/08/2026, 9:26 AMMarvin
06/08/2026, 9:26 AMMarvin
06/08/2026, 9:26 AM1, then across both flow runs only one of those task runs can run at a time.
So this is not scoped to a single flow run.
### Meaning
If you do:
python
@task(tags=["write"])
def write_result(x):
...
and create:
bash
prefect concurrency-limit create write 1
then all task runs tagged write compete for that same single slot:
- flow run A: write_result(...)
- flow run B: write_result(...)
Only one can execute at once.
The other waits.
### So the scope is effectively
- per tag
- shared across the Prefect API / workspace
- not per flow run
### If you want per-flow-run isolation
Then you need a different tag per flow run, which is exactly why you were asking about tagging with the flow run name.
For example:
python
from prefect import flow, task, tags
from prefect.runtime import flow_run
@task
def write_result(x):
...
@flow
def my_flow(items):
with tags(flow_run.name):
write_result.map(items)
But note the implication:
- if every flow run gets a unique tag
- and you set concurrency limit 1 on that unique tag
then serialization happens within that flow run only, not across all flow runs.
### So:
- tag = "write" with limit 1
-> global serialization across all flow runs using that tag
- tag = flow_run.name with limit 1
-> serialization only within that specific flow run, assuming the limit exists for that tag
### Important practical caveat
Prefect tag concurrency limits are for known tags. If you generate a brand-new tag per run, you’d need a corresponding concurrency limit for that tag, which is usually not practical unless you manage them programmatically.
That’s why most people use:
- one shared tag for global serialization, or
- another concurrency mechanism if they need per-run dynamic isolation
### Direct answer
No, task tag concurrency limits are not flow-scoped.
If two different flow runs use the same tag and its limit is 1, they will block each other.
Docs:
- Tasks
- Run work concurrently
If you want, I can help you choose between:
1. global single writer across all flow runs, or
2. one writer per flow run.oleander-whirlwind
06/08/2026, 9:29 AMMarvin
06/08/2026, 9:29 AMMarvin
06/08/2026, 9:29 AMfetch should not get the per-flow tag/concurrency control
- only write should get it
### But there’s an important limitation
Prefect’s built-in tag concurrency limits are global per tag, not automatically “per flow run”.
So if you want:
- flow run A: one write at a time
- flow run B: one write at a time
- A and B should not block each other
then a shared tag like "write" with limit 1 is wrong, because that serializes writes across all flow runs.
### What you actually need
You need a dynamic tag on the write task only, such as:
- write-<flow_run_id> or
- write-<flow_run_name>
Then all writes within one flow run share the same tag, but writes from different flow runs have different tags.
Conceptually:
python
fetch(item) # no special tag
write(result) # tag = f"write-{current_flow_run_id}"
### However: another practical limitation
For Prefect tag-based concurrency limits to work, the limit has to exist for that tag.
So if you generate a unique write tag per flow run, you’d also need a concurrency limit of 1 created for each of those dynamic tags.
That makes dynamic per-flow tag limits awkward unless you create/manage them yourself.
### Therefore, for per-flow-run-only write serialization, the most reliable option is often not tag concurrency
Instead, do the serialization in the flow logic itself, and only for writes.
For example:
python
from prefect import flow, task
@task
def fetch(item):
return f"data-{item}"
@task
def write(data):
print(f"writing {data}")
@flow
def my_flow(items):
fetch_futures = [fetch.submit(item) for item in items]
for future in fetch_futures:
data = future.result()
write.submit(data).result()
That gives you:
- fetches can all run concurrently
- writes happen one-at-a-time within this flow run
- another flow run can do the same independently
### But note
This still allows all fetches to finish early and wait in memory/results, which earlier you said may be a problem.
So if you need both:
- fetch parallelism
- write serial per flow run
- avoid large stale backlog
then you want a bounded number of item pipelines active per flow run
Example:
python
from prefect import flow, task
from prefect.futures import wait
@task
def fetch(item):
return f"data-{item}"
@task
def write(data):
print(f"writing {data}")
@flow
def my_flow(items, window=5):
for i in range(0, len(items), window):
chunk = items[i:i+window]
futures = [fetch.submit(item) for item in chunk]
for f in futures:
write.submit(f.result()).result()
### Key correction
You are correct that only write should be constrained.
The fetch task should remain untagged / unconstrained unless you separately want a fetch limit.
### Bottom line
- No, you should not tag both fetch and write if only write needs serialization.
- Yes, the write-only limit should apply only to write.
- But Prefect tag concurrency is global per tag, not automatically per flow run.
- So per-flow-run serialization of only write is best implemented either:
- by your flow structure, or
- with dynamically managed per-run tags/limits on write only.
If you want, I can show the cleanest nested-task pattern for exactly this:
- fetch concurrent
- write serial
- serialization only within each flow run
- no cross-flow blocking.oleander-whirlwind
06/08/2026, 12:04 PMMarvin
06/08/2026, 12:04 PMMarvin
06/08/2026, 12:08 PMtext
Submitting task process_interval to thread pool executor...
and then it never actually starts, the usual cause is:
- a parent task is already running in the thread pool
- it submits a nested child task
- then waits on that child
- but all worker threads are already occupied by similar parent tasks
- so the child is queued and can never get a worker
So even if the Prefect concurrency slot is free, the subtask may still not run because the task runner thread pool has no free worker.
### Typical deadlock shape
python
@task
def child(x):
return x * 2
@task
def parent(x):
fut = child.submit(x)
return fut.result() # blocks parent thread
@flow(task_runner=ThreadPoolTaskRunner(max_workers=2))
def my_flow():
futures = [parent.submit(i) for i in range(2)]
return [f.result() for f in futures]
What happens:
- both parent tasks occupy the two worker threads
- each parent submits a child
- each parent waits on child.result()
- but no worker thread is left to run either child
- everything stalls
### Why this is different from tag concurrency
If it were a tag concurrency limit issue, you’d expect the task to be waiting on a lease/slot.
But your log:
text
Submitting task ... to thread pool executor
means the task was already handed to the executor layer.
That points more to executor starvation/deadlock than Prefect tag limits.
### Most likely causes
1. Bounded ThreadPoolTaskRunner(max_workers=...)
2. Nested .submit() inside tasks
3. Calling .result() inside the parent task
4. Multiple parents doing this at once
### How to fix it
#### Option 1: increase max_workers
Quickest test:
python
from prefect import flow
from prefect.task_runners import ThreadPoolTaskRunner
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def my_flow():
...
If the problem disappears, it was almost certainly pool starvation/deadlock.
#### Option 2: avoid waiting inside the parent task
Instead of:
python
@task
def parent(x):
fut = child.submit(x)
return fut.result()
restructure so the flow waits, not the task.
#### Option 3: use direct nested call if you actually want synchronous execution
If you really mean “run child inline as part of parent”, then:
python
@task
def parent(x):
return child(x)
That avoids executor submission for the child, though it changes behavior.
#### Option 4: move orchestration to the flow level
This is generally the safest pattern in Prefect.
### For your case
Since you do want nested behavior, the key question is:
Are you doing something like this inside the task?
python
subtask.submit(...).result()
If yes, that is the prime suspect.
### Very short diagnosis
- empty concurrency slot does not mean the task can start
- the task may still be stuck waiting for a thread pool worker
- nested submit + .result() inside tasks is the classic cause
If you paste your @flow, @task, and task runner config, I can tell you exactly whether it’s deadlocking and show the minimal change to fix it.oleander-whirlwind
06/08/2026, 12:10 PMMarvin
06/08/2026, 12:10 PMMarvin
06/08/2026, 12:10 PM@task(concurrency_limit=...) parameter.
In Prefect 3.x, per-task concurrency is usually done by giving that task a unique tag and applying a concurrency limit to that tag.
### Example
python
from prefect import task
@task(tags=["my-write-task"])
def write_result(x):
...
Then create a limit for that tag:
bash
prefect concurrency-limit create my-write-task 1
That makes write_result run with concurrency 1 as long as no other tasks use that same tag.
### Important nuance
This is really:
- concurrency limit per tag
- not a first-class “per task object” setting
So if two different tasks share the same tag, they share the same limit.
If you want it to behave like per task, use a tag unique to that task.
### If you need different limits for different tasks
python
@task(tags=["fetch-task"])
def fetch(...):
...
@task(tags=["write-task"])
def write(...):
...
Then:
bash
prefect concurrency-limit create fetch-task 10
prefect concurrency-limit create write-task 1
### Verified CLI
bash
prefect concurrency-limit --help
Docs:
- Tasks
- Run work concurrently
If you want, I can also show how to set a limit that applies to only one specific task but not its subtasks or sibling tasks.