Reconciler Products, Variants and Media

Orphaned media cannot be cleaned up non-interactively

Your media library only grows. Products get re-photographed, variants get replaced, CMS blocks get redesigned, and the old images stay behind on disk forever. Shopware ships a command for exactly this, bin/console media:delete-unused, but it stops and asks you a yes or no question before it will delete anything. Put that command in a cron job or a CI pipeline and it just fails, every single run, because nothing is there to answer the prompt. Here is why Shopware built it this way and a small script that does the same safe cleanup without ever needing a human at the keyboard.

Python and Node.js Admin API Safe by default (dry run)
The short answer

media:delete-unused hard-codes an interactive "Are you sure that you want to delete unused media files? (yes/no)" confirmation, and there is no flag that answers it for you. --no-interaction does not assume yes, it makes Symfony abort the command instead, so cron and CI can never get past the prompt. Run a small Python or Node.js script instead that pages through media over the Admin API, checks each row against every consuming relation the same way Shopware's own UnusedMediaPurger does, keeps only the ones with zero references, older than a grace period, and outside a protected folder, and deletes those with DELETE /api/media/{id}. Full code, tests, and a dry run guard are below.

The problem in plain words

Every Shopware store accumulates media it no longer needs. A product photo gets swapped, a manufacturer logo gets replaced, a CMS block gets redesigned, and the file that used to be attached to something just sits in the media table and on disk, unreferenced and quietly taking up space and storage cost.

Shopware knows this happens, which is why bin/console media:delete-unused exists at all. But the command was built for a human sitting at a terminal. Before it deletes anything, it prints the confirmation question and waits for you to type yes. That is a reasonable safety net for a person, but it is exactly the wrong shape for automation. A cron job has no terminal to type into. A CI pipeline has no one watching the output. Both just see the command hang or exit, and the media pile keeps growing, unattended, forever.

Cron job runs media:delete-unused Asks yes or no are you sure? confirm nothing answers it Command aborts nothing deleted Media pile keeps growing
The command is built to ask a human, and cron or CI has no one to answer, so it aborts and the orphaned files stay on disk.

Why it happens

Deleting media is destructive and permanent, so Shopware built the command to stop and confirm before it touches anything. That is the right instinct for an operator running it by hand, but it becomes a wall once you try to automate it. A few specifics worth knowing:

This is a known limitation, not a bug that slipped through review. Store operators and agencies have filed it against Shopware's own repository more than once, and the community forum has independent reports of the exact same wall. See the citations at the end for the exact threads and docs.

The key insight

The console command's confirmation prompt is not the safety mechanism, it is just a UI detail. The real safety is in how carefully UnusedMediaPurger checks that a media row truly has zero references before it ever reaches the delete step. So the fix is not to find a way past the prompt. It is to reimplement the same reference-checking discipline over the Admin API, where there is no prompt to get stuck on in the first place, and to keep every one of Shopware's own safeguards, a grace period, a check against every consuming relation, and never touching protected folders.

The fix, as a flow

We never call the console command from automation. Instead we page through media rows over the Admin API, and for each one we ask the same question UnusedMediaPurger asks: does any consuming relation still reference this file. Only rows with zero references everywhere, older than the grace period, and outside a protected system folder are added to a delete manifest. Everything else is left exactly where it is.

Scheduled job runs on a timer Page through media sorted by createdAt asc, 500 at a time Check every consuming relation, folder, and age Zero refs, old, unprotected? yes in use, protected, or too recent, skip re-check, then DELETE /media/id
Only media that is unreferenced everywhere, older than the grace period, and outside a protected folder ever reaches the delete manifest, and it is re-checked immediately before each delete.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export GRACE_PERIOD_DAYS="20"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export GRACE_PERIOD_DAYS="20"
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for every search and for the delete call.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Page through media and check every consuming relation

List media rows sorted by createdAt ascending, 500 at a time. For each row, search every relation that can reference it, product media, manufacturer logos, category images, mail template media, document base config media, and CMS page preview media, filtering on the id field with limit: 1 and total-count-mode: 1 so we only need the count, not the rows.

step3.py
ASSOCIATION_CHECKS = {
    "product_media": "mediaId",
    "product_manufacturer": "mediaId",
    "category": "mediaId",
    "mail_template_media": "mediaId",
    "document_base_config": "documentMediaId",
    "cms_page": "previewMediaId",
}

def search_media_page(token, page, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "sort": [{"field": "createdAt", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api_post("/api/search/media", token, body)

def count_association(token, relation, field, media_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": field, "value": media_id}],
        "total-count-mode": 1,
    }
    data = api_post(f"/api/search/{relation}", token, body)
    return data.get("total", len(data.get("data", [])))

def association_counts_for(token, media_id):
    return {
        relation: count_association(token, relation, field, media_id)
        for relation, field in ASSOCIATION_CHECKS.items()
    }
step3.js
const ASSOCIATION_CHECKS = {
  product_media: "mediaId",
  product_manufacturer: "mediaId",
  category: "mediaId",
  mail_template_media: "mediaId",
  document_base_config: "documentMediaId",
  cms_page: "previewMediaId",
};

async function searchMediaPage(token, page, limit = 500) {
  const body = {
    page,
    limit,
    sort: [{ field: "createdAt", order: "ASC" }],
    "total-count-mode": 1,
  };
  return apiPost("/api/search/media", token, body);
}

async function countAssociation(token, relation, field, mediaId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field, value: mediaId }],
    "total-count-mode": 1,
  };
  const data = await apiPost(`/api/search/${relation}`, token, body);
  return typeof data.total === "number" ? data.total : (data.data || []).length;
}

async function associationCountsFor(token, mediaId) {
  const entries = await Promise.all(
    Object.entries(ASSOCIATION_CHECKS).map(async ([relation, field]) => [relation, await countAssociation(token, relation, field, mediaId)])
  );
  return Object.fromEntries(entries);
}
4

Decide, with one pure function

Keep the classification separate from any network call. Given a media row, its pre-fetched association counts, the set of protected folder ids, and the current time, return one of four labels: protected-folder, in-use, too-recent, or orphan. Nothing here talks to the network, so it is trivial to test with fixed clocks and fixture rows.

decide.py
from datetime import datetime

def _parse(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))

def classify_orphan_media(media, association_counts, protected_folder_ids, now, grace_period_days=20):
    if media.get("mediaFolderId") in protected_folder_ids:
        return "protected-folder"

    if any(count > 0 for count in association_counts.values()):
        return "in-use"

    created = media.get("createdAt")
    if created and (now - _parse(created)).total_seconds() < grace_period_days * 86400:
        return "too-recent"

    return "orphan"
decide.js
export function classifyOrphanMedia(media, associationCounts, protectedFolderIds, now, gracePeriodDays = 20) {
  if (protectedFolderIds.has(media.mediaFolderId)) return "protected-folder";

  if (Object.values(associationCounts).some((count) => count > 0)) return "in-use";

  if (media.createdAt) {
    const ageMs = now.getTime() - new Date(media.createdAt).getTime();
    if (ageMs < gracePeriodDays * 86400000) return "too-recent";
  }

  return "orphan";
}
5

Delete only the confirmed orphans, re-checked right before the delete

For every media row classified orphan, re-run the same association searches immediately before deleting it. A live store can attach a brand new reference between the initial scan and the delete, and skipping this re-check is how a safe cleanup script turns into a data loss incident. Only after the re-check comes back clean do we call DELETE /api/media/{id}, which cascades the DB row and the physical file removal through Shopware's own MediaService.

apply.py
def api_delete(path, token):
    r = requests.delete(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()

def delete_media(token, media_id):
    api_delete(f"/api/media/{media_id}", token)

def delete_if_still_orphaned(token, media_id):
    recheck = association_counts_for(token, media_id)
    if any(count > 0 for count in recheck.values()):
        return False  # a new reference appeared since the scan, skip it
    delete_media(token, media_id)
    return True
apply.js
async function apiDelete(path, token) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
}

async function deleteMedia(token, mediaId) {
  await apiDelete(`/api/media/${mediaId}`, token);
}

async function deleteIfStillOrphaned(token, mediaId) {
  const recheck = await associationCountsFor(token, mediaId);
  if (Object.values(recheck).some((count) => count > 0)) {
    return false; // a new reference appeared since the scan, skip it
  }
  await deleteMedia(token, mediaId);
  return true;
}
6

Wire it together with a dry run guard and small batches

The loop ties every piece together. Notice the dry run guard and the batching. On the first few runs, leave DRY_RUN on so the script only logs the manifest of files it would delete. Once you trust the list, switch it off. Delete in small chunks, for example 50 at a time, with a short pause between batches, mirroring how Shopware's own UnusedMediaPurger paces itself so it never saturates the message queue or the filesystem.

Run it safe

Always start with DRY_RUN=true, and never bulk-delete an entire folder or skip the per-id re-check right before each delete. A media row that looked orphaned during the scan can gain a reference minutes later on a busy store, and the re-check is what keeps this from turning into an accidental data loss run.

The full code

Here is the complete script in one file for each language. It authenticates, pages through media, classifies each row with the pure function, and deletes only the confirmed orphans in small paced batches, respecting DRY_RUN and re-checking references immediately before every delete.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

purge_orphaned_media.py
"""Clean up Shopware 6 orphaned media non-interactively, without the
built-in bin/console media:delete-unused confirmation prompt.

media:delete-unused hard-codes an interactive "Are you sure...? (yes/no)"
question with no auto-confirm flag, so it cannot be wired into cron or CI.
--no-interaction just makes Symfony abort the command instead of assuming
yes. This script reimplements the same detection over the Admin API:

A media row is only classified 'orphan' when it has zero rows in every
consuming relation (product_media, product_manufacturer, category,
mail_template_media, cms_page, and so on), it is older than a grace period
(default 20 days, matching Shopware's own default), and it does not live in
a protected system folder. Everything else is left alone.

DRY_RUN guards the delete. Even in write mode, every association search is
re-run immediately before each DELETE, because new references can appear
between the initial scan and the delete on a live store. Batches are small
and paced with a short sleep, mirroring Shopware's own UnusedMediaPurger.
"""
import os
import time
import logging
import requests
from datetime import datetime, timezone

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

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
GRACE_PERIOD_DAYS = int(os.environ.get("GRACE_PERIOD_DAYS", "20"))
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "50"))
BATCH_SLEEP_SECONDS = float(os.environ.get("BATCH_SLEEP_SECONDS", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Relation -> the field on that entity that points back at media.id.
ASSOCIATION_CHECKS = {
    "product_media": "mediaId",
    "product_manufacturer": "mediaId",
    "category": "mediaId",
    "mail_template_media": "mediaId",
    "document_base_config": "documentMediaId",
    "cms_page": "previewMediaId",
}


def _parse(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))


def classify_orphan_media(media, association_counts, protected_folder_ids, now, grace_period_days=20):
    """Pure decision. No I/O. Takes plain data and returns a label.

    media: {id, createdAt, mediaFolderId}
    association_counts: {relationName: totalCount}, pre-fetched by the caller
    protected_folder_ids: set of media-default-folder ids to never touch
    now: a datetime, passed in so the function is fully deterministic
    """
    if media.get("mediaFolderId") in protected_folder_ids:
        return "protected-folder"

    if any(count > 0 for count in association_counts.values()):
        return "in-use"

    created = media.get("createdAt")
    if created and (now - _parse(created)).total_seconds() < grace_period_days * 86400:
        return "too-recent"

    return "orphan"


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def api_delete(path, token):
    r = requests.delete(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()


def search_protected_folder_ids(token):
    data = api_post("/api/search/media-default-folder", token, {"page": 1, "limit": 500, "total-count-mode": 1})
    return {row["id"] for row in data.get("data", [])}


def search_media_page(token, page, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "sort": [{"field": "createdAt", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api_post("/api/search/media", token, body)


def count_association(token, relation, field, media_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": field, "value": media_id}],
        "total-count-mode": 1,
    }
    data = api_post(f"/api/search/{relation}", token, body)
    return data.get("total", len(data.get("data", [])))


def association_counts_for(token, media_id):
    return {
        relation: count_association(token, relation, field, media_id)
        for relation, field in ASSOCIATION_CHECKS.items()
    }


def delete_media(token, media_id):
    api_delete(f"/api/media/{media_id}", token)


def run():
    token = get_token()
    protected_folder_ids = search_protected_folder_ids(token)
    now = datetime.now(timezone.utc)

    manifest = []
    page = 1
    while True:
        result = search_media_page(token, page)
        rows = result.get("data", [])
        if not rows:
            break
        for media in rows:
            counts = association_counts_for(token, media["id"])
            label = classify_orphan_media(media, counts, protected_folder_ids, now, GRACE_PERIOD_DAYS)
            if label == "orphan":
                manifest.append({
                    "id": media["id"],
                    "fileName": media.get("fileName"),
                    "fileExtension": media.get("fileExtension"),
                    "createdAt": media.get("createdAt"),
                    "uploadedAt": media.get("uploadedAt"),
                })
        if not result.get("data") or len(rows) < 500:
            break
        page += 1

    log.info("Found %d orphaned media file(s) older than the %d day grace period.", len(manifest), GRACE_PERIOD_DAYS)

    deleted = 0
    for i in range(0, len(manifest), BATCH_SIZE):
        batch = manifest[i:i + BATCH_SIZE]
        for entry in batch:
            log.info("Media %s (%s.%s) orphaned since %s. %s",
                      entry["id"], entry["fileName"], entry["fileExtension"], entry["createdAt"],
                      "would delete" if DRY_RUN else "deleting")
            if not DRY_RUN:
                # Re-check associations right before deleting: a live store
                # can attach a new reference between the scan and the delete.
                recheck = association_counts_for(token, entry["id"])
                if any(count > 0 for count in recheck.values()):
                    log.info("Media %s gained a reference since the scan, skipping.", entry["id"])
                    continue
                delete_media(token, entry["id"])
            deleted += 1
        if not DRY_RUN and i + BATCH_SIZE < len(manifest):
            time.sleep(BATCH_SLEEP_SECONDS)

    log.info("Done. %d media file(s) %s.", deleted, "to delete" if DRY_RUN else "deleted")


if __name__ == "__main__":
    run()
purge-orphaned-media.js
/**
 * Clean up Shopware 6 orphaned media non-interactively, without the
 * built-in bin/console media:delete-unused confirmation prompt.
 *
 * media:delete-unused hard-codes an interactive "Are you sure...? (yes/no)"
 * question with no auto-confirm flag, so it cannot be wired into cron or CI.
 * --no-interaction just makes Symfony abort the command instead of assuming
 * yes. This script reimplements the same detection over the Admin API:
 *
 * A media row is only classified 'orphan' when it has zero rows in every
 * consuming relation (product_media, product_manufacturer, category,
 * mail_template_media, cms_page, and so on), it is older than a grace period
 * (default 20 days, matching Shopware's own default), and it does not live
 * in a protected system folder. Everything else is left alone.
 *
 * DRY_RUN guards the delete. Even in write mode, every association search is
 * re-run immediately before each DELETE, because new references can appear
 * between the initial scan and the delete on a live store. Batches are small
 * and paced with a short sleep, mirroring Shopware's own UnusedMediaPurger.
 *
 * Guide: https://www.allanninal.dev/shopware/orphaned-media-cleanup-non-interactive/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const GRACE_PERIOD_DAYS = Number(process.env.GRACE_PERIOD_DAYS || 20);
const BATCH_SIZE = Number(process.env.BATCH_SIZE || 50);
const BATCH_SLEEP_MS = Number(process.env.BATCH_SLEEP_SECONDS || 1) * 1000;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Relation -> the field on that entity that points back at media.id.
const ASSOCIATION_CHECKS = {
  product_media: "mediaId",
  product_manufacturer: "mediaId",
  category: "mediaId",
  mail_template_media: "mediaId",
  document_base_config: "documentMediaId",
  cms_page: "previewMediaId",
};

export function classifyOrphanMedia(media, associationCounts, protectedFolderIds, now, gracePeriodDays = 20) {
  if (protectedFolderIds.has(media.mediaFolderId)) return "protected-folder";

  if (Object.values(associationCounts).some((count) => count > 0)) return "in-use";

  if (media.createdAt) {
    const ageMs = now.getTime() - new Date(media.createdAt).getTime();
    if (ageMs < gracePeriodDays * 86400000) return "too-recent";
  }

  return "orphan";
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function apiDelete(path, token) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
}

async function searchProtectedFolderIds(token) {
  const data = await apiPost("/api/search/media-default-folder", token, { page: 1, limit: 500, "total-count-mode": 1 });
  return new Set((data.data || []).map((row) => row.id));
}

async function searchMediaPage(token, page, limit = 500) {
  const body = {
    page,
    limit,
    sort: [{ field: "createdAt", order: "ASC" }],
    "total-count-mode": 1,
  };
  return apiPost("/api/search/media", token, body);
}

async function countAssociation(token, relation, field, mediaId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field, value: mediaId }],
    "total-count-mode": 1,
  };
  const data = await apiPost(`/api/search/${relation}`, token, body);
  return typeof data.total === "number" ? data.total : (data.data || []).length;
}

async function associationCountsFor(token, mediaId) {
  const entries = await Promise.all(
    Object.entries(ASSOCIATION_CHECKS).map(async ([relation, field]) => [relation, await countAssociation(token, relation, field, mediaId)])
  );
  return Object.fromEntries(entries);
}

async function deleteMedia(token, mediaId) {
  await apiDelete(`/api/media/${mediaId}`, token);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function run() {
  const token = await getToken();
  const protectedFolderIds = await searchProtectedFolderIds(token);
  const now = new Date();

  const manifest = [];
  let page = 1;
  while (true) {
    const result = await searchMediaPage(token, page);
    const rows = result.data || [];
    if (!rows.length) break;
    for (const media of rows) {
      const counts = await associationCountsFor(token, media.id);
      const label = classifyOrphanMedia(media, counts, protectedFolderIds, now, GRACE_PERIOD_DAYS);
      if (label === "orphan") {
        manifest.push({
          id: media.id,
          fileName: media.fileName,
          fileExtension: media.fileExtension,
          createdAt: media.createdAt,
          uploadedAt: media.uploadedAt,
        });
      }
    }
    if (rows.length < 500) break;
    page++;
  }

  console.log(`Found ${manifest.length} orphaned media file(s) older than the ${GRACE_PERIOD_DAYS} day grace period.`);

  let deleted = 0;
  for (let i = 0; i < manifest.length; i += BATCH_SIZE) {
    const batch = manifest.slice(i, i + BATCH_SIZE);
    for (const entry of batch) {
      console.log(`Media ${entry.id} (${entry.fileName}.${entry.fileExtension}) orphaned since ${entry.createdAt}. ${DRY_RUN ? "would delete" : "deleting"}`);
      if (!DRY_RUN) {
        // Re-check associations right before deleting: a live store can
        // attach a new reference between the scan and the delete.
        const recheck = await associationCountsFor(token, entry.id);
        if (Object.values(recheck).some((count) => count > 0)) {
          console.log(`Media ${entry.id} gained a reference since the scan, skipping.`);
          continue;
        }
        await deleteMedia(token, entry.id);
      }
      deleted++;
    }
    if (!DRY_RUN && i + BATCH_SIZE < manifest.length) {
      await sleep(BATCH_SLEEP_MS);
    }
  }

  console.log(`Done. ${deleted} media file(s) ${DRY_RUN ? "to delete" : "deleted"}.`);
}

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

Add a test

The classification rule is the part most worth testing, because it decides which media files actually get deleted. Because classify_orphan_media is pure and takes only plain data plus an injected clock, the test needs no network, no Shopware store, and no real media files.

test_orphaned_classification.py
from datetime import datetime, timezone, timedelta

from purge_orphaned_media import classify_orphan_media

NOW = datetime(2026, 7, 10, tzinfo=timezone.utc)
PROTECTED = {"folder-system-1"}


def media(**over):
    base = {
        "id": "media-1",
        "createdAt": (NOW - timedelta(days=30)).isoformat(),
        "mediaFolderId": "folder-user-uploads",
    }
    base.update(over)
    return base


def counts(**over):
    base = {"product_media": 0, "category": 0, "cms_page": 0}
    base.update(over)
    return base


def test_orphan_when_unreferenced_old_and_unprotected():
    result = classify_orphan_media(media(), counts(), PROTECTED, NOW, 20)
    assert result == "orphan"


def test_protected_folder_wins_even_if_unreferenced_and_old():
    result = classify_orphan_media(
        media(mediaFolderId="folder-system-1"), counts(), PROTECTED, NOW, 20
    )
    assert result == "protected-folder"


def test_in_use_when_any_association_count_is_positive():
    result = classify_orphan_media(media(), counts(product_media=1), PROTECTED, NOW, 20)
    assert result == "in-use"


def test_too_recent_when_within_grace_period():
    recent = media(createdAt=(NOW - timedelta(days=5)).isoformat())
    result = classify_orphan_media(recent, counts(), PROTECTED, NOW, 20)
    assert result == "too-recent"


def test_exactly_at_grace_period_boundary_is_orphan():
    boundary = media(createdAt=(NOW - timedelta(days=20)).isoformat())
    result = classify_orphan_media(boundary, counts(), PROTECTED, NOW, 20)
    assert result == "orphan"
orphaned-media.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrphanMedia } from "./purge-orphaned-media.js";

const NOW = new Date("2026-07-10T00:00:00Z");
const PROTECTED = new Set(["folder-system-1"]);

const media = (over = {}) => ({
  id: "media-1",
  createdAt: new Date(NOW.getTime() - 30 * 86400000).toISOString(),
  mediaFolderId: "folder-user-uploads",
  ...over,
});

const counts = (over = {}) => ({ product_media: 0, category: 0, cms_page: 0, ...over });

test("orphan when unreferenced, old, and unprotected", () => {
  assert.equal(classifyOrphanMedia(media(), counts(), PROTECTED, NOW, 20), "orphan");
});

test("protected folder wins even if unreferenced and old", () => {
  const m = media({ mediaFolderId: "folder-system-1" });
  assert.equal(classifyOrphanMedia(m, counts(), PROTECTED, NOW, 20), "protected-folder");
});

test("in-use when any association count is positive", () => {
  assert.equal(classifyOrphanMedia(media(), counts({ product_media: 1 }), PROTECTED, NOW, 20), "in-use");
});

test("too-recent when within grace period", () => {
  const recent = media({ createdAt: new Date(NOW.getTime() - 5 * 86400000).toISOString() });
  assert.equal(classifyOrphanMedia(recent, counts(), PROTECTED, NOW, 20), "too-recent");
});

test("exactly at grace period boundary is orphan", () => {
  const boundary = media({ createdAt: new Date(NOW.getTime() - 20 * 86400000).toISOString() });
  assert.equal(classifyOrphanMedia(boundary, counts(), PROTECTED, NOW, 20), "orphan");
});

Case studies

Fashion retailer

Storage bill climbing every season

A fashion store reshoots its catalog every season, and every reshoot leaves the previous season's product photos behind in media, unreferenced but never removed. An agency finally tried to schedule media:delete-unused in cron to clean it up automatically, and every run just failed silently on the confirmation prompt, so nothing was ever deleted and the storage bill kept climbing.

Switching to the Admin API script gave them a weekly job that only deletes files with zero references across every consuming relation, older than a 20 day grace period. The first run in dry mode surfaced thousands of leftover season photos, and turning off DRY_RUN let the job clear the backlog and keep it clear going forward, with no one typing yes into a terminal.

Multi-brand marketplace

CI pipeline that could never finish the cleanup step

A marketplace running several storefronts wanted a media cleanup step in their deployment pipeline, right after a content migration that always left stale manufacturer logos behind. They added media:delete-unused --no-interaction to the pipeline expecting it to just work, and instead every pipeline run failed at that step because the flag aborts the command rather than answering the prompt.

They replaced the pipeline step with the script here, gating it on DRY_RUN=false only in a dedicated post-migration job. Because the re-check step re-verifies every association right before each delete, a logo that got reattached to a new manufacturer mid-migration was correctly skipped instead of deleted, and the pipeline finally completed the cleanup step without human intervention.

What good looks like

After this runs on a schedule, orphaned media stops piling up without anyone ever touching a terminal prompt. Every deletion still passes through the same reference checks Shopware's own command relies on, protected folders and recently uploaded files are never touched, and the re-check right before each delete means a media file that gains a new reference at the last second survives. The only thing that changed is that the cleanup finally runs unattended, the way a cron job or CI pipeline needs it to.

FAQ

Why can't I run bin/console media:delete-unused in a cron job?

The command hard-codes an interactive confirmation question before it deletes anything, and it has no flag that answers yes on your behalf. Passing --no-interaction does not skip the question with an assumed yes, it makes Symfony abort the command instead, so a cron job or CI pipeline running it non-interactively fails every time rather than cleaning anything up.

How does Shopware decide a media file is unused?

Shopware's UnusedMediaPurger checks a media row against every entity that can reference it, such as product_media, product_manufacturer, category, cms pages and blocks, mail templates, and documents, using an event-driven UnusedMediaSearchEvent that plugins can subscribe to and veto. A media row only counts as unused when none of those relations reference it at all.

Is it safe to auto-delete orphaned media on a schedule?

Yes, as long as you keep the same safeguards Shopware's own command uses: only delete a media row when every consuming relation truly has zero references, skip anything younger than a grace period so files mid-upload are never touched, never touch protected system folders, and re-check the same association counts immediately before each delete since new references can appear between the scan and the delete on a live store.

Related field notes

Citations

On the problem:

  1. media:delete-unused can't be run in non interactive environments, shopware/shopware Issue #8507. github.com/shopware/shopware/issues/8507
  2. CLI: Command "bin/console media:delete-unused" leads to an error, shopware/shopware Issue #5298. github.com/shopware/shopware/issues/5298
  3. Shopware Community Forum: CLI Befehl media:delete-unused Problematik. forum.shopware.com/t/cli-befehl-media-delete-unused-problematik/92399

On the solution:

  1. Shopware Admin API: Media Management. shopware.stoplight.io/docs/admin-api/c042ae0cd330f-media-management
  2. Shopware Developer Documentation: Commands Reference. developer.shopware.com/docs/resources/references/core-reference/commands-reference.html
  3. Shopware Developer Documentation: Prevent Deletion of Media Files Referenced in Plugins. developer.shopware.com/docs/guides/plugins/plugins/content/media/prevent-deletion-of-media-files-referenced-in-your-plugins.html

Stuck on a tricky one?

If you have a problem in Shopware products, variants, media, or automation 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 clear your media library?

If this saved you a failed cron job or a storage bill that would not stop climbing, 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 Shopware field notes