Nicholas Pini
07/21/2026, 12:22 PMresource:"POST /api/work_pools/*/get_scheduled_flow_runs" , despite having one single deployment which has a scheduled run every 10 minutes (all other flows are run on demand, not automatically scheduled). What is happening?Marvin
07/21/2026, 12:22 PMMarvin
07/21/2026, 12:24 PMPOST /api/work_pools/*/get_scheduled_flow_runs is the endpoint workers hit to ask “is there anything for me to run?”
- It is called repeatedly, typically every ~30 seconds per worker.
- The cost is not driven mainly by your one 10-minute schedule.
- The cost is often driven by:
- total size of the flow_run table
- number of work queues in the pool
- concurrency accounting queries
- locking / polling behavior
- DB latency on RDS
- Valkey is not really helping this endpoint; this is primarily a database query path.
So even if only one deployment is scheduled every 10 minutes, the server may still spend a lot of time answering “nothing to do right now”.
What that endpoint is for
In Prefect 3.x, workers poll their work pool for scheduled work. That endpoint:
- checks the work pool / work queues
- looks for SCHEDULED flow runs that are ready
- applies queue/pool concurrency logic
- locks selected rows to avoid double-pickup
- returns runs to the worker
If you have multiple workers, they all do this repeatedly.
If you have, say, 10 workers, that can easily mean ~20 requests/minute to the same endpoint even when there is no real work.
Why it can be slow despite only one scheduled deployment
The important detail is: the query does not just say “give me the next run for this one deployment”. It operates at the work-pool / work-queue level and has to evaluate eligible scheduled runs plus concurrency state.
From the implementation in 3.6.x, the expensive parts are roughly:
1. Workers poll continuously
- default polling interval is around 30s
- each worker repeats the same query
- many requests can pile up on the API / DB
2. The DB query can depend on total historical flow-run volume
- if your flow_run table is large, checking for runnable scheduled work becomes slower
- this can happen even if most runs are on-demand
3. Concurrency accounting is part of the query path
- the endpoint checks running/pending counts for queue/pool limits
- that means reading more than just scheduled rows
4. Locking is involved
- it uses row-locking semantics to safely assign runs
- with multiple workers polling concurrently, lock overhead increases
5. Multiple work queues can make it worse
- if your work pool has many queues, the endpoint may evaluate each queue
- even if only one queue/deployment really matters
6. RDS latency amplifies the cost
- each poll is a real DB round trip
- repeated small-but-complex queries over networked Postgres add up
7. Valkey is not the main factor here
- this endpoint is not being accelerated by Redis/Valkey-style caching in the way you might hope
- so replacing Redis with Valkey is probably not the root cause here
Why this matches your symptom exactly
Your symptom is:
only one deployment is scheduled every 10 minutes, butThat strongly suggests the slowness is polling overhead, not schedule volume. In other words: - on-demand flows still create rows inis slowget_scheduled_flow_runs
flow_run
- those rows contribute to table size / query planning / concurrency scans
- workers keep polling even when no scheduled work is available
- the API spends time proving “nothing is ready”
Most likely root causes in your setup
In order of likelihood:
1. Large flow_run table in Postgres
- especially if you retain lots of historical runs
- on-demand runs still count here
2. Too many workers polling the same pool
- common in Kubernetes if multiple worker replicas are running
3. Many work queues in the pool
- even unused queues can matter
4. DB indexing / planner mismatch
- the endpoint benefits heavily from the right indexes and fresh planner stats
5. RDS resource pressureMarvin
07/21/2026, 12:24 PMflow_run?
- row count and table size matter a lot.
3. How many work queues exist in that work pool?
- not just active ones.
4. Run EXPLAIN ANALYZE on the underlying query path
- this is the fastest way to confirm whether you’re hitting scans / bad plans / lock waits.
5. Check RDS metrics
- CPU
- read IOPS
- DB connections
- lock waits
- slow queries
Practical mitigations
1. Reduce worker polling pressure
If you have more workers than you need, scale them down.
Also consider increasing the worker query interval if low-latency pickup is not critical. The setting to look at is PREFECT_WORKER_QUERY_SECONDS.
That reduces how often workers call this endpoint.
2. Clean up old flow runs
If you keep a lot of historical flow runs, this can materially affect performance. Archiving/pruning old rows can help a lot.
3. Review work queues
If the pool has lots of queues, remove unused ones or simplify the pool structure.
4. Inspect database indexes and query plans
This is probably the biggest technical lever. In particular, you want to verify whether Postgres is doing sequential scans or repeated scans over flow_run.
5. Check concurrency settings
If you have queue or pool concurrency limits configured, they may be forcing extra counting work every poll.
6. Look for lock contention
If many workers poll simultaneously, they may be contending on the same scheduling rows.
Important note on Valkey
Your use of Valkey instead of Redis is probably not the main explanation for this particular endpoint being slow. This path is mostly about Postgres work, not cache hits. So I would focus first on:
- worker count
- work queue count
- flow_run size
- query plans on RDS
If you want, I can help you narrow it down quickly.
If you can share:
- number of worker replicas
- number of work queues in the pool
- approximate flow_run row count
- whether pool/queue concurrency limits are set
- one slow query trace or EXPLAIN ANALYZE
…I can help you determine whether this is primarily:
- polling frequency
- queue explosion
- table bloat
- missing index / bad query plan
- lock contention
Relevant docs:
- Work pools
- Workers
- DeploymentsNicholas Pini
07/21/2026, 12:38 PM"POST /api/flow_runs/paginate" returned 500 and took 10+ seconds @MarvinMarvin
07/21/2026, 12:38 PMMarvin
07/21/2026, 12:40 PMPOST /api/flow_runs/paginate is typically used by the UI/API to list flow runs. If that is taking 10s and returning 500, the likely problem is one of these:
Most likely causes
1. Large flow_run table
- even on-demand runs grow this table
- listing/paginating flow runs gets slower as history grows
2. RDS is slow / overloaded
- CPU, IOPS, connection count, lock waits
3. DB connection pool exhaustion in the Prefect server
- this endpoint does both a read query and a count query
- in 3.6.12 they are performed concurrently
- under load, that can amplify connection pressure
4. Bad query plan / stale stats / missing useful indexes
- especially if Postgres is doing sequential scans on flow_run
5. A UI filter is making it worse
- some filters/sorts are much more expensive than others
Why this matters together with get_scheduled_flow_runs
Seeing both:
- POST /api/work_pools/*/get_scheduled_flow_runs slow
- POST /api/flow_runs/paginate slow/500
usually means the bottleneck is the database layer, not just scheduling.
So the emerging picture is:
- worker polling is continuously hitting Postgres
- flow-run listing is also hitting Postgres
- RDS or the server DB pool is struggling
What I’d check immediately
1. Size of flow_run
Run this on Postgres:
sql
SELECT count(*) FROM flow_run;
SELECT
pg_size_pretty(pg_total_relation_size('flow_run')) AS flow_run_total_size;
2. Active queries / waits
sql
SELECT pid, state, wait_event_type, wait_event, query_start, query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY query_start ASC;
Look for:
- many active Prefect queries
- lock waits
- long-running count(*) / paginate queries
3. Table/index health
sql
ANALYZE flow_run;
ANALYZE task_run;
ANALYZE deployment;
ANALYZE work_queue;
ANALYZE work_pool;
And check whether autovacuum is keeping up.
4. Server logs around the 500
The 500 is important because it may tell us whether this is:
- SQL timeout
- connection pool timeout
- serialization failure
- DB error
If you can grab the traceback from the Prefect server pod logs, that would narrow this down quickly.
5. RDS metrics
In CloudWatch, check around the same timestamp:
- CPUUtilization
- DatabaseConnections
- ReadIOPS / WriteIOPS
- FreeableMemory
- DiskQueueDepth
Likely practical fixes
Reduce polling load
- if you have many worker replicas, scale them down
- increase PREFECT_WORKER_QUERY_SECONDS if acceptable
Prune old flow-run history
- if flow_run is large, retention cleanup can help a lot
Tune DB pool on the Prefect server
If your server is under concurrency pressure, increasing SQLAlchemy pool settings may help.
Common env vars to review:
- PREFECT_SERVER_DATABASE_SQLALCHEMY_POOL_SIZE
- PREFECT_SERVER_DATABASE_SQLALCHEMY_MAX_OVERFLOW
Update planner stats / vacuum
- make sure autovacuum is healthy
- run manual ANALYZE if stats are stale
Inspect expensive UI queries
- avoid very broad “all flow runs ever” views if the table is huge
- filters on task runs / certain sorts can be much slower than plain recent runs
My current best guess
Based on the two symptoms together, my best guess is:
your flow_run table has grown enough that routine Prefect API queries are now expensive, and worker polling is adding constant background DB load; on top of that, RDS and/or the API DB pool is saturating, causing 10s+ latency and occasional 500s.
If you want, send me:
- the flow_run row count
- number of worker replicas
- number of work queues
- the actual 500 traceback from server logs
- whether RDS CPU / connections spike during these calls
and I can help you pinpoint whether the first thing to fix is:
- worker polling
- DB pool sizing
- retention cleanup
- RDS sizing
- query/index tuning
Relevant docs:Marvin
07/21/2026, 12:40 PMNicholas Pini
07/21/2026, 1:09 PMflow_run_total_size is 2101MBMarvin
07/21/2026, 1:09 PMMarvin
07/21/2026, 1:09 PM162,259 rows / ~2.1 GB is surprisingly bloated for flow_run alone.
That combination suggests one or more of:
- table bloat / dead tuples
- large JSON payloads on rows
- large associated TOAST storage
- indexes taking substantial space
- autovacuum not keeping up
So I would shift suspicion from “too many rows” to “the table is unhealthy or queries are inefficient for its shape.”
Why this is suspicious
162k rows should not normally make basic pagination take 10+ seconds on a healthy Postgres/RDS instance. Even self-hosted Prefect with moderate load should usually handle that fine.
So if POST /api/flow_runs/paginate is taking 10s and 500ing at only 162k rows, likely causes become:
1. DB bloat / stale stats
2. RDS underprovisioned or saturated
3. many concurrent worker polls / API requests
4. connection pool exhaustion in Prefect server
5. slow query plan due to missing or poor index usage
6. lock contention
What to check next
1. How much of that 2.1 GB is table vs indexes
Run:
sql
SELECT
pg_size_pretty(pg_relation_size('flow_run')) AS table_size,
pg_size_pretty(pg_indexes_size('flow_run')) AS indexes_size,
pg_size_pretty(pg_total_relation_size('flow_run')) AS total_size;
This will tell us whether:
- the table itself is huge
- the indexes are huge
- or both
2. Check dead tuples / autovacuum
Run:
sql
SELECT
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'flow_run';
If n_dead_tup is high or autovacuum/analyze is old, that’s a big clue.
3. Check actual bloat-ish indicators on flow_run and task_run
Also inspect task_run, because UI queries may involve it too:
sql
SELECT
relname,
n_live_tup,
n_dead_tup,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE relname IN ('flow_run', 'task_run')
ORDER BY pg_total_relation_size(relid) DESC;
4. Check worker count
How many worker replicas are polling this work pool?
Even with modest row counts, many workers can create constant DB churn.
5. Check the Prefect server traceback for the 500
This is still very important. The traceback can distinguish between:
- SQLAlchemy connection pool timeout
- DB statement timeout
- lock timeout
- serialization issue
- other DB exception
What I suspect now
Given your numbers, my updated best guess is:
the main issue is probably not raw row count, but a combination of polling load + database bloat/stats/pool contention.
162k rows should generally be manageable. 2.1 GB for that row count is what stands out.
Low-risk things you can do now
Run analyze
sql
ANALYZE flow_run;
ANALYZE task_run;
ANALYZE deployment;
ANALYZE work_queue;
ANALYZE work_pool;
Check vacuum health
If autovacuum is lagging, that can absolutely cause bad plans and slow pagination.
Reduce worker polling pressure temporarily
If you have multiple workers:
- scale down replicas if possible
- increase PREFECT_WORKER_QUERY_SECONDS
Look at RDS metrics during the slow calls
Especially:
- CPU
- free memory
- disk queue depth
- read IOPS
- DB connections
Best next step
Please run these two queries and send the output:
sql
SELECT
pg_size_pretty(pg_relation_size('flow_run')) AS table_size,
pg_size_pretty(pg_indexes_size('flow_run')) AS indexes_size,
pg_size_pretty(pg_total_relation_size('flow_run')) AS total_size;
sql
SELECT
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relname IN ('flow_run', 'task_run');
If you also know:
- number of worker replicas
- RDS instance class
- whether autovacuum is enabled
I can help you narrow this down pretty quickly.Nicholas Pini
07/21/2026, 1:14 PM┌────────────┬──────────────┬────────────┐
│ table_size │ indexes_size │ total_size │
│ varchar │ varchar │ varchar │
├────────────┼──────────────┼────────────┤
│ 132 MB │ 216 MB │ 2102 MB │
└────────────┴──────────────┴────────────┘
second query:
┌──────────┬────────────┬────────────┬──────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────────────────────────────┐
│ relname │ n_live_tup │ n_dead_tup │ last_vacuum │ last_autovacuum │ last_analyze │ last_autoanalyze │
│ varchar │ int64 │ int64 │ timestamp with time zone │ timestamp with time zone │ timestamp with time zone │ timestamp with time zone │
├──────────┼────────────┼────────────┼──────────────────────────┼───────────────────────────────┼───────────────────────────────┼───────────────────────────────┤
│ flow_run │ 162295 │ 16181 │ NULL │ 2026-07-16 22:02:37.486414+02 │ 2026-07-21 15:05:51.984192+02 │ 2026-07-21 14:24:00.865962+02 │
└──────────┴────────────┴────────────┴──────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────────────────────────────┘
third query:
┌──────────┬────────────┬────────────┬────────────┐
│ relname │ n_live_tup │ n_dead_tup │ total_size │
│ varchar │ int64 │ int64 │ varchar │
├──────────┼────────────┼────────────┼────────────┤
│ flow_run │ 162296 │ 16185 │ 2102 MB │
│ task_run │ 427609 │ 33068 │ 751 MB │
└──────────┴────────────┴────────────┴────────────┘
@MarvinMarvin
07/21/2026, 1:14 PMMarvin
07/21/2026, 1:14 PMflow_run.
What your numbers say
For `flow_run`:
- ~162k live rows
- ~16k dead rows
- table heap only 132 MB
- indexes 216 MB
That all looks pretty normal.
The suspicious part is this:
- total_size = 2102 MB
- but table + indexes = 132 MB + 216 MB = 348 MB
That means roughly 1.7 GB is elsewhere, almost certainly TOAST storage.
In Postgres, TOAST is where large variable-width values get stored out-of-line — typically big JSON/text payloads.
So the picture is now:
Likely interpretation
- flow_run itself is not huge
- indexes are not huge
- autovacuum/analyze seem active
- dead tuples are not extreme
- but many flow_run rows likely contain large serialized payloads / metadata / parameters / state details
That means the slowness is less likely to be “table bloat” and more likely to be one of:
1. pagination is reading/serializing large flow_run rows
2. some query path is forcing access to toasted columns
3. connection pool / concurrency pressure from workers
4. RDS instance is underpowered for repeated API polling + UI queries
5. lock/contention issues
This is the key insight now
At 162k rows, POST /api/flow_runs/paginate should usually be fast.
If it is 10+ seconds and sometimes 500s, I would now prioritize:
1. Prefect server / DB connection pool saturation
2. worker polling load
3. RDS resource bottleneck
4. large row serialization costs / TOAST fetches
more than raw row count or bloat.
Why TOAST matters here
The endpoint returns flow run objects, and if Prefect is selecting ORM rows with columns that contain large JSON/text fields, Postgres may need to fetch big toasted values, and then Python/Pydantic has to serialize them.
So even a page of 100 rows can become surprisingly expensive if each row carries bulky metadata/state payloads.
What I would check next
1. How many worker replicas do you have?
This is still one of the most important missing facts.
If you have many workers polling every 30s, that alone can create enough DB/API pressure to make the UI pagination endpoint intermittently fail.
2. What RDS instance type are you using?
A small burstable instance can absolutely struggle here.
If you’re on something like:
- db.t3.micro
- db.t3.small
- db.t3.medium
then I would be very suspicious.
3. Check whether the 500 is a connection-pool timeout
Please inspect the Prefect server pod logs around one of these failures. I’d specifically look for:
- sqlalchemy.exc.TimeoutError
- pool timeout / queue pool exhausted
- statement timeout
- connection refused / too many connections
If you see a SQLAlchemy pool timeout, that would explain both the slow paginate and the worker endpoint slowness.
4. Measure TOAST size explicitly
Run this:
sql
SELECT
c.relname AS table_name,
c.reltoastrelid::regclass AS toast_table,
pg_size_pretty(pg_total_relation_size(c.reltoastrelid)) AS toast_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'flow_run';
That should confirm whether most of the 2.1 GB is TOAST.
5. Check average / max sizes of likely large columns
I don’t want to guess the exact column names in your schema without seeing them, but likely candidates are JSON/text columns related to:
- parameters
- context
- state
- empirical policy
- labels / tags / metadata
If you want, I can help you build a query to inspect the largest columns safely.
My current best guess
Given everything you shared, my current ranking is:
1. Too much concurrent polling / API load for your DB/server sizing
2. Large toasted flow_run payloads making pagination serialization expensive
3. DB connection pool pressure in Prefect server
4. RDS instance constraints
5. less likely: plain table bloat
What I’d do right now, in order
Immediate
1. Check number of worker replicas
2. Check RDS instance classMarvin
07/21/2026, 1:14 PMPREFECT_WORKER_QUERY_SECONDS
3. Increase Prefect server DB pool if it’s small
4. Consider pruning old flow runs / task runs if you keep a lot of verbose metadata
5. If RDS is burstable/small, move to a larger non-burstable class
One more very useful query
Please run this too:
sql
SELECT
c.relname,
c.reltoastrelid::regclass AS toast_table,
pg_size_pretty(pg_total_relation_size(c.reltoastrelid)) AS toast_size
FROM pg_class c
WHERE c.relname IN ('flow_run', 'task_run');
And if possible, tell me:
- how many worker replicas are running
- your RDS instance type
- the exact error/traceback from the 500 in server logs
Those three items should let us get from “strong guess” to “probable root cause.”Nicholas Pini
07/21/2026, 1:17 PM┌──────────┬─────────────────────────┬────────────┐
│ relname │ toast_table │ toast_size │
│ varchar │ varchar │ varchar │
├──────────┼─────────────────────────┼────────────┤
│ flow_run │ pg_toast.pg_toast_16543 │ 1754 MB │
│ task_run │ pg_toast.pg_toast_16601 │ 8192 bytes │
└──────────┴─────────────────────────┴────────────┘Marvin
07/21/2026, 1:18 PMMarvin
07/21/2026, 1:18 PMflow_run row size / TOASTed payloads, not row count.
You have:
- only ~162k flow_run rows
- heap table only 132 MB
- indexes only 216 MB
- but flow_run TOAST is 1.75 GB
That means the average flow_run row is carrying a lot of large out-of-line data.
And with 6 worker replicas total polling across 3 work pools, you also have steady background API pressure:
- 6 workers
- polling roughly every 30s by default
- so about 12 work-pool polling requests/minute even before UI traffic
That by itself is not crazy, but when the DB has to touch / serialize heavy flow_run rows, it can absolutely turn into the slowdown you’re seeing.
What this means concretely
POST /api/flow_runs/paginate returning 10s+ / 500 strongly suggests the API is reading and serializing flow_run rows that include large toasted columns.
And because get_scheduled_flow_runs also interacts with flow_run, the large-row nature of that table can contribute there too, especially under repeated polling.
Important nuance
You’re right that max_connections=800+ means the database is unlikely failing due to PostgreSQL connection limits.
But I would still not completely rule out the Prefect API’s SQLAlchemy pool being too small. Those are different things:
- Postgres max_connections = how many DB sessions the server can accept
- Prefect SQLAlchemy pool size = how many connections the Prefect API process is willing to hold/reuse
Still, with the TOAST result you shared, the bigger issue now looks like fat flow_run records.
Why flow_run gets huge
In Prefect, large flow_run rows usually come from one or more of:
- large parameters
- large state payloads / state details
- large serialized results or messages associated to the run metadata
- large context variables
- excessive tags / labels / metadata blobs
Even if task results are persisted elsewhere, some run metadata can still make the row large.
Why this causes your exact symptoms
1. paginate gets slow
The UI/API lists flow runs and serializes them into responses. If each run contains heavy toasted fields:
- more disk reads
- more TOAST fetches
- more network traffic between RDS and API
- more Python deserialization/serialization overhead
2. intermittent 500s
If the query is just slow enough, or the API process gets backed up, you can see:
- timeouts
- worker starvation
- request cancellation
- pool waits inside the app
- generic 500s
3. worker polling also slows down
Even if get_scheduled_flow_runs is not selecting every giant field, it still operates on the same table and contends with the same DB load.
What I’d investigate next: which flow_run columns are huge
This is now the highest-value next step.
Run this to inspect average / max sizes for likely large columns on `flow_run`:
sql
SELECT
avg(pg_column_size(parameters)) AS avg_parameters,
max(pg_column_size(parameters)) AS max_parameters,
avg(pg_column_size(context)) AS avg_context,
max(pg_column_size(context)) AS max_context,
avg(pg_column_size(state)) AS avg_state,
max(pg_column_size(state)) AS max_state,
avg(pg_column_size(empirical_policy)) AS avg_empirical_policy,
max(pg_column_size(empirical_policy)) AS max_empirical_policy
FROM flow_run;
If some of these columns don’t exist in your exact schema, we can adjust, but this is the right kind of check.
If you want to first discover the columns:
sql
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'flow_run'
ORDER BY ordinal_position;
Also: find the biggest rows
This is very useful:
sql
SELECT
id,
name,
pg_column_size(flow_run) AS row_size
FROM flow_run
ORDER BY row_size DESC
LIMIT 20;
That will show whether you have a subset of pathological runs causing most of the TOAST usage.
What to do if large parameters/state are the cause
1. Stop sending huge parameters to flows if possibleMarvin
07/21/2026, 1:18 PMflow_run.
Better pattern:
- store big payloads in S3 / blob storage / DB
- pass a reference/URI/id into the flow
2. Avoid storing large objects in flow state/metadata
If you’re returning or attaching very large objects, inspect whether they’re being represented in state details or metadata.
3. Prune old runs
If you have historical rows with giant payloads, cleanup will help.
4. Consider a maintenance rebuild if needed
If you eventually delete a lot of oversized historical rows, you may need vacuum maintenance to reclaim space.
5. Reduce worker poll frequency
This won’t solve the root cause, but it can lower pressure:
- increase PREFECT_WORKER_QUERY_SECONDS
I would also still inspect the API traceback
Because the exact 500 will tell us whether the immediate failure mode is:
- SQLAlchemy pool timeout
- statement timeout
- cancelled query
- serialization issue
My current conclusion
Based on all the evidence:
the most likely root cause is oversized flow_run rows caused by large toasted columns, with worker polling and general API traffic amplifying the impact.
This fits all of your data much better than:
- too many rows
- severe bloat
- raw PostgreSQL max connection limits
If you want, send me the output of either:
sql
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'flow_run'
ORDER BY ordinal_position;
or, even better, the column-size query above, and I can help identify exactly which field is blowing up flow_run.Nicholas Pini
07/21/2026, 1:47 PMSELECT
avg(pg_column_size(parameters)) AS avg_parameters,
max(pg_column_size(parameters)) AS max_parameters,
avg(pg_column_size(context)) AS avg_context,
max(pg_column_size(context)) AS max_context,
avg(pg_column_size(empirical_policy)) AS avg_empirical_policy,
max(pg_column_size(empirical_policy)) AS max_empirical_policy
FROM flow_run;
returns
┌───────────────────┬────────────────┬─────────────┬─────────────┬──────────────────────┬──────────────────────┐
│ avg_parameters │ max_parameters │ avg_context │ max_context │ avg_empirical_policy │ max_empirical_policy │
│ double │ int32 │ double │ int32 │ double │ int32 │
├───────────────────┼────────────────┼─────────────┼─────────────┼──────────────────────┼──────────────────────┤
│ 10237.47510665936 │ 2157105 │ 5.0 │ 5 │ 173.75663812156395 │ 254 │
└───────────────────┴────────────────┴─────────────┴─────────────┴──────────────────────┴──────────────────────┘
Running
SELECT
id,
name,
pg_column_size(flow_run) AS row_size
FROM flow_run
ORDER BY row_size DESC;
returns
┌──────────────────────────────────────┬─────────────────────┬──────────┐
│ id │ name │ row_size │
│ uuid │ varchar │ int32 │
├──────────────────────────────────────┼─────────────────────┼──────────┤
│ 1bb776cd-099e-48a1-838b-1863ceb9b2c1 │ refreshing-parrot │ 2157954 │
│ 40bce496-23d5-4695-b1a8-2189e9fa9011 │ spicy-mosquito │ 2157942 │
│ ee389e29-58ee-4c74-bb12-16b25d194b75 │ outstanding-harrier │ 2097861 │
│ f81d5af7-64d5-4ea1-b456-093a7828d8de │ enthusiastic-sambar │ 2097861 │
│ 5437aa63-db96-4227-a6cf-117884a1b71c │ interesting-frog │ 2097861 │
│ 961ec163-c4ea-4392-b647-df1bd327c3b1 │ purple-falcon │ 2097853 │
│ 92a5e01d-8749-4d15-8ae3-c431f364258b │ celadon-lorikeet │ 2097853 │
│ 36540e05-830a-4ad0-93f1-e5393e2eca00 │ malachite-toucan │ 2097853 │
│ cd42b3b1-636f-4a12-9c45-ee66ccae0af6 │ tidy-piculet │ 2097845 │
│ 08dc43fa-806b-4b74-ac59-aa3a76c1ef94 │ daffodil-newt │ 2097845 │
│ 0ec7132d-60f2-4230-893e-23b23e5c3d31 │ modest-bumblebee │ 2097829 │
│ abfe1eeb-417d-43e1-9866-e76cc6c6bbfb │ axiomatic-scallop │ 2097829 │
│ 992e425e-243a-4676-92df-7f488e6fadb0 │ rampant-alligator │ 2097829 │
│ ce920a92-ff49-4f7b-96c3-595fbc24dd37 │ cinnamon-jaguarundi │ 2097829 │
│ fd1f4231-515e-4d36-a56d-a59ed65d99ad │ outrageous-marmot │ 2097829 │
│ 87bfd8a9-ecd1-47b7-b516-038a0f1c03a6 │ bold-guan │ 2097821 │
│ 479eba4e-509f-4641-bf49-6789aedc831f │ pastoral-bug │ 2097821 │
│ 1db24325-80ed-46cc-b5b0-06d0aff46ce6 │ blue-hog │ 2097821 │
│ 4e773f00-bd0a-4c12-8e9e-f136c63a936b │ ambitious-crow │ 2097821 │
│ ebe16af6-9312-473e-af30-31b11db7eff3 │ shiny-swallow │ 2097821 │
└──────────────────────────────────────┴─────────────────────┴──────────┘Marvin
07/21/2026, 1:47 PMMarvin
07/21/2026, 1:47 PMflow_run.parameters column is massively inflating flow_run.
Your query shows:
- avg(parameters) ≈ 10 KB per run
- max(parameters) ≈ 2,157,105 bytes (~2.1 MB)
- context is negligible
- empirical_policy is negligible
And your largest rows are all ~`2.1 MB`, which lines up almost perfectly with parameters.
So the big issue is:
you have some flow runs with extremely large parameter payloads, and on average your parameters are also pretty large.
That explains the 1.75 GB TOAST footprint almost perfectly.
What’s happening operationally
When Prefect stores a flow run, it persists the run parameters in flow_run.parameters.
If your flows are triggered with large payloads — for example:
- big JSON blobs
- large nested dicts/lists
- full documents / records
- base64 content
- serialized datasets / batches
— then each flow run row becomes heavy.
That affects:
- UI pagination over flow runs
- API serialization of flow-run responses
- DB IO on reads
- overall contention on flow_run
Even though only some runs are ~2 MB, your average parameter size is already ~10 KB, which is quite high for a metadata table.
So the answer to “what is happening?” is now pretty concrete:
1. Your flow_run table is storing very large parameter payloads
2. Those parameters are being TOASTed out-of-line in Postgres
3. The API endpoints that list / inspect flow runs are paying the cost to read/serialize those heavy rows
4. Worker polling adds constant background DB pressure on top
5. That combination is causing slow endpoints and occasional 500s
This is the most important fix direction
Stop passing large payloads as Prefect flow parameters.
Instead:
- store the payload in external storage
- S3
- database table
- object storage
- file
- pass only a small reference:
- S3 URI
- primary key
- object ID
- path
For example, instead of:
python
my_flow(huge_payload=really_large_dict)
prefer:
python
my_flow(payload_s3_uri="<s3://bucket/path/to/payload.json>")
Then the flow loads the payload inside execution.
Why this matters so much
parameters are part of run metadata, and that metadata is frequently queried by the API/UI. It’s not like an opaque blob stored somewhere never read again — it sits on a hot table.
So large parameters turn a control-plane table into a data-plane storage table, which hurts performance.
What I would do next
1. Identify which flows/deployments are producing these huge parameters
Run:
sql
SELECT
deployment_id,
count(*) AS runs,
avg(pg_column_size(parameters)) AS avg_param_size,
max(pg_column_size(parameters)) AS max_param_size
FROM flow_run
GROUP BY deployment_id
ORDER BY max_param_size DESC
LIMIT 20;
If you want names too:
sql
SELECT
d.name AS deployment_name,
count(*) AS runs,
avg(pg_column_size(fr.parameters)) AS avg_param_size,
max(pg_column_size(fr.parameters)) AS max_param_size
FROM flow_run fr
LEFT JOIN deployment d ON fr.deployment_id = d.id
GROUP BY d.name
ORDER BY max_param_size DESC
LIMIT 20;
That will show which deployment(s) are creating the biggest rows.
2. Identify the worst individual runs
You already have IDs; if needed, inspect one of the bad rows:
sql
SELECT
id,
name,
pg_column_size(parameters) AS parameter_size,
left(parameters::text, 1000) AS parameter_preview
FROM flow_run
ORDER BY pg_column_size(parameters) DESC
LIMIT 5;
Be careful: this may dump sensitive data, so use it cautiously.
3. Change the calling pattern
Wherever these flows are being triggered, stop embedding large payloads directly in parameters.
4. Clean up historical oversized runs
If you have many historical giant rows, you may want to delete old runs according to your retention policy.
That won’t immediately shrink on-disk size unless Postgres reclaims it over time / with maintenance, but it will reduce active working set and future growth.Marvin
07/21/2026, 1:47 PMpaginate after new runs get smaller
Once new runs stop carrying huge parameters, the UI/API should improve substantially. Old rows can still affect broad queries until cleaned up, but the trend will improve.
How this relates to your scheduled-run endpoint
This also helps explain why get_scheduled_flow_runs was showing up:
- it shares the same flow_run table
- even if it doesn’t need all parameter payloads directly, it still operates in the same database environment
- heavy flow_run rows increase overall IO/cache pressure
- broad API slowness spreads
So the earlier observation was real, but the deeper root cause seems to be oversized flow-run records, especially parameters.
Bottom line
The issue is not number of runs, not major bloat, and probably not Valkey. It’s that flow_run.parameters is being used to store large payloads, including some ~2 MB parameter sets, and that is slowing Prefect’s API/control-plane queries.
If you want, I can help with either of these next:
1. writing the SQL to find exactly which deployment/function is generating the oversized parameters, or
2. suggesting a safe migration pattern for moving large flow inputs out of Prefect parameters and into S3 / external storage.Nicholas Pini
07/21/2026, 1:58 PMSELECT
d.name AS deployment_name,
count(*) AS runs,
avg(pg_column_size(fr.parameters)) AS avg_param_size,
max(pg_column_size(fr.parameters)) AS max_param_size
FROM flow_run fr
LEFT JOIN deployment d ON fr.deployment_id = d.id
GROUP BY d.name
ORDER BY max_param_size DESC
LIMIT 20;
but also showing the flow name (not the run, the flow itself)Marvin
07/21/2026, 1:58 PMMarvin
07/21/2026, 1:59 PMflow as well and group by both flow and deployment.
Use:
sql
SELECT
f.name AS flow_name,
d.name AS deployment_name,
count(*) AS runs,
avg(pg_column_size(fr.parameters)) AS avg_param_size,
max(pg_column_size(fr.parameters)) AS max_param_size
FROM flow_run fr
LEFT JOIN deployment d ON fr.deployment_id = d.id
LEFT JOIN flow f ON fr.flow_id = f.id
GROUP BY f.name, d.name
ORDER BY max_param_size DESC
LIMIT 20;
If you want it a bit more robust in case names collide, group by IDs too:
sql
SELECT
f.id AS flow_id,
f.name AS flow_name,
d.id AS deployment_id,
d.name AS deployment_name,
count(*) AS runs,
avg(pg_column_size(fr.parameters)) AS avg_param_size,
max(pg_column_size(fr.parameters)) AS max_param_size
FROM flow_run fr
LEFT JOIN deployment d ON fr.deployment_id = d.id
LEFT JOIN flow f ON fr.flow_id = f.id
GROUP BY f.id, f.name, d.id, d.name
ORDER BY max_param_size DESC
LIMIT 20;
If you want, I can also give you a version that shows:
- only the largest offenders
- a human-readable size column
- or the top individual runs for each flow/deployment.