<@ULVA73B9P> How can I delete all flow runs from t...
# ask-marvin
n
@Marvin How can I delete all flow runs from the Prefect database belonging to a flow and deployment name?
m
thought for 99.6 seconds
In Prefect 3.x, the supported way is to delete them through the API, not by deleting rows directly in the database. Best option: use the
POST /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:
Copy code
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 reference
n
@Marvin I have a self hosted server using password auth
m
thought for 8.3 seconds
That’s fine — for a self-hosted Prefect server with password auth, use the same API approach, but send Basic Auth headers instead of a bearer token. Example with `httpx`:
Copy code
python
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`:
Copy code
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.
n
@Marvin 405 method not allowed
m
thought for 12.0 seconds
A
405 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:
Copy code
bash
http://<host>:4200/api
not just:
Copy code
bash
http://<host>:4200
So the request should go to:
Copy code
bash
POST http://<host>:4200/api/flow_runs/bulk_delete
Try this quick check:
Copy code
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:
Copy code
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.
n
@Marvin bulk_delete not found
m
thought for 10.0 seconds
Got it — that means your self-hosted Prefect server does not have the
bulk_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:
Copy code
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`:
Copy code
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 HTTP
n
@Marvin I have a self hosted prefect server on version 3.6.12, doesn't it have bulk_delete?
m
thought for 57.9 seconds
You’re right to ask — and for 3.6.12 specifically, the answer is no:
flow_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 releases
n
@Marvin I want to delete all flow runs of the flow name
fire-sight-poll-activation
regardless of deploymen
m
thought for 12.4 seconds
On Prefect 3.6.12, do it by: 1. finding the flow ID for
fire-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:
Copy code
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:
Copy code
python
for run in runs:
    print(run["id"], run.get("name"))
Here’s the same thing in `curl`:
Copy code
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
/
FAILED
n
@Marvin it says 0 runs deleted, but if I run this query on the prefect db
Copy code
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;
I get this
Copy code
┌──────────────────────────────────────┬─────────────────────────────────┬──────────────────────────────────────┬─────────────────┬───────┬────────────────────┬────────────────┐
│               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 present
m
thought for 10.3 seconds
Ah — the issue is likely where the filter is being applied. Your SQL shows
flow_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:
Copy code
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:
Copy code
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:
Copy code
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:
Copy code
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 runs
n
@Marvin
Copy code
httpx.HTTPStatusError: Client error '422 Unprocessable Entity' for url '<http://localhost:4200/api/flow_runs/filter>'
m
thought for 82.4 seconds
Yep — the
422
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:
Copy code
json
{
  "flows": {
    "id": {
      "any_": ["<flow-uuid>"]
    }
  },
  "limit": 100
}
or directly by flow name:
Copy code
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:
Copy code
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:
Copy code
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`:
Copy code
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:
Copy code
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 deleting
n
@Marvin it still does't find any runs, despite being present in the db
m
thought for 7.8 seconds
If the rows are in
flow_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:
Copy code
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:
Copy code
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:
Copy code
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:
Copy code
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.