Nicholas Pini
07/24/2026, 10:18 AMMarvin
07/24/2026, 10:18 AMMarvin
07/24/2026, 10:20 AMPOST /flow_runs/bulk_delete endpoint and filter by deployment. Since deployment names are not globally unique, it’s safest to resolve:
1. the flow by name
2. the deployment by name (optionally scoped to that flow)
3. bulk delete matching flow runs in batches
Important details:
- Bulk delete is limited to 50 flow runs per request
- You’ll need to loop until no more runs are returned
- Direct DB deletes are not recommended because Prefect’s API handles related cleanup
Here’s a Python example:
python
import os
import httpx
PREFECT_API_URL = os.environ["PREFECT_API_URL"]
PREFECT_API_KEY = os.environ.get("PREFECT_API_KEY") # needed for Cloud
FLOW_NAME = "my-flow"
DEPLOYMENT_NAME = "my-deployment"
headers = {}
if PREFECT_API_KEY:
headers["Authorization"] = f"Bearer {PREFECT_API_KEY}"
with httpx.Client(base_url=PREFECT_API_URL, headers=headers, timeout=30.0) as client:
# Find flow
resp = <http://client.post|client.post>(
"/flows/filter",
json={
"flows": {
"name": {"any_": [FLOW_NAME]}
},
"limit": 1,
},
)
resp.raise_for_status()
flows = resp.json()
if not flows:
raise RuntimeError(f"Flow not found: {FLOW_NAME}")
flow_id = flows[0]["id"]
# Find deployment for that flow
resp = <http://client.post|client.post>(
"/deployments/filter",
json={
"deployments": {
"name": {"any_": [DEPLOYMENT_NAME]}
},
"flows": {
"id": {"any_": [flow_id]}
},
"limit": 1,
},
)
resp.raise_for_status()
deployments = resp.json()
if not deployments:
raise RuntimeError(
f"Deployment not found: {DEPLOYMENT_NAME} for flow {FLOW_NAME}"
)
deployment_id = deployments[0]["id"]
total_deleted = 0
while True:
resp = <http://client.post|client.post>(
"/flow_runs/bulk_delete",
json={
"flow_runs": {
"deployment_id": {"any_": [deployment_id]}
},
"limit": 50,
},
)
resp.raise_for_status()
deleted_ids = resp.json().get("deleted", [])
if not deleted_ids:
break
total_deleted += len(deleted_ids)
print(f"Deleted {len(deleted_ids)} runs in this batch")
print(f"Total deleted: {total_deleted}")
If you want to do a dry run first, count/list matching runs before deleting via:
- POST /flow_runs/filter
- POST /flow_runs/count
If you prefer, I can also give you:
- a curl version
- a Prefect client SDK version
- a version that deletes only runs older than N days
Relevant docs:
- Prefect docs
- REST API referenceNicholas Pini
07/24/2026, 10:21 AMMarvin
07/24/2026, 10:21 AMMarvin
07/24/2026, 10:21 AMpython
import os
import httpx
PREFECT_API_URL = os.environ["PREFECT_API_URL"]
FLOW_NAME = "my-flow"
DEPLOYMENT_NAME = "my-deployment"
USERNAME = os.environ["PREFECT_API_AUTH_STRING"].split(":", 1)[0]
PASSWORD = os.environ["PREFECT_API_AUTH_STRING"].split(":", 1)[1]
with httpx.Client(
base_url=PREFECT_API_URL,
auth=(USERNAME, PASSWORD),
timeout=30.0,
) as client:
# Find flow
resp = <http://client.post|client.post>(
"/flows/filter",
json={
"flows": {
"name": {"any_": [FLOW_NAME]}
},
"limit": 1,
},
)
resp.raise_for_status()
flows = resp.json()
if not flows:
raise RuntimeError(f"Flow not found: {FLOW_NAME}")
flow_id = flows[0]["id"]
# Find deployment for that flow
resp = <http://client.post|client.post>(
"/deployments/filter",
json={
"deployments": {
"name": {"any_": [DEPLOYMENT_NAME]}
},
"flows": {
"id": {"any_": [flow_id]}
},
"limit": 1,
},
)
resp.raise_for_status()
deployments = resp.json()
if not deployments:
raise RuntimeError(
f"Deployment not found: {DEPLOYMENT_NAME} for flow {FLOW_NAME}"
)
deployment_id = deployments[0]["id"]
total_deleted = 0
while True:
resp = <http://client.post|client.post>(
"/flow_runs/bulk_delete",
json={
"flow_runs": {
"deployment_id": {"any_": [deployment_id]}
},
"limit": 50,
},
)
resp.raise_for_status()
deleted_ids = resp.json().get("deleted", [])
if not deleted_ids:
break
total_deleted += len(deleted_ids)
print(f"Deleted {len(deleted_ids)} runs in this batch")
print(f"Total deleted: {total_deleted}")
If you already have PREFECT_API_AUTH_STRING=user:password set, this should work as-is.
You can also do it with `curl`:
bash
FLOW_NAME="my-flow"
DEPLOYMENT_NAME="my-deployment"
API_URL="<http://localhost:4200/api>"
AUTH="$PREFECT_API_AUTH_STRING"
FLOW_ID=$(curl -s -u "$AUTH" -X POST "$API_URL/flows/filter" \
-H "Content-Type: application/json" \
-d "{\"flows\":{\"name\":{\"any_\":[\"$FLOW_NAME\"]}},\"limit\":1}" \
| jq -r '.[0].id')
DEPLOYMENT_ID=$(curl -s -u "$AUTH" -X POST "$API_URL/deployments/filter" \
-H "Content-Type: application/json" \
-d "{\"deployments\":{\"name\":{\"any_\":[\"$DEPLOYMENT_NAME\"]}},\"flows\":{\"id\":{\"any_\":[\"$FLOW_ID\"]}},\"limit\":1}" \
| jq -r '.[0].id')
while true; do
DELETED=$(curl -s -u "$AUTH" -X POST "$API_URL/flow_runs/bulk_delete" \
-H "Content-Type: application/json" \
-d "{\"flow_runs\":{\"deployment_id\":{\"any_\":[\"$DEPLOYMENT_ID\"]}},\"limit\":50}")
COUNT=$(echo "$DELETED" | jq '.deleted | length')
[ "$COUNT" -eq 0 ] && break
echo "Deleted $COUNT runs"
done
A couple of notes:
- Make sure PREFECT_API_URL includes /api, e.g. <http://host:4200/api>
- bulk_delete deletes at most 50 per request, so the loop is required
- Filtering by both flow name and deployment name is safer than deployment name alone
If you want, I can turn this into a one-off script that takes --flow-name and --deployment-name arguments.Nicholas Pini
07/24/2026, 10:27 AMMarvin
07/24/2026, 10:27 AMMarvin
07/24/2026, 10:27 AM405 Method Not Allowed usually means one of these:
1. you’re hitting the wrong URL
2. your server/version does not expose POST /flow_runs/bulk_delete
3. a proxy/auth layer is intercepting the request
For Prefect, the most common cause is that PREFECT_API_URL is wrong.
Check this first:
PREFECT_API_URL should usually be something like:
bash
http://<host>:4200/api
not just:
bash
http://<host>:4200
So the request should go to:
bash
POST http://<host>:4200/api/flow_runs/bulk_delete
Try this quick check:
bash
curl -i -u "$PREFECT_API_AUTH_STRING" \
-X POST \
"$PREFECT_API_URL/flow_runs/bulk_delete" \
-H "Content-Type: application/json" \
-d '{"flow_runs": {}, "limit": 1}'
If that still returns 405, then I’d check whether your self-hosted server actually supports that endpoint.
A reliable fallback is to:
1. query matching flow runs
2. delete them one-by-one
Example:
python
import os
import httpx
PREFECT_API_URL = os.environ["PREFECT_API_URL"]
FLOW_NAME = "my-flow"
DEPLOYMENT_NAME = "my-deployment"
USERNAME, PASSWORD = os.environ["PREFECT_API_AUTH_STRING"].split(":", 1)
with httpx.Client(
base_url=PREFECT_API_URL,
auth=(USERNAME, PASSWORD),
timeout=30.0,
) as client:
# Resolve flow
resp = <http://client.post|client.post>(
"/flows/filter",
json={"flows": {"name": {"any_": [FLOW_NAME]}}, "limit": 1},
)
resp.raise_for_status()
flows = resp.json()
if not flows:
raise RuntimeError(f"Flow not found: {FLOW_NAME}")
flow_id = flows[0]["id"]
# Resolve deployment
resp = <http://client.post|client.post>(
"/deployments/filter",
json={
"deployments": {"name": {"any_": [DEPLOYMENT_NAME]}},
"flows": {"id": {"any_": [flow_id]}},
"limit": 1,
},
)
resp.raise_for_status()
deployments = resp.json()
if not deployments:
raise RuntimeError(f"Deployment not found: {DEPLOYMENT_NAME}")
deployment_id = deployments[0]["id"]
total_deleted = 0
while True:
resp = <http://client.post|client.post>(
"/flow_runs/filter",
json={
"flow_runs": {"deployment_id": {"any_": [deployment_id]}},
"limit": 200,
},
)
resp.raise_for_status()
runs = resp.json()
if not runs:
break
for run in runs:
delete_resp = client.delete(f"/flow_runs/{run['id']}")
delete_resp.raise_for_status()
total_deleted += 1
print(f"Deleted {run['id']}")
print(f"Total deleted: {total_deleted}")
If you want, send me:
- the exact URL you’re posting to
- whether it includes /api
- your Prefect server version
and I can help pinpoint why the 405 is happening.Nicholas Pini
07/24/2026, 10:29 AMMarvin
07/24/2026, 10:29 AMMarvin
07/24/2026, 10:30 AMbulk_delete endpoint available.
In that case, the supported fallback is to:
1. find the deployment by flow name + deployment name
2. list matching flow runs
3. delete each flow run individually with DELETE /flow_runs/{id}
Here’s a working approach for self-hosted + password auth:
python
import os
import httpx
PREFECT_API_URL = os.environ["PREFECT_API_URL"] # e.g. <http://host:4200/api>
AUTH_STRING = os.environ["PREFECT_API_AUTH_STRING"] # user:password
FLOW_NAME = "my-flow"
DEPLOYMENT_NAME = "my-deployment"
username, password = AUTH_STRING.split(":", 1)
with httpx.Client(
base_url=PREFECT_API_URL,
auth=(username, password),
timeout=30.0,
) as client:
# 1. Resolve flow by name
resp = <http://client.post|client.post>(
"/flows/filter",
json={
"flows": {
"name": {"any_": [FLOW_NAME]}
},
"limit": 1,
},
)
resp.raise_for_status()
flows = resp.json()
if not flows:
raise RuntimeError(f"Flow not found: {FLOW_NAME}")
flow_id = flows[0]["id"]
# 2. Resolve deployment by flow + deployment name
resp = <http://client.post|client.post>(
"/deployments/filter",
json={
"deployments": {
"name": {"any_": [DEPLOYMENT_NAME]}
},
"flows": {
"id": {"any_": [flow_id]}
},
"limit": 1,
},
)
resp.raise_for_status()
deployments = resp.json()
if not deployments:
raise RuntimeError(
f"Deployment not found: {DEPLOYMENT_NAME} for flow {FLOW_NAME}"
)
deployment_id = deployments[0]["id"]
total_deleted = 0
while True:
# 3. Fetch a page of matching flow runs
resp = <http://client.post|client.post>(
"/flow_runs/filter",
json={
"flow_runs": {
"deployment_id": {"any_": [deployment_id]}
},
"limit": 100,
},
)
resp.raise_for_status()
runs = resp.json()
if not runs:
break
# 4. Delete each run individually
for run in runs:
run_id = run["id"]
delete_resp = client.delete(f"/flow_runs/{run_id}")
delete_resp.raise_for_status()
total_deleted += 1
print(f"Deleted flow run {run_id}")
print(f"Total deleted: {total_deleted}")
A couple of notes:
- Make sure PREFECT_API_URL includes /api
- This is slower than bulk delete, but should work on older/self-hosted setups
- If you have a lot of runs, you may want to add a small sleep or periodic progress logging
If you want a shell version, here’s the same pattern with curl + `jq`:
bash
FLOW_NAME="my-flow"
DEPLOYMENT_NAME="my-deployment"
API_URL="<http://localhost:4200/api>"
AUTH="$PREFECT_API_AUTH_STRING"
FLOW_ID=$(curl -s -u "$AUTH" -X POST "$API_URL/flows/filter" \
-H "Content-Type: application/json" \
-d "{\"flows\":{\"name\":{\"any_\":[\"$FLOW_NAME\"]}},\"limit\":1}" \
| jq -r '.[0].id')
DEPLOYMENT_ID=$(curl -s -u "$AUTH" -X POST "$API_URL/deployments/filter" \
-H "Content-Type: application/json" \
-d "{\"deployments\":{\"name\":{\"any_\":[\"$DEPLOYMENT_NAME\"]}},\"flows\":{\"id\":{\"any_\":[\"$FLOW_ID\"]}},\"limit\":1}" \
| jq -r '.[0].id')
while true; do
RUN_IDS=$(curl -s -u "$AUTH" -X POST "$API_URL/flow_runs/filter" \
-H "Content-Type: application/json" \
-d "{\"flow_runs\":{\"deployment_id\":{\"any_\":[\"$DEPLOYMENT_ID\"]}},\"limit\":100}" \
| jq -r '.[].id')
[ -z "$RUN_IDS" ] && break
for RUN_ID in $RUN_IDS; do
curl -s -u "$AUTH" -X DELETE "$API_URL/flow_runs/$RUN_ID"
echo "Deleted $RUN_ID"
done
done
If you want, I can also adapt this to:
- delete only older than N days
- delete only Completed/Failed runs
- use the Prefect Python client instead of raw HTTPNicholas Pini
07/24/2026, 10:31 AMMarvin
07/24/2026, 10:31 AMMarvin
07/24/2026, 10:32 AMflow_runs bulk_delete is not available there.
It was added after 3.6.12, and first appears in 3.6.16.
So for your self-hosted server on 3.6.12:
- POST /flow_runs/bulk_delete → not present
- you need to use POST /flow_runs/filter + DELETE /flow_runs/{id} in a loop
- if you upgrade to 3.6.16+, you should have bulk delete available
So the 405 / “not found” behavior you saw is consistent with running 3.6.12.
Recommended options:
1. Stay on 3.6.12 and delete runs individually
2. Upgrade to 3.6.16+ if you want the bulk delete endpoint
If helpful, I can give you either:
- a clean script for 3.6.12 that deletes all runs by flow+deployment
- a version that uses bulk delete when available and falls back automatically otherwise
Docs:
- Prefect docs
- Prefect releasesNicholas Pini
07/24/2026, 10:41 AMfire-sight-poll-activation regardless of deploymenMarvin
07/24/2026, 10:41 AMMarvin
07/24/2026, 10:42 AMfire-sight-poll-activation
2. listing flow runs filtered by that flow
3. deleting them one by one
Here’s a script for self-hosted + password auth:
python
import os
import httpx
PREFECT_API_URL = os.environ["PREFECT_API_URL"] # e.g. <http://host:4200/api>
AUTH_STRING = os.environ["PREFECT_API_AUTH_STRING"] # user:password
FLOW_NAME = "fire-sight-poll-activation"
username, password = AUTH_STRING.split(":", 1)
with httpx.Client(
base_url=PREFECT_API_URL,
auth=(username, password),
timeout=30.0,
) as client:
# Resolve the flow by name
resp = <http://client.post|client.post>(
"/flows/filter",
json={
"flows": {
"name": {"any_": [FLOW_NAME]}
},
"limit": 10,
},
)
resp.raise_for_status()
flows = resp.json()
if not flows:
raise RuntimeError(f"Flow not found: {FLOW_NAME}")
# Usually flow names are unique, but handle multiple matches just in case
flow_ids = [flow["id"] for flow in flows]
print(f"Found {len(flow_ids)} matching flow(s)")
total_deleted = 0
while True:
# Fetch a page of flow runs for these flow IDs
resp = <http://client.post|client.post>(
"/flow_runs/filter",
json={
"flows": {
"id": {"any_": flow_ids}
},
"limit": 100,
},
)
resp.raise_for_status()
runs = resp.json()
if not runs:
break
for run in runs:
run_id = run["id"]
delete_resp = client.delete(f"/flow_runs/{run_id}")
delete_resp.raise_for_status()
total_deleted += 1
print(f"Deleted flow run {run_id}")
print(f"Total deleted: {total_deleted}")
If you want a dry run first, replace the delete loop with:
python
for run in runs:
print(run["id"], run.get("name"))
Here’s the same thing in `curl`:
bash
FLOW_NAME="fire-sight-poll-activation"
API_URL="<http://localhost:4200/api>"
AUTH="$PREFECT_API_AUTH_STRING"
FLOW_IDS=$(curl -s -u "$AUTH" -X POST "$API_URL/flows/filter" \
-H "Content-Type: application/json" \
-d "{\"flows\":{\"name\":{\"any_\":[\"$FLOW_NAME\"]}},\"limit\":10}" \
| jq -r '.[].id')
[ -z "$FLOW_IDS" ] && echo "No matching flow found" && exit 1
while true; do
FLOW_IDS_JSON=$(printf '%s\n' "$FLOW_IDS" | jq -R . | jq -s .)
RUN_IDS=$(curl -s -u "$AUTH" -X POST "$API_URL/flow_runs/filter" \
-H "Content-Type: application/json" \
-d "{\"flows\":{\"id\":{\"any_\":$FLOW_IDS_JSON}},\"limit\":100}" \
| jq -r '.[].id')
[ -z "$RUN_IDS" ] && break
for RUN_ID in $RUN_IDS; do
curl -s -u "$AUTH" -X DELETE "$API_URL/flow_runs/$RUN_ID" > /dev/null
echo "Deleted $RUN_ID"
done
done
A couple of notes:
- This deletes all flow runs for that flow name, regardless of deployment
- On 3.6.12, individual deletion is the correct approach
- Make sure PREFECT_API_URL includes /api
If you want, I can make this safer by adding:
- a confirmation prompt
- deletion only for runs older than N days
- deletion only for terminal states like COMPLETED / FAILEDNicholas Pini
07/24/2026, 10:44 AMSELECT
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;
I get this
┌──────────────────────────────────────┬─────────────────────────────────┬──────────────────────────────────────┬─────────────────┬───────┬────────────────────┬────────────────┐
│ flow_id │ flow_name │ deployment_id │ deployment_name │ runs │ avg_param_size │ max_param_size │
│ uuid │ varchar │ uuid │ varchar │ int64 │ double │ int32 │
├──────────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────────┼─────────────────┼───────┼────────────────────┼────────────────┤
│ e4be9c44-3d79-4e5c-85fe-77fde230d381 │ simulation-lst-edi-wrapper-flow │ NULL │ NULL │ 30 │ 522233.5333333333 │ 2157105 │
│ 3b1aee06-3eb1-48e3-845a-1d413aeb9fa1 │ pre_processing │ NULL │ NULL │ 146 │ 34910.650684931505 │ 2097363 │
│ 65360de0-bad5-4767-8734-eaf89077e716 │ processing │ NULL │ NULL │ 205 │ 65809.36097560976 │ 2097359 │
│ 6660abc5-b023-43e4-bb5e-76237a67b139 │ machine-learning-classification │ NULL │ NULL │ 76 │ 149104.76315789475 │ 2097331 │
│ 7fa55a46-4635-484f-8948-4a5341e97e74 │ indexes-calculation │ NULL │ NULL │ 24 │ 559457.0 │ 2097331 │
│ 2865ef43-3834-4b4a-a797-7dd0110694c0 │ fire-sight-poll-activation │ NULL │ NULL │ 125 │ 1021348.096 │ 1029583 │
fire-sight-poll-activation is presentMarvin
07/24/2026, 10:44 AMMarvin
07/24/2026, 10:44 AMflow_run.flow_id is populated, but deployment_id is NULL, and in older API usage the POST /flow_runs/filter body can be easy to get wrong.
For your case, the safest approach is:
1. resolve the flow ID for fire-sight-poll-activation
2. query flow runs using a flow run filter on flow_id
3. delete each returned run
Use this version instead:
python
import os
import httpx
PREFECT_API_URL = os.environ["PREFECT_API_URL"] # e.g. <http://host:4200/api>
AUTH_STRING = os.environ["PREFECT_API_AUTH_STRING"] # user:password
FLOW_NAME = "fire-sight-poll-activation"
username, password = AUTH_STRING.split(":", 1)
with httpx.Client(
base_url=PREFECT_API_URL,
auth=(username, password),
timeout=30.0,
) as client:
# 1. Find the flow
resp = <http://client.post|client.post>(
"/flows/filter",
json={
"flows": {
"name": {"any_": [FLOW_NAME]}
}
},
)
resp.raise_for_status()
flows = resp.json()
if not flows:
raise RuntimeError(f"Flow not found: {FLOW_NAME}")
flow_id = flows[0]["id"]
print(f"Resolved flow {FLOW_NAME} -> {flow_id}")
total_deleted = 0
while True:
# 2. Query runs by flow_run.flow_id directly
resp = <http://client.post|client.post>(
"/flow_runs/filter",
json={
"flow_runs": {
"flow_id": {"any_": [flow_id]}
},
"limit": 100,
},
)
resp.raise_for_status()
runs = resp.json()
if not runs:
break
print(f"Found {len(runs)} runs in this batch")
# 3. Delete each run
for run in runs:
run_id = run["id"]
delete_resp = client.delete(f"/flow_runs/{run_id}")
delete_resp.raise_for_status()
total_deleted += 1
print(f"Deleted {run_id}")
print(f"Total deleted: {total_deleted}")
The key change is this filter:
python
"flow_runs": {
"flow_id": {"any_": [flow_id]}
}
instead of filtering via the top-level "flows" section.
You can also do a dry run first:
python
resp = <http://client.post|client.post>(
"/flow_runs/filter",
json={
"flow_runs": {
"flow_id": {"any_": [flow_id]}
},
"limit": 5,
},
)
print(resp.json())
If that returns runs, deletion should work.
If you want to test with curl first:
bash
API_URL="<http://localhost:4200/api>"
AUTH="$PREFECT_API_AUTH_STRING"
FLOW_NAME="fire-sight-poll-activation"
FLOW_ID=$(curl -s -u "$AUTH" -X POST "$API_URL/flows/filter" \
-H "Content-Type: application/json" \
-d "{\"flows\":{\"name\":{\"any_\":[\"$FLOW_NAME\"]}}}" \
| jq -r '.[0].id')
echo "$FLOW_ID"
curl -s -u "$AUTH" -X POST "$API_URL/flow_runs/filter" \
-H "Content-Type: application/json" \
-d "{\"flow_runs\":{\"flow_id\":{\"any_\":[\"$FLOW_ID\"]}},\"limit\":5}" \
| jq
If you want, I can also give you a version that:
- prints the number of matching runs before deleting
- deletes in smaller batches
- skips non-terminal runsNicholas Pini
07/24/2026, 10:46 AMhttpx.HTTPStatusError: Client error '422 Unprocessable Entity' for url '<http://localhost:4200/api/flow_runs/filter>'Marvin
07/24/2026, 10:46 AMMarvin
07/24/2026, 10:47 AM422 is because flow_runs.flow_id is not a valid filter field in Prefect 3.6.12.
For POST /flow_runs/filter, you need to filter by the top-level flows filter, like this:
json
{
"flows": {
"id": {
"any_": ["<flow-uuid>"]
}
},
"limit": 100
}
or directly by flow name:
json
{
"flows": {
"name": {
"any_": ["fire-sight-poll-activation"]
}
},
"limit": 100
}
So the easiest fix is to skip resolving the flow ID entirely and just query by flow name.
Try this script:
python
import os
import httpx
PREFECT_API_URL = os.environ["PREFECT_API_URL"] # e.g. <http://localhost:4200/api>
AUTH_STRING = os.environ["PREFECT_API_AUTH_STRING"] # user:password
FLOW_NAME = "fire-sight-poll-activation"
username, password = AUTH_STRING.split(":", 1)
with httpx.Client(
base_url=PREFECT_API_URL,
auth=(username, password),
timeout=30.0,
) as client:
total_deleted = 0
while True:
resp = <http://client.post|client.post>(
"/flow_runs/filter",
json={
"flows": {
"name": {
"any_": [FLOW_NAME]
}
},
"limit": 100,
},
)
resp.raise_for_status()
runs = resp.json()
if not runs:
break
print(f"Found {len(runs)} runs in this batch")
for run in runs:
run_id = run["id"]
delete_resp = client.delete(f"/flow_runs/{run_id}")
delete_resp.raise_for_status()
total_deleted += 1
print(f"Deleted {run_id}")
print(f"Total deleted: {total_deleted}")
If you want to verify before deleting, run just this part:
python
resp = <http://client.post|client.post>(
"/flow_runs/filter",
json={
"flows": {
"name": {
"any_": ["fire-sight-poll-activation"]
}
},
"limit": 5,
},
)
resp.raise_for_status()
print(resp.json())
And here’s the equivalent `curl`:
bash
API_URL="<http://localhost:4200/api>"
AUTH="$PREFECT_API_AUTH_STRING"
FLOW_NAME="fire-sight-poll-activation"
curl -s -u "$AUTH" -X POST "$API_URL/flow_runs/filter" \
-H "Content-Type: application/json" \
-d "{\"flows\":{\"name\":{\"any_\":[\"$FLOW_NAME\"]}},\"limit\":5}" \
| jq
Then deletion loop:
bash
API_URL="<http://localhost:4200/api>"
AUTH="$PREFECT_API_AUTH_STRING"
FLOW_NAME="fire-sight-poll-activation"
while true; do
RUN_IDS=$(curl -s -u "$AUTH" -X POST "$API_URL/flow_runs/filter" \
-H "Content-Type: application/json" \
-d "{\"flows\":{\"name\":{\"any_\":[\"$FLOW_NAME\"]}},\"limit\":100}" \
| jq -r '.[].id')
[ -z "$RUN_IDS" ] && break
for RUN_ID in $RUN_IDS; do
curl -s -u "$AUTH" -X DELETE "$API_URL/flow_runs/$RUN_ID" > /dev/null
echo "Deleted $RUN_ID"
done
done
One more important note: if your server has a low default API limit, limit: 100 might also cause a 422. If that happens, try limit: 10 or omit limit entirely.
If you want, I can give you a defensive version that:
- starts with a dry run
- paginates safely
- handles rate limits / partial failures
- prints the run count before deletingNicholas Pini
07/24/2026, 10:49 AMMarvin
07/24/2026, 10:49 AMMarvin
07/24/2026, 10:49 AMflow_run but the API returns none, the usual cause is that the API query excludes them because of joins / visibility constraints / orphaned metadata state, especially on older self-hosted versions.
Given that you’re on self-hosted 3.6.12 and you’ve already confirmed the rows directly in Postgres, the most reliable answer is:
if you need them gone from the database, delete them in SQL.
Since you specifically want to remove all runs for flow name fire-sight-poll-activation, do it with a SQL delete scoped by flow_id.
First, preview what will be deleted:
sql
SELECT fr.id, fr.flow_id, fr.deployment_id, fr.state_id
FROM flow_run fr
JOIN flow f ON fr.flow_id = f.id
WHERE f.name = 'fire-sight-poll-activation';
Then delete those flow runs:
sql
DELETE FROM flow_run fr
USING flow f
WHERE fr.flow_id = f.id
AND f.name = 'fire-sight-poll-activation';
If you want to be extra safe, do it in a transaction:
sql
BEGIN;
SELECT count(*)
FROM flow_run fr
JOIN flow f ON fr.flow_id = f.id
WHERE f.name = 'fire-sight-poll-activation';
DELETE FROM flow_run fr
USING flow f
WHERE fr.flow_id = f.id
AND f.name = 'fire-sight-poll-activation';
COMMIT;
If you want the option to back out:
sql
BEGIN;
DELETE FROM flow_run fr
USING flow f
WHERE fr.flow_id = f.id
AND f.name = 'fire-sight-poll-activation';
-- inspect row count
-- ROLLBACK;
-- or
-- COMMIT;
A few cautions:
- This bypasses Prefect’s API-level cleanup
- If you have foreign key constraints from related tables (task_run, logs, artifacts, etc.), the delete may:
- cascade automatically, or
- fail until child rows are deleted first
- So it’s best to test with a transaction first
If your delete fails with FK errors, send me the exact Postgres error and I can give you the correct ordered delete statements for your Prefect 3.6.12 schema.
If you want, I can also give you a single SQL script that deletes:
- matching task_run rows
- matching log rows
- matching flow_run rows
in the right order for Prefect 3.6.12.