Ben Griffiths
05/06/2026, 3:53 PMMarvin
05/06/2026, 3:53 PMMarvin
05/06/2026, 3:53 PMBen Griffiths
05/06/2026, 3:55 PMMarvin
05/06/2026, 3:55 PMMarvin
05/06/2026, 3:57 PMThreadPoolTaskRunner that does that.
from prefect import flow, task
from prefect.futures import as_completed
from prefect.task_runners import ThreadPoolTaskRunner
@task
def process_item(item: int) -> int:
# Simulate work
return item * 2
@task
def write_batch(results: list[int]) -> None:
# Replace with DB/file/object-store write
# Important idea: flush results out of memory regularly
print(f"writing {len(results)} results")
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def process_items(
items: list[int],
submit_batch_size: int = 200,
flush_batch_size: int = 50,
) -> None:
for i in range(0, len(items), submit_batch_size):
current_batch = items[i : i + submit_batch_size]
# Only submit a bounded batch of tasks
futures = [process_item.submit(item) for item in current_batch]
# Consume results as they complete instead of waiting for all
buffer = []
for future in as_completed(futures):
result = future.result()
buffer.append(result)
# Periodically flush downstream and clear memory
if len(buffer) >= flush_batch_size:
write_batch(buffer)
buffer.clear()
# Flush any leftovers from this submit batch
if buffer:
write_batch(buffer)
# Drop references so Python can reclaim memory
del futures
del buffer
Why this structure helps:
* submit_batch_size limits the total number of in-memory futures.
* as_completed(futures) lets you process results incrementally instead of storing all results until the end.
* write_batch(...) pushes data out of memory regularly.
* del futures and buffer.clear() remove references quickly, which helps GC.
A common anti-pattern is this:
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def bad_pattern(items: list[int]):
futures = [process_item.submit(item) for item in items]
results = [f.result() for f in futures]
return results
That tends to creep upward because:
* you retain all futures until the end
* then you retain all results too
* for thousands of tasks, that can become a lot of memory pressure
A few extra suggestions that usually help:
Keep task return values small
If each task returns a large dataframe/object/blob, memory creep will be worse. Prefer returning small metadata and writing large outputs to external storage inside the task.
Use batching at the flow level
Even if your thread pool is only 20 workers, submitting 100k futures in one loop still means the flow is holding references to a huge number of futures.
Avoid accumulating logs/data structures in the flow
Sometimes the growth is not Prefect itself, but your own growing lists/dicts/caches.
If you want an even more isolated pattern for very long runs, use a subflow per batch so each batch gets its own runner lifecycle:
```
from prefect import flow, task
from prefect.futures import as_completed
from prefect.task_runners import ThreadPoolTaskRunner
@task
def process_item(item: int) -> int:
return item * 2
@task
def write_batch(results: list[int]) -> None:
print(f"writing {len(results)} results")
@flow(task_runner=ThreadPoolTaskRunner(max_workers=20))
def process_batch(items: list[int], flush_batch_size: int = 50) -> None:
futures = [process_item.submit(item) for item in items]
buffer = []
for future in as_completed(futures):
buffer.append(future.result())
if len(buffer) >= flush_batch_size:
write_batch(buffer)
buffer.clear()
if buffer:
write_batch(buffer)
@flowMarvin
05/06/2026, 3:57 PMas_completed → flush externally → clear references
* Worst for memory: submit everything → wait at the end → collect all results in one list
Relevant docs:
* Task runners
* Run work concurrently
* Flows
If you want, I can also give you:
1. a version that writes large task outputs to result storage, or
2. a version instrumented with `psutil`/`tracemalloc` so you can watch memory per batch.Ben Griffiths
05/06/2026, 4:06 PMMarvin
05/06/2026, 4:06 PMMarvin
05/06/2026, 4:06 PMBen Griffiths
05/06/2026, 4:12 PMfuture_to_id: dict = {}
for id in files: # list of thousands of file ID's
future = process_file.with_options(
task_run_name=f"file-id-{id}"
).submit(
file_id=id,
)
future_to_id[future] = id
# collect results as they complete
results: list[dict] = []
for f in as_completed(list(future_to_id)):
activity_id = future_to_id.pop(f)
try:
f.result()
result = {
"activity_id": activity_id,
"status": "Success",
"error": None,
}
except Exception as e:
result = {
"activity_id": activity_id,
"status": "Failed",
"error": str(e),
}
results.append(result)Marvin
05/06/2026, 4:12 PMMarvin
05/06/2026, 4:12 PMBen Griffiths
05/06/2026, 4:15 PMfuture_to_id: dict = {}
for id in files: # list of thousands of file ID's
future = process_file.with_options(
task_run_name=f"file-id-{id}"
).submit(
file_id=id,
)
future_to_id[future] = id
# collect results as they complete
results: list[dict] = []
for f in as_completed(list(future_to_id)):
activity_id = future_to_id.pop(f)
try:
f.result()
result = {
"activity_id": activity_id,
"status": "Success",
"error": None,
}
except Exception as e:
result = {
"activity_id": activity_id,
"status": "Failed",
"error": str(e),
}
results.append(result)Marvin
05/06/2026, 4:15 PMMarvin
05/06/2026, 4:17 PMcache_result=False is probably not the setting you want in Prefect 3.x
* the biggest issue in your example is that you submit all thousands of futures up front and keep references to them until they finish
* if failures involve DataFrames, the exception traceback can retain references to large local objects for longer
A few important points.
1. Failures can retain traceback frames
When a task fails, the exception object can keep its traceback, and traceback frames can reference local variables inside the task. If a local variable is a large dataframe, that can make failed tasks “heavier” in memory than successful ones.
So this pattern:
@task
def process_file(file_id):
df = load_big_dataframe(file_id)
...
raise ValueError("bad file")
can use noticeably more memory on failure than on success, because the exception/traceback may still reference df.
A very practical mitigation is to avoid raising exceptions while large objects are still in scope.
For example:
@task
def process_file(file_id):
df = load_big_dataframe(file_id)
try:
result = transform_and_validate(df)
except Exception as exc:
# explicitly drop big locals before re-raising
del df
raise exc
del df
return result
Even better, structure code so failure happens after you’ve reduced the dataframe to something small.
2. cache_result=False is not the main knob here
In Prefect 3, the setting that matters for in-memory retention is cache_result_in_memory=False.
cache_result=False is not the same thing as “don’t keep results in RAM”. Also, caching and result persistence are related but different concepts.
The task options worth considering are:
@task(
cache_result_in_memory=False,
persist_result=False,
)
def process_file(...):
...
What these do:
* `cache_result_in_memory=False`:
helps avoid Prefect keeping successful results around in its in-memory result cache
* `persist_result=False`:
avoids persisting the task result to result storage
But neither of these fully solves memory held by failed exceptions / traceback state while futures still exist.
So: yes, cache_result_in_memory=False can help for successful dataframe-returning tasks, but your faster growth during failures is likely more about exception retention than result caching.
3. Your current flow retains too much at once
This is the main problem in your snippet:
future_to_id: dict = {}
for id in files:
future = process_file.with_options(
task_run_name=f"file-id-{id}"
).submit(file_id=id)
future_to_id[future] = id
Even though your thread pool only runs max_workers concurrently, you still create and retain thousands of futures. Each completed future can retain final state, and failed ones may retain exception data/tracebacks.
Then later:
results: list[dict] = []
for f in as_completed(list(future_to_id)):
...
results.append(result)
This means:
* you hold all futures in future_to_id
* you make another list copy with list(future_to_id)
* you accumulate all summary records in results
The results list is probably small enough not to matter much, but the all-at-once future submission definitely does.
4. Best fix: bounded submission window
Instead of submitting all files immediately, keep only a limited number of in-flight futures at a time.
Here’s a better structure:
```
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
from prefect.futures import wait
@task(
cache_result_in_memory=False,
persist_result=False,
)
def process_file(file_id: str) -> None:
df = None
try:
df = load_dataframe(file_id)
transformed = transform_dataframe(df)
write_output(file_id, transformed)Marvin
05/06/2026, 4:17 PMThis helps because:
* only `submit_batch_size` futures exist at once
* failed futures are not retained for the whole run
* result summaries are flushed periodically
* large dataframe objects should die sooner if the task code drops references
If you want true completion-order processing, use `as_completed`, but still only per bounded batch:
from prefect import flow, task
from prefect.futures import as_completed
from prefect.task_runners import ThreadPoolTaskRunner
@task(
cache_result_in_memory=False,
persist_result=False,
)
def process_file(file_id: str) -> None:
df = None
transformed = None
try:
df = load_dataframe(file_id)
transformed = transform_dataframe(df)
write_output(file_id, transformed)
finally:
del transformed
del df
@task
def write_results_batch(batch: list[dict]) -> None:
print(f"writing {len(batch)} results")
@flow(task_runner=ThreadPoolTaskRunner(max_workers=16))
def run_files(files: list[str], submit_batch_size: int = 100, result_batch_size: int = 50):
results_buffer: list[dict] = []
for i in range(0, len(files), submit_batch_size):
batch = files[i:i + submit_batch_size]
future_to_id = {}
for file_id in batch:
future = process_file.with_options(
task_run_name=f"file-id-{file_id}"
).submit(file_id=file_id)
future_to_id[future] = file_id
for f in as_completed(list(future_to_id)):
file_id = future_to_id.pop(f)
try:
f.result()
results_buffer.append(
{
"activity_id": file_id,
"status": "Success",
"error": None,
}
)
except Exception as e:
results_buffer.append(
{
"activity_id": file_id,
"status": "Failed",
"error": str(e),
}
)
finally:
del f
if len(results_buffer) >= result_batch_size:
write_results_batch(results_buffer)Marvin
05/06/2026, 4:17 PM*5. If tasks return dataframes, consider not returning them at all*
If possible, this is the biggest improvement:
* task loads dataframe
* task transforms dataframe
* task writes dataframe/output somewhere external
* task returns only small metadata
For example:
@task(cache_result_in_memory=False, persist_result=False)
def process_file(file_id: str) -> dict:
df = load_dataframe(file_id)
out_path = write_transformed_dataframe(transform_dataframe(df), file_id)
del df
return {"file_id": file_id, "out_path": out_path}
Better than:
@task
def process_file(file_id: str) -> pd.DataFrame:
df = load_dataframe(file_id)
return transform_dataframe(df)
Returning dataframes makes both success and failure paths heavier.
*6. A subtle improvement for failure-heavy tasks*
If you only need failure info in the parent flow, consider catching exceptions *inside the task* and returning a small status object instead of letting the task fail with a traceback.
Example:
@task(cache_result_in_memory=False, persist_result=False)
def process_file(file_id: str) -> dict:
df = None
transformed = None
try:
df = load_dataframe(file_id)
transformed = transform_dataframe(df)
write_output(file_id, transformed)
return {
"activity_id": file_id,
"status": "Success",
"error": None,
}
except Exception as e:
return {
"activity_id": file_id,
"status": "Failed",
"error": str(e),
}
finally:
del transformed
del df
Then the flow becomes:
@flow(task_runner=ThreadPoolTaskRunner(max_workers=16))
def run_files(files: list[str], submit_batch_size: int = 100):
for i in range(0, len(files), submit_batch_size):
batch = files[i:i + submit_batch_size]
futures = [process_file.submit(file_id=file_id) for file_id in batch]
batch_results = []
for f in as_completed(futures):
batch_results.append(f.result())
write_results_batch(batch_results)
Why this can help:
* successful and failed tasks both return small dicts
* you avoid retaining Python exception tracebacks in failed future states
* memory is often much more stable
Tradeoff:
* tasks won’t be marked failed in Prefect UI unless you explicitly choose to raise
* you’d be treating failure as data, not orchestration failure
That may or may not fit your use case.
*My recommendation for your case*
I would try these in order:
1. Change task decorators to use:
@task(cache_result_in_memory=False, persist_result=False)
```
2. Stop submitting all thousands at once; use bounded batches.
3. Avoid returning dataframes from tasks if possible.
4. In task code, explicitly drop dataframe references in finally.
5. If many failures are expected and you mostly need reporting, catch exceptions inside the task and return a small status dict instead of failing the task.
Useful docs:
* Results and result persistence
* Run work concurrently
* Task runners
If you want, I can rewrite your exact snippet into:
* a bounded in-flight futures version, or
* a failure-as-data version optimized for dataframe-heavy tasks.Ben Griffiths
05/06/2026, 5:55 PMMarvin
05/06/2026, 5:55 PMMarvin
05/06/2026, 5:55 PMBen Griffiths
05/06/2026, 5:55 PMMarvin
05/06/2026, 5:55 PMMarvin
05/06/2026, 5:57 PMProcessPoolTaskRunner, but the answer to “will memory be released after each task completes?” is:
not necessarily.
With a process pool, worker processes are typically reused across many tasks for the lifetime of the runner. So memory used inside a worker may stay associated with that worker process until:
* the worker process exits, or
* Python in that process actually returns memory to the OS, which is not guaranteed
So ProcessPoolTaskRunner can help in some cases, but it is not the same as “fresh process per task”.
What it can help with
----------------------
If your memory issue is caused by:
* large temporary objects living inside task execution
* pandas / numpy allocations inside workers
* GIL-related thread contention for CPU-heavy dataframe transforms
then process-based execution can sometimes behave better than threads, because each worker has its own memory space.
But there are important tradeoffs.
Big caveat for dataframe-heavy tasks
------------------------------------
If you use ProcessPoolTaskRunner, task inputs and outputs need to be serialized between processes.
That means if your tasks:
* take a dataframe as input, or
* return a dataframe as output
you may actually make memory behavior worse, because large objects are copied/serialized across process boundaries.
So this is usually a bad fit:
@task
def process_df(df):
return transform(df)
with a process pool, because you are shipping whole dataframes between processes.
A much better fit is:
@task
def process_file(file_id):
df = load_dataframe(file_id)
transformed = transform(df)
write_output(file_id, transformed)
return {"file_id": file_id, "status": "Success"}
That way the worker:
* loads the dataframe locally
* processes it locally
* writes it externally
* returns only a small dict
That’s the pattern where ProcessPoolTaskRunner is most likely to help.
Will memory be freed after tasks complete?
------------------------------------------
Usually:
* memory for Python objects that become unreachable can be reclaimed inside the worker process
* but the worker process itself remains alive
* and many allocators do not eagerly return freed memory to the OS
So you may see:
* task-local memory usage goes down somewhat
* but RSS of the worker process stays high
* total process-pool memory plateaus instead of dropping fully
That is normal for process pools.
So if your question is:
“Will each task fully release its memory back to the OS when done?”
the realistic answer is:
generally no, not reliably.
When process pools help most
----------------------------
ProcessPoolTaskRunner is most promising when:
* tasks are CPU-bound
* tasks load data from files/object storage themselves
* tasks return only small summaries/status objects
* task failures are caused by computation/validation, not by carrying giant dataframes back out
* you want isolation from the main flow process
When process pools help least
-----------------------------
They are less likely to help if:
* you pass large dataframes into tasks
* you return large dataframes from tasks
* failures hold big exceptions/tracebacks referencing local dataframe objects
* the worker processes stay alive for a very long time and keep high-water-mark memory
Recommended pattern if you try it
---------------------------------
If you want to experiment, I’d structure it like this:
```
from prefect import flow, task
from prefect.task_runners import ProcessPoolTaskRunner
from prefect.futures import as_completed
@task(
cache_result_in_memory=False,
persist_result=False,
)
def process_file(file_id: str) -> dict:
df = None
transformed = None
try:
df = load_dataframe(file_id)
transformed = transform_dataframe(df)
write_output(file_id, transformed)
return {
"activity_id": file_id,
"status": "Success",Marvin
05/06/2026, 5:57 PMProcessPoolTaskRunner if tasks are CPU-heavy
3. Even better for hard memory resets: break into shorter subflows / separate flow runs so the process pool is recreated periodically
4. Avoid: passing or returning large dataframes between flow and task boundaries
So yes, it is worth trying ProcessPoolTaskRunner, but only if you also change the task contract so that dataframes stay inside the task process.
Docs:
* Task runners
* Run work concurrently
* Results and result persistence
If you want, I can rewrite your current process_file pattern into:
* a ProcessPoolTaskRunner version optimized for dataframe workloads, or
* a subflow-per-batch pattern that forces more reliable memory release.Nate
05/06/2026, 6:27 PMcache_result_in_memory=False can help when successful tasks return large objects, but it will not fully address memory growth from failed tasks.
for failures, one important factor is traceback retention. when a task raises, the exception keeps its traceback, and traceback frames can keep local variables alive. If a dataframe is still in local scope when the task raises, the failed task state/future can retain that dataframe longer than expected
looking at what you have, i'd try 3 things
1. submit work in bounded groups instead of creating thousands of futures at once.
2. avoid returning dataframes from tasks; write large outputs inside the task and return small metadata.
3. if a task may fail after creating a dataframe, either handle the failure inside the task and return a small failure record, or clear large locals before re-raising.
example failure-as-data pattern:
@task(cache_result_in_memory=False, persist_result=False)
def process_file(file_id: str) -> dict:
df = None
try:
df = load_dataframe(file_id)
output_path = write_processed_dataframe(file_id, df)
return {"activity_id": file_id, "status": "Success", "output": output_path, "error": None}
except Exception as exc:
return {"activity_id": file_id, "status": "Failed", "output": None, "error": str(exc)}
finally:
del df
if you need the task marked failed in the API, re-raise after releasing large locals:
except Exception:
del df
raise
then collect results in bounded batches rather than keeping all futures/results around for the whole flow runBen Griffiths
05/07/2026, 10:30 PMMarvin
05/07/2026, 10:30 PMMarvin
05/07/2026, 10:31 PMProcessPoolTaskRunner uses a pool of long-lived worker processes.
That means:
* a task runs in a worker process
* when the task finishes, Python objects may be freed in that process
* but the worker process stays alive for future tasks
* memory often stays reserved by that process allocator, even if objects are gone
So it can help if:
* the problem is specific to thread-shared memory behavior
* your task loads/processes/writes data entirely inside the worker
* the task returns only small metadata
But it usually does not mean:
* “after each file task, all memory goes back to the OS”
It is more like:
* “memory is isolated per worker, and may stabilize better than threads in some workloads”
DaskTaskRunner
----------------
DaskTaskRunner also uses long-lived workers. So the same core limitation applies:
* task finishes
* worker stays alive
* memory may or may not be returned to the OS
* workers can accumulate memory fragmentation / high-water marks over time
Dask can sometimes behave better for memory management than a simple local process pool because it has:
* a separate scheduler/worker model
* worker-level memory management policies
* the ability to spill data to disk in some configurations
* better observability into worker memory
But for your specific question — “reclaim memory from each file processing task?” — the answer is still:
not reliably per task unless workers themselves are restarted or their memory policy forces cleanup.
So if your mental model is:
task finishes => worker fully clears memory
that is usually not true for either ProcessPool or Dask.
When they do help
-------------------
They can help if your current issue is mostly due to:
* large temporary pandas/numpy allocations inside threads
* failures retaining heavy traceback/object graphs in the main process
* CPU-bound transforms that are better isolated in separate processes
* not wanting one process heap to absorb all task allocations
They help most when each task:
* reads the file itself
* processes the dataframe internally
* writes output externally
* returns only a small dict/status
Example of the right shape:
@task
def process_file(file_id: str) -> dict:
df = load_dataframe(file_id)
out = transform_dataframe(df)
write_output(file_id, out)
return {"file_id": file_id, "status": "ok"}
Example of the wrong shape for process-based runners:
@task
def process_file(df):
return transform_dataframe(df)
because now you are shipping large dataframes between processes/workers.
What actually helps memory reclamation most
-------------------------------------------
If your goal is real reclamation after each file or each small group of files, the best options are usually:
* a fresh process for each unit of work
* or restarting workers periodically
* or splitting work into smaller flow runs / subflows that end completely
In practice:
* best reclamation: separate process lifecycle per chunk
* medium: process pool / dask worker pool
* least: thread pool in one long-lived process
So between your two options:
* ProcessPoolTaskRunner may help some
* DaskTaskRunner may help some, and gives more scaling/memory tooling
* but neither is a guaranteed fix for monotonic memory growth
My practical recommendation
---------------------------
If you want to test runner choice specifically:
Try ProcessPoolTaskRunner first if:
* you are on one machine
* tasks are CPU-heavy / pandas-heavyMarvin
05/07/2026, 10:31 PMDaskTaskRunner if:
* you already use Dask
* you want worker-level memory controls / scaling
* you want to isolate work across a more robust worker system
But if your top priority is memory reset, I would prioritize architecture over runner:
1. keep dataframes inside the task
2. return only tiny results
3. catch expected failures inside the task and return failure metadata instead of raising
4. process files in smaller chunks with shorter-lived worker lifecycles
5. if needed, use subprocess/process-worker boundaries that actually terminate periodically
Rule of thumb
-------------
If you ask:
“Will this reclaim memory after each file task?”
Then:
* `ThreadPoolTaskRunner`: mostly no
* `ProcessPoolTaskRunner`: sometimes partially, not reliably
* `DaskTaskRunner`: sometimes partially, with more controls, not reliably
* fresh worker/process per chunk: yes, most reliably
If you want, I can give you:
* a decision table for ThreadPool vs ProcessPool vs Dask for pandas workloads, or
* a concrete Prefect pattern that forces worker/process turnover every N files for better memory reclamation.