oleander-whirlwind
06/15/2026, 6:29 AMMarvin
06/15/2026, 6:29 AMMarvin
06/15/2026, 6:31 AMdel and gc.collect() only remove Python references they can actually reach. In Prefect, task memory often stays high because the process still holds references through task state, futures, exceptions/tracebacks, thread pools, logging queues, or native libraries. Also, even when Python objects are freed, CPython may not return that memory to the OS immediately.
A few common reasons in Prefect 3.x:
1. The task result is still being retained
- Prefect stores task outcomes in State objects.
- If you return a large object, Prefect may still hold it via the task state and future.
- If your flow does:
fut = my_task.submit()
result = fut.result()
then both fut and result can keep memory alive until they go out of scope.
2. Exceptions keep locals alive
- If a task errors, the exception traceback can retain references to local variables.
- This is a classic Python behavior: traceback -> frame -> locals -> large objects.
- Prefect also tracks failures in task state, so failed tasks can hold onto more memory than successful ones.
3. Thread-based execution doesn’t isolate memory
- With the default thread-based execution, tasks run in the same Python process.
- If memory is fragmented or held by allocators, the process RSS may not drop even after objects are freed.
- So “memory not released” may mean “Python can reuse it later,” not “the objects still exist.”
4. Native libraries may keep allocations
- Pandas, NumPy, PyArrow, Torch, etc. often allocate memory outside normal Python object management.
- gc.collect() only affects Python-tracked objects, not necessarily memory held by native allocators.
5. Logging / queued internal objects
- Prefect keeps task metadata, states, and logs around long enough to report orchestration status.
- That can delay reclamation.
What usually helps most
Use smaller task outputs
- Don’t return huge dataframes/arrays if you can persist them externally and return a path, key, or summary instead.
Drop futures/results promptly
@flow
def f():
fut = heavy_task.submit()
x = fut.result()
# use x
del x
del fut
import gc
gc.collect()
Prefer process isolation for memory-heavy work
- If a task is truly memory-intensive and you need memory to be actually reclaimable, run it in a separate process/container.
- When that process exits, the OS reclaims the memory reliably.
- In practice this often means:
- using infrastructure/workers that execute flow runs in separate processes/containers
- or using process-based parallelism instead of threads for the heavy step
Persist large intermediates outside memory
- Write to object storage / parquet / database, and pass references between tasks.
Be careful with failed tasks
- Large objects created before an exception may stay alive through traceback retention.
- Catching exceptions earlier and reducing what’s in scope can help.
A useful mental model:
- del obj = remove one reference name
- gc.collect() = collect unreachable Python objects
- Neither guarantees:
- Prefect isn’t holding another reference
- native memory is released
- OS-visible RSS drops immediately
If you want, I can help you diagnose this in a *Prefect-specific pattern*—for example:
- task returns large dataframe
- many .submit() calls in a loop
- mapped tasks
- failures causing memory growth
- Dask/Ray/task runner setup
If you share a small flow/task example, I can point to the exact retention path.oleander-whirlwind
06/15/2026, 6:38 AMMarvin
06/15/2026, 6:38 AMMarvin
06/15/2026, 6:41 AM@task via a task-runner setting.
In Prefect 3.x, task runners are configured at the @flow level, not per task. So if your main flow uses ThreadPoolTaskRunner, then tasks submitted with .submit() in that flow use threads.
Best supported pattern: put that one task inside a subflow that uses ProcessPoolTaskRunner, while the parent flow keeps ThreadPoolTaskRunner.
Example:
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner, ProcessPoolTaskRunner
@task
def normal_task(x):
return x * 2
@task
def memory_heavy_task(x):
# runs in separate process when called from process subflow
big = [x] * 10_000_000
return sum(big)
@flow(task_runner=ProcessPoolTaskRunner(max_workers=1))
def isolated_process_subflow(x):
fut = memory_heavy_task.submit(x)
return fut.result()
@flow(task_runner=ThreadPoolTaskRunner(max_workers=8))
def main_flow():
a = normal_task.submit(1)
b = normal_task.submit(2)
isolated = isolated_process_subflow(5)
return {
"a": a.result(),
"b": b.result(),
"isolated": isolated,
}
What this gives you:
- main_flow tasks run with threads
- memory_heavy_task runs in a separate process
- that process-based runner is scoped to the subflow
Important caveat:
- the subflow call is synchronous from the parent’s perspective unless you structure around it
- so this is good for isolation, but not a per-task runner override inside the same submitted task set
Alternative options
1. Launch a subprocess manually inside one task
If you need true one-off OS-level isolation for exactly one operation, you can do:
from prefect import task
import subprocess
import sys
@task
def run_heavy_thing():
result = subprocess.run(
[sys.executable, "heavy_script.py"],
capture_output=True,
text=True,
check=True,
)
return result.stdout
This is often the strongest memory-isolation option because when the subprocess exits, its RAM is fully reclaimed by the OS.
2. Use a separate deployed flow / worker
If the isolated step is big enough, turn it into its own flow/deployment and call it separately. That gives stronger isolation boundaries, but it’s more operational overhead.
What you cannot do
There is no supported pattern like:
@task(task_runner=ProcessPoolTaskRunner())
def one_task():
...
That parameter does not exist on @task.
Also relevant: the @task signature does not include a task_runner argument, which confirms this.
If you want, I can show you:
- a version where the threaded parent flow continues doing work while the isolated subflow runs
- or a subprocess wrapper task pattern specifically for memory-hungry pandas/numpy code.oleander-whirlwind
06/15/2026, 6:48 AMMarvin
06/15/2026, 6:48 AMMarvin
06/15/2026, 6:50 AMProcessPoolTaskRunner are:
---
1. Do not keep large task results in Prefect memory
Use:
@task(
persist_result=False,
cache_result_in_memory=False,
)
def heavy_task(...):
...
Or, if you do need persistence:
@task(
persist_result=True,
cache_result_in_memory=False,
)
def heavy_task(...):
...
Why this helps:
- persist_result=False means Prefect does not persist the return value as a result
- cache_result_in_memory=False means Prefect avoids retaining the result in its in-memory result cache
This is probably the most important Prefect-level knob.
---
2. Return a small handle, not the large object
Instead of returning a dataframe / array / model, write it somewhere and return a path/key:
from prefect import task
@task(cache_result_in_memory=False)
def build_big_df():
df = ...
path = "/tmp/output.parquet"
df.to_parquet(path)
del df
return {"path": path}
Then downstream tasks load from that path only if needed.
This is the most common pattern for thread-based flows.
---
3. Consume futures/results quickly, then delete them
If you keep a list of many futures or results, you keep memory alive.
Bad:
futures = [heavy_task.submit(x) for x in items]
results = [f.result() for f in futures]
Better:
for x in items:
fut = heavy_task.submit(x)
result = fut.result()
# use result
del result
del fut
Or process in small batches:
batch_size = 20
for i in range(0, len(items), batch_size):
batch = items[i:i+batch_size]
futures = [heavy_task.submit(x) for x in batch]
for fut in futures:
result = fut.result()
# use result
del result
del futures
---
4. Delete large locals before raising exceptions
This is a big one for memory leaks that look mysterious.
If a task fails, Python tracebacks can retain all locals in the frame.
Bad:
@task
def heavy_task():
big = make_huge_object()
raise ValueError("boom")
Better:
@task
def heavy_task():
big = make_huge_object()
try:
do_work(big)
except Exception:
del big
raise
Also useful:
@task
def heavy_task():
big = None
try:
big = make_huge_object()
return do_work(big)
finally:
if big is not None:
del big
That won’t solve all cases, but it helps a lot when failures are involved.
---
5. Keep object scope as small as possible
Instead of one huge task that loads, transforms, aggregates, and returns a giant object, split it so the large object dies earlier.
Example:
@task(cache_result_in_memory=False)
def extract_to_file():
...
return {"path": "/tmp/raw.parquet"}
@task(cache_result_in_memory=False)
def transform_file(meta):
...
return {"path": "/tmp/transformed.parquet"}
@task
def summarize(meta):
...
return {"row_count": 123}
This reduces the lifetime of large in-memory objects.
---
6. Prefer external storage for intermediates
Common pattern:
- parquet on disk
- object storage
- temp tables
- warehouse staging tables
Then Prefect coordinates small references, not huge Python objects.
---
7. Be aware that gc.collect() does not mean RSS goes down
Even if all references are gone:
- CPython may keep arenas allocated
- NumPy/pandas/pyarrow/native allocators may keep memory
- thread-based execution keeps everything in one long-lived processMarvin
06/15/2026, 6:50 AMfrom prefect import flow, task
import gc
@task(persist_result=False, cache_result_in_memory=False)
def heavy_task(x):
big = [x] * 10_000_000
total = sum(big)
del big
gc.collect()
return total
@flow
def main(items):
outputs = []
for x in items:
fut = heavy_task.submit(x)
result = fut.result()
outputs.append(result)
del result
del fut
gc.collect()
return outputs
And if the returned value is also large, replace it with:
- a file path
- object storage key
- DB table name
- compact metadata
---
Most effective knobs to try first
1. cache_result_in_memory=False
2. persist_result=False for intermediates
3. return references, not large objects
4. avoid accumulating many futures/results
5. delete large locals before exceptions escape
If you want, I can give you a concrete Prefect 3 memory-safe task pattern for:
- pandas dataframe tasks
- numpy arrays
- LLM/model inference outputs
- batch loop flows