Skip to content

Reconciler Module Links & Migrations

Custom module links do not cascade delete with the linked product

You wired a custom module to a product with defineLink, the product got deleted, and your custom link table still has a row pointing at that old prod_ id. The relation looks broken because it is. Here is why Medusa v2 treats link cascades as opt-in and only honors them through its own delete paths, and a small script that finds the rows this leaves behind.

Python and Node.js Medusa Admin API and Query/Link config Safe by default (report first)
Typing on a laptop
Photo by Shoper on Unsplash
The short answer

Medusa v2 module links are stored in a separate pivot table created by defineLink, living outside both linked modules' own schemas, by design, to keep modules isolated. Cascade on delete is opt-in: unless the link definition passes deleteCascade: true for the product side, deleting the product will not remove rows in your custom link table. Even when deleteCascade is set, it is only honored when the deletion goes through Medusa's Link or Remote Link APIs or workflow steps, such as deleteProductsWorkflow, removeRemoteLinkStep, or link.delete. A raw module-service delete or a direct database delete on the product bypasses that logic entirely and leaves the link row behind, still holding a prod_ id that no longer resolves. Run a small Python or Node.js script that diffs your custom link table's product_id values against the live product id set and reports every dangling row. Full code, tests, and citations are below.

The problem in plain words

When you connect your own custom module to the Product module with defineLink, Medusa creates a pivot table to hold that relationship, separate from both modules' own tables. That separation is deliberate. Product does not know your custom module exists, and your custom module does not know how Product stores its rows. Each module can be swapped or migrated on its own.

The tradeoff is that nothing in the database ties that pivot table to the product row by a foreign key that Postgres itself will enforce automatically on every delete path. Medusa can add that behavior for you, but only if you ask for it in the link definition, and only if the deletion actually runs through the code path that knows to check for it. Delete the product some other way and the pivot row does not know the product is gone.

Product deleted raw service or SQL custom_product link deleteCascade never checked workflow layer bypassed no cascade fired Row survives holds dead prod_ id Query resolves null
Cascade on a module link is opt-in and only honored on Medusa's own delete paths. A raw delete anywhere else leaves the pivot row behind, still pointing at the old product id.

Why it happens

The gap is not a bug in your link definition, it is what happens when a delete skips the layer that knows about the link. A few common ways stores end up with dangling rows in a custom link table:

This is a common source of confusion because a custom link can look fully wired, tests pass, cascade is configured, and it still leaves orphans the moment any code path skips the workflow. Medusa maintainers have confirmed there is still no public API to hard-delete a link table row once its parent is gone (see GitHub issue #13315). See the citations at the end for the exact threads and docs.

The key insight

A row in your custom link table cannot tell you on its own whether it is healthy or dangling, because there is no foreign key forcing the database to know. The only way to find out is to compare every product_id the link table holds against the set of product ids that are actually still alive. That is a pure set-membership problem, not a Medusa bug to patch, so the fix is a reconciliation script, not a schema change, plus fixing the link definition going forward so new deletes cascade correctly.

The fix, as a flow

We do not touch a live link. We add a job that pulls every live product id from the Admin API, pulls every product_id your custom module's link currently stores, and diffs the two sets. Any link row whose product_id is not in the live set is a dangling row, confirmed further with a direct 404 check, and only reported unless a human turns off the dry run flag.

List live products GET /admin/products List custom link rows every stored product_id Diff the two sets pure set membership Confirmed dangling? yes no, leave alone Skip, fine still a live product Report/delete under DRY_RUN=false
Only a link row whose product_id is confirmed gone gets flagged, and only a human turning off dry run lets the script clear it through your module's own service.

Build it step by step

1

Get an Admin API session

Medusa Admin auth is a JWT, not a long-lived static token. Exchange an admin email and password for a token at POST /auth/user/emailpass, then send it back as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL, email, and password in environment variables.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, report only
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, report only
2

Check your link definition for deleteCascade

Enumerate every defineLink call in src/links/*.ts that involves product, and note the pivot table name Medusa auto-generates and the field that holds the prod_ id. If the product side does not pass deleteCascade: true, that is the first fix, so future deletes through Medusa's own workflow steps clear the link automatically.

link definition (TypeScript, for reference)
# This part of the fix lives in your Medusa project's TypeScript source,
# not in the external Python or Node.js script. Shown here for reference,
# src/links/custom-product.ts:
#
#   import ProductModule from "@medusajs/medusa/product"
#   import CustomModule from "../modules/custom"
#   import { defineLink } from "@medusajs/framework/utils"
#
#   export default defineLink(
#     ProductModule.linkable.product,
#     { linkable: CustomModule.linkable.custom_id },
#     { deleteCascade: true },
#   )
#
# With deleteCascade set, a delete run through deleteProductsWorkflow,
# removeRemoteLinkStep, or link.delete will also remove the matching row
# in the custom link (pivot) table. A raw module-service delete or a
# direct SQL delete still bypasses this, which is why the reconciliation
# script below stays necessary for anything deleted before this fix, or
# through a path that skips the workflow layer.
link definition (TypeScript, for reference)
// This part of the fix lives in your Medusa project's TypeScript source,
// not in the external Python or Node.js script. Shown here for reference,
// src/links/custom-product.ts:
//
//   import ProductModule from "@medusajs/medusa/product";
//   import CustomModule from "../modules/custom";
//   import { defineLink } from "@medusajs/framework/utils";
//
//   export default defineLink(
//     ProductModule.linkable.product,
//     { linkable: CustomModule.linkable.custom_id },
//     { deleteCascade: true }
//   );
//
// With deleteCascade set, a delete run through deleteProductsWorkflow,
// removeRemoteLinkStep, or link.delete will also remove the matching row
// in the custom link (pivot) table. A raw module-service delete or a
// direct SQL delete still bypasses this, which is why the reconciliation
// script below stays necessary for anything deleted before this fix, or
// through a path that skips the workflow layer.
3

Pull every live product id

Page through GET /admin/products?fields=id&limit=1000&offset=0 using the returned count, offset, and limit until every page is read. This is the ground truth set of product ids that are truly still alive.

step3.py
import os, requests

BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def login():
    r = requests.post(
        f"{BASE}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]

def list_live_product_ids(token):
    ids, offset, limit = [], 0, 200
    while True:
        r = requests.get(
            f"{BASE}/admin/products",
            params={"fields": "id", "limit": limit, "offset": offset},
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        ids.extend(p["id"] for p in body["products"])
        offset += limit
        if offset >= body["count"]:
            return ids
step3.js
const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";

async function login() {
  const res = await fetch(`${BASE}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function listLiveProductIds(token) {
  const ids = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const url = new URL(`${BASE}/admin/products`);
    url.searchParams.set("fields", "id");
    url.searchParams.set("limit", String(limit));
    url.searchParams.set("offset", String(offset));
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    ids.push(...body.products.map((p) => p.id));
    offset += limit;
    if (offset >= body.count) return ids;
  }
}
4

Pull the product_id values from your custom link

Read your custom module's own list endpoint, or a query.graph call run inside a medusa exec script, for the entity that stores the link rows, asking for id and product_id. This is the set the diff checks against the live ids from step 3.

step4.py
# If your custom module exposes an admin route, call it directly:
#
#   GET /admin/custom-entities?fields=id,product_id&limit=1000
#
# Otherwise, run this inside a medusa exec script so it can resolve the
# container and use the Query module directly:
#
#   const query = container.resolve(ContainerRegistrationKeys.QUERY)
#   const { data } = await query.graph({
#     entity: "custom_module_entity",
#     fields: ["id", "product_id"],
#   })
#
# Either way, the result is the same shape this script needs: a list of
# {"id": ..., "product_id": ...} rows currently stored in the link.
def list_custom_link_rows(token):
    r = requests.get(
        f"{BASE}/admin/custom-entities",
        params={"fields": "id,product_id", "limit": 1000},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["custom_entities"]
step4.js
// If your custom module exposes an admin route, call it directly:
//
//   GET /admin/custom-entities?fields=id,product_id&limit=1000
//
// Otherwise, run this inside a medusa exec script so it can resolve the
// container and use the Query module directly:
//
//   const query = container.resolve(ContainerRegistrationKeys.QUERY);
//   const { data } = await query.graph({
//     entity: "custom_module_entity",
//     fields: ["id", "product_id"],
//   });
//
// Either way, the result is the same shape this script needs: a list of
// { id, product_id } rows currently stored in the link.
async function listCustomLinkRows(token) {
  const url = new URL(`${BASE}/admin/custom-entities`);
  url.searchParams.set("fields", "id,product_id");
  url.searchParams.set("limit", "1000");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.custom_entities;
}
5

Decide, with one pure function

Keep the diff in its own function that takes only the live id set and the link rows, since all the I/O already happened in steps 3 and 4. It returns the link rows whose product_id is not a member of the live set, which is the exact definition of a dangling row.

decide.py
def find_dangling_links(live_product_ids, link_rows):
    return [row for row in link_rows if row["product_id"] not in live_product_ids]
decide.js
export function findDanglingLinks(liveProductIds, linkRows) {
  return linkRows.filter((row) => !liveProductIds.has(row.product_id));
}
6

Cross-check, then report or repair behind DRY_RUN

Before trusting a dangling row, confirm the product is truly gone, not just soft-deleted or filtered out. Call GET /admin/products/{"{id}"} and expect a 404. If the product is soft-deleted, GET /admin/products/{"{id}"}?with_deleted=true tells you whether it is hard gone or just trashed. By default the script only logs each dangling row. Only if DRY_RUN=false and a human has confirmed the ids should the script call your custom module's own delete or softDelete method for those rows, run from inside a medusa exec script so the deletion goes through Medusa's declared data layer, never a raw SQL delete.

Run it safe

Leave DRY_RUN=true so the script only reports dangling rows by id. There is no public API to hard-delete a link row once its product is gone, so the repair step always runs your own custom module's declared delete method from inside Medusa, never blind SQL, and only after a human reviews the report.

The full code

Here is the complete script in one file for each language. It logs in, pulls every live product id, pulls every product_id your custom link table currently stores, diffs the two sets with the pure decision function, cross-checks each candidate with a 404 lookup, and reports every confirmed dangling row. The hard-delete or soft-delete path against your custom module's own service is documented but stays outside this external script's process, since it only runs safely from inside a Medusa server context that can resolve the container.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
find_dangling_links.py
"""Find custom module link rows left dangling by a product delete that did not cascade.

Medusa v2 module links live in a pivot table outside both linked modules' own
schemas, by design, to keep modules isolated. Cascade on delete is opt-in via
deleteCascade in the defineLink call, and even then it is only honored when the
deletion runs through Medusa's own Link/Remote Link APIs or workflow steps, such
as deleteProductsWorkflow, removeRemoteLinkStep, or link.delete. A raw module
service delete or a direct SQL delete on the product bypasses that cascade
entirely, leaving rows in the custom link table pointing at a prod_ id that no
longer exists. This script lists every live product id, lists every product_id
your custom link table currently stores, diffs the two sets with a pure function,
and cross-checks each candidate with a 404 lookup before reporting it. It only
reports by default. Hard-deleting or soft-deleting a confirmed dangling row must
run from inside a Medusa server context that can resolve your custom module's
own service, so that part is documented in the guide, not executed by this script.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_dangling_links")

BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def login():
    r = requests.post(
        f"{BASE}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_live_product_ids(token):
    ids, offset, limit = [], 0, 200
    while True:
        r = requests.get(
            f"{BASE}/admin/products",
            params={"fields": "id", "limit": limit, "offset": offset},
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        ids.extend(p["id"] for p in body["products"])
        offset += limit
        if offset >= body["count"]:
            return ids


def list_custom_link_rows(token):
    r = requests.get(
        f"{BASE}/admin/custom-entities",
        params={"fields": "id,product_id", "limit": 1000},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["custom_entities"]


def product_is_gone(token, product_id):
    r = requests.get(
        f"{BASE}/admin/products/{product_id}",
        params={"with_deleted": "true"},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    return r.status_code == 404


def find_dangling_links(live_product_ids, link_rows):
    return [row for row in link_rows if row["product_id"] not in live_product_ids]


def run():
    token = login()
    live_ids = set(list_live_product_ids(token))
    link_rows = list_custom_link_rows(token)
    candidates = find_dangling_links(live_ids, link_rows)

    confirmed = 0
    for row in candidates:
        if not product_is_gone(token, row["product_id"]):
            continue
        confirmed += 1
        log.warning(
            "Dangling link row %s -> product %s. %s",
            row["id"], row["product_id"],
            "would report" if DRY_RUN else "confirmed, repair runs server-side",
        )
    log.info("Done. %d dangling link row(s) confirmed out of %d candidate(s).", confirmed, len(candidates))


if __name__ == "__main__":
    run()
find-dangling-links.js
/**
 * Find custom module link rows left dangling by a product delete that did not cascade.
 *
 * Medusa v2 module links live in a pivot table outside both linked modules' own
 * schemas, by design, to keep modules isolated. Cascade on delete is opt-in via
 * deleteCascade in the defineLink call, and even then it is only honored when the
 * deletion runs through Medusa's own Link/Remote Link APIs or workflow steps, such
 * as deleteProductsWorkflow, removeRemoteLinkStep, or link.delete. A raw module
 * service delete or a direct SQL delete on the product bypasses that cascade
 * entirely, leaving rows in the custom link table pointing at a prod_ id that no
 * longer exists. This script lists every live product id, lists every product_id
 * your custom link table currently stores, diffs the two sets with a pure function,
 * and cross-checks each candidate with a 404 lookup before reporting it. It only
 * reports by default. Hard-deleting or soft-deleting a confirmed dangling row must
 * run from inside a Medusa server context that can resolve your custom module's
 * own service, so that part is documented in the guide, not executed by this script.
 *
 * Guide: https://www.allanninal.dev/medusa/custom-link-no-cascade-delete/
 */
import { pathToFileURL } from "node:url";

const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function findDanglingLinks(liveProductIds, linkRows) {
  return linkRows.filter((row) => !liveProductIds.has(row.product_id));
}

async function login() {
  const res = await fetch(`${BASE}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function listLiveProductIds(token) {
  const ids = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const url = new URL(`${BASE}/admin/products`);
    url.searchParams.set("fields", "id");
    url.searchParams.set("limit", String(limit));
    url.searchParams.set("offset", String(offset));
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    ids.push(...body.products.map((p) => p.id));
    offset += limit;
    if (offset >= body.count) return ids;
  }
}

async function listCustomLinkRows(token) {
  const url = new URL(`${BASE}/admin/custom-entities`);
  url.searchParams.set("fields", "id,product_id");
  url.searchParams.set("limit", "1000");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.custom_entities;
}

async function productIsGone(token, productId) {
  const url = new URL(`${BASE}/admin/products/${productId}`);
  url.searchParams.set("with_deleted", "true");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  return res.status === 404;
}

export async function run() {
  const token = await login();
  const liveIds = new Set(await listLiveProductIds(token));
  const linkRows = await listCustomLinkRows(token);
  const candidates = findDanglingLinks(liveIds, linkRows);

  let confirmed = 0;
  for (const row of candidates) {
    if (!(await productIsGone(token, row.product_id))) continue;
    confirmed++;
    console.warn(
      `Dangling link row ${row.id} -> product ${row.product_id}. ${DRY_RUN ? "would report" : "confirmed, repair runs server-side"}`
    );
  }
  console.log(`Done. ${confirmed} dangling link row(s) confirmed out of ${candidates.length} candidate(s).`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The diff is the part most worth testing, because it decides which rows get reported as dangling. Because find_dangling_links is pure, taking only the live id set and the link rows, the test needs no network and no Medusa backend. It just feeds in fixture arrays and checks what comes back.

test_custom_dangling_links.py
from find_dangling_links import find_dangling_links


def test_no_dangling_when_all_products_live():
    live = {"prod_1", "prod_2"}
    rows = [{"id": "l1", "product_id": "prod_1"}, {"id": "l2", "product_id": "prod_2"}]
    assert find_dangling_links(live, rows) == []


def test_finds_the_single_dangling_row():
    live = {"prod_1", "prod_2"}
    rows = [{"id": "l1", "product_id": "prod_1"}, {"id": "l2", "product_id": "prod_999"}]
    assert find_dangling_links(live, rows) == [{"id": "l2", "product_id": "prod_999"}]


def test_finds_multiple_dangling_rows():
    live = {"prod_1"}
    rows = [
        {"id": "l1", "product_id": "prod_1"},
        {"id": "l2", "product_id": "prod_404"},
        {"id": "l3", "product_id": "prod_405"},
    ]
    result = find_dangling_links(live, rows)
    assert {r["id"] for r in result} == {"l2", "l3"}


def test_empty_link_rows_returns_empty():
    assert find_dangling_links({"prod_1"}, []) == []


def test_empty_live_set_flags_every_row():
    rows = [{"id": "l1", "product_id": "prod_1"}, {"id": "l2", "product_id": "prod_2"}]
    result = find_dangling_links(set(), rows)
    assert len(result) == 2
dangling-links.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDanglingLinks } from "./find-dangling-links.js";

test("no dangling rows when all products are live", () => {
  const live = new Set(["prod_1", "prod_2"]);
  const rows = [{ id: "l1", product_id: "prod_1" }, { id: "l2", product_id: "prod_2" }];
  assert.deepEqual(findDanglingLinks(live, rows), []);
});

test("finds the single dangling row", () => {
  const live = new Set(["prod_1", "prod_2"]);
  const rows = [{ id: "l1", product_id: "prod_1" }, { id: "l2", product_id: "prod_999" }];
  assert.deepEqual(findDanglingLinks(live, rows), [{ id: "l2", product_id: "prod_999" }]);
});

test("finds multiple dangling rows", () => {
  const live = new Set(["prod_1"]);
  const rows = [
    { id: "l1", product_id: "prod_1" },
    { id: "l2", product_id: "prod_404" },
    { id: "l3", product_id: "prod_405" },
  ];
  const result = findDanglingLinks(live, rows);
  assert.deepEqual(new Set(result.map((r) => r.id)), new Set(["l2", "l3"]));
});

test("empty link rows returns empty", () => {
  assert.deepEqual(findDanglingLinks(new Set(["prod_1"]), []), []);
});

test("empty live set flags every row", () => {
  const rows = [{ id: "l1", product_id: "prod_1" }, { id: "l2", product_id: "prod_2" }];
  assert.equal(findDanglingLinks(new Set(), rows).length, 2);
});

Case studies

Seed reset

A staging reset wiped products and left loyalty points orphaned

A team had a custom loyalty module linked to product with defineLink, but never set deleteCascade. Every time staging reset its catalog with a truncate script, the loyalty link rows survived, each one still pointing at a prod_ id from the previous seed. Reports built off that link kept showing phantom products with points attached.

Adding deleteCascade: true to the link fixed new resets that went through deleteProductsWorkflow. For the existing pile, the reconciliation script diffed the loyalty link's stored ids against the live catalog, confirmed every dangling id with a 404 check, and the team cleared them through the loyalty module's own delete method.

Bulk cleanup script

A catalog purge called the module service directly and skipped the workflow

Ahead of a season change, an engineer wrote a one-off script that called the Product module's own delete method directly on a few thousand discontinued items, since it was the fastest way to clear them. deleteCascade was already set on the custom warranty module's link, but because the delete bypassed deleteProductsWorkflow, the cascade logic never ran, and the warranty link table kept thousands of rows pointing at gone products.

Running the reconciliation script found every dangling row in minutes, confirmed each one was truly gone with the 404 check, and the team cleared the confirmed set through the warranty module's own service from a medusa exec script, keeping every other still-valid warranty link untouched.

What good looks like

After the link definition carries deleteCascade: true and future deletes run through deleteProductsWorkflow or link.delete, new dangling rows stop appearing. Running the reconciliation script on a schedule catches anything that still slips through a raw delete, and every repair stays a deliberate, confirmed step through your own module's declared service, never a blind SQL delete against a table Medusa never promised to protect.

FAQ

Why does deleting a product not remove rows in my custom link table?

Medusa v2 module links live in their own pivot table outside both linked modules, by design, to keep modules isolated. Cascade behavior is opt-in: unless the defineLink call passes deleteCascade true for that side, deleting the product will not touch rows in your custom link table, and they are left pointing at a prod_ id that no longer exists.

I set deleteCascade to true, so why are there still orphaned rows?

deleteCascade is only honored when the deletion runs through Medusa's own Link or Remote Link APIs or workflow steps, such as deleteProductsWorkflow, removeRemoteLinkStep, or link.delete. A raw call to the product module service's delete method, or a direct SQL delete on the product table, bypasses that cascade logic entirely and leaves the link row behind even with deleteCascade configured.

Can I hard-delete an orphaned link row once its product is already gone?

There is no public Medusa API to hard-delete a link table row after its parent product no longer exists, a gap tracked in medusajs/medusa#13315. The safe path is to fix the link definition going forward with deleteCascade, and for existing orphans, confirm the product id is truly gone and then call your custom module's own delete or softDelete method for those ids from inside a medusa exec script, never a raw SQL delete.

Related field notes

Citations

On the problem:

  1. [Bug]: Missing way to hard-delete link table entries. github.com/medusajs/medusa/issues/13315
  2. Link API medusa v2, Discussion #10108. github.com/medusajs/medusa/discussions/10108
  3. Link, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links/link

On the solution:

  1. cascades Method, DML Reference, Medusa Documentation. docs.medusajs.com/resources/references/data-model/model-methods/cascades
  2. Define Module Link, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links
  3. dismissRemoteLinkStep, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/steps/dismissRemoteLinkStep

Stuck on a tricky one?

If you have a problem in Medusa pricing, regions, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a dangling link row for you?

If this caught an orphaned row before it broke a query in production, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa.js field notes