Wie werde ich bloß die alten Bilder los? Jeder, der sich etwas länger mit WordPress beschäftigt, weiß, im Ordner wp_uploads sammeln sich mit der Zeit jede Menge Bilddateien an. Nach Theme-Wechseln, Plugin-Experimenten oder dem Löschen von Beiträgen bleiben im Upload-Verzeichnis zahlreiche Bilddateien zurück, die von WordPress gar nicht mehr verwendet werden.
Gerade bei älteren Installationen können da unter Umständen mehr als ein Gigabyte unnötig belegter Speicherplatz zusammenkommen. Um diese Dateien aufzuspüren, habe ich ein kleines PHP-Skript entwickelt, das direkt auf der Kommandozeile funktioniert. Dabei greift es auf die WordPress-API zurück und vergleicht den tatsächlichen Inhalt der Mediathek mit den Dateien im Upload-Verzeichnis.
Was macht das Skript?
Das Skript arbeitet in vier Schritten:
1. WordPress initialisieren
Zunächst überprüft es, ob das Skript auf der Kommandozeile (CLI) gestartet wurde und sich im Root-Verzeichnis der WordPress-Installation befindet. Anschließend lädt es über `wp-load.php` die komplette WordPress-Umgebung. Dadurch können sämtliche WordPress-Funktionen genutzt werden, ohne dass Änderungen an der Website oder im Backend erforderlich sind.
2. Alle bekannten Bilddateien ermitteln
Im nächsten Schritt liest das Skript sämtliche Bildanhänge aus der WordPress-Datenbank aus. Dabei werden nicht nur die Originalbilder berücksichtigt, sondern auch alle von WordPress erzeugten Bildgrößen (Thumbnails), die in den Metadaten eines Bildes hinterlegt sind. So entsteht eine vollständige Liste aller Bilddateien, die WordPress tatsächlich kennt und verwendet.
3. Upload-Verzeichnis scannen
Anschließend durchsucht das Skript rekursiv das komplette Upload-Verzeichnis. Berücksichtigt werden ausschließlich Bilddateien mit den Endungen:
- JPG / JPEG
- PNG
- GIF
- WebP
- AVIF
Plugin-Dateien, Fonts, Logdateien oder Cache-Dateien bleiben außen vor, um die Installation nicht zu gefährden.
4. Vergleich durchführen
Jetzt werden beide Listen miteinander verglichen. Alle Bilddateien, die zwar im Upload-Verzeichnis vorhanden sind, aber in der WordPress-Datenbank nicht mehr referenziert werden, gelten als potenziell verwaist.
Am Ende gibt das Skript eine Zusammenfassung aus:
- Anzahl der Bildanhänge in WordPress
- Anzahl der bekannten Bilddateien
- Anzahl aller Bilddateien im Upload-Verzeichnis
- Anzahl der nicht referenzierten Dateien
- möglicher Speichergewinn
Drei Betriebsmodi
Das Skript kennt drei verschiedene Betriebsarten.
Analyse
php media-cleanup-analyse.php

Mit diesem Aufruf erfolgt ausschließlich eine Analyse. Es werden keinerlei Dateien verändert. Aber das ermöglicht Euch zu beurteilen, wie viel Datenmüll Ihr in Eurer WordPress-Instanz mitschleppt.
Dry-Run
php media-cleanup-analyse.php --quarantine --dry-run

Jetzt zeigt das Skript zusätzlich jede einzelne gefundene Datei an, die verschoben würde. Tatsächlich verändert wird jedoch nichts. Bitte prüft die Liste vor dem nächsten Schritt auf Plausibilität. Eine Gefahr ist beispielsweise, wenn ihr in der Vergangenheit Bilder direkt per html-Tag in Eure Beiträge eingefügt habt. Das kann das Skript nicht erkennen.
Quarantäne
php media-cleanup-analyse.php --quarantine

Erst in diesem Modus werden die gefundenen Dateien verschoben.
Wichtig dabei: Die Dateien werden nicht gelöscht, sondern in einen automatisch angelegten Quarantäne-Ordner innerhalb des Upload-Verzeichnisses verschoben. Dadurch können die als überflüssig identifizierten Bilder bei Bedarf jederzeit wieder zurückkopiert werden.
Warum Quarantäne statt Löschen?
Gerade bei älteren WordPress-Installationen ist nie vollständig auszuschließen, dass Plugins oder Themes auf Dateien zugreifen, die nicht mehr in der Datenbank registriert sind.
Aus diesem Grund löscht das Skript bewusst nichts endgültig.
Die Quarantäne bietet die Möglichkeit, die Website zunächst einige Tage oder Wochen zu beobachten. Erst wenn sicher ist, dass keine Bilder fehlen, kann der Quarantäne-Ordner endgültig entfernt werden.
Grenzen des Skripts
Das Skript erkennt ausschließlich Bilddateien, die nicht mehr in der WordPress-Datenbank referenziert werden.
Es erkennt dagegen nicht:
- Bilder, die zwar in der Mediathek vorhanden sind, aber auf keiner Seite mehr verwendet werden.
- Alte Bildgrößen, die zwar in den Metadaten eingetragen sind, von einem heutigen Theme aber nicht mehr benötigt werden. Gerade da steckt zusätzliches Potenzial. Aber mir fehlte bisher die Zeit, um mich damit zu beschäftigen.
- Manuell hochgeladene Bilder außerhalb der Mediathek.
Warnhinweis
Der Einsatz dieses Skripts erfolgt ausdrücklich auf eigene Gefahr.
Obwohl die Dateien nicht gelöscht, sondern lediglich in einen Quarantäne-Ordner verschoben werden, sollte das Skript ausschließlich auf einer vollständig gesicherten WordPress-Installation eingesetzt werden. Vor dem ersten Einsatz empfiehlt sich unbedingt ein aktuelles Backup von Dateien und Datenbank.

Ich empfehle außerdem, zunächst immer die Analyse und anschließend den Dry-Run auszuführen. Erst wenn die Ergebnisse plausibel erscheinen, sollte der Quarantäne-Modus verwendet werden.
Denn trotz sorgfältiger Entwicklung kann nicht ausgeschlossen werden, dass individuelle Themes oder Plugins Dateien verwenden, die von WordPress selbst nicht mehr referenziert werden. In solchen Fällen ist eine manuelle Prüfung sinnvoll.
Code des PHP-Skripts:
<?php
/**
* WordPress Media Cleanup Analyse
*
* @author Tom Schwede <info@autonatives.de>
* @copyright 2026 Tom Schwede
* @license https://opensource.org/licenses/MIT MIT License
*/
/**
*
* Usage:
* php media-cleanup-analyse.php
* php media-cleanup-analyse.php --quarantine --dry-run
* php media-cleanup-analyse.php --quarantine
*/
/*
The MIT License (MIT)
Copyright (c) 2026 Dein Name
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
HAFTUNGSAUSSCHLUSS:
Diese Software wird vom Urheberrechtsinhaber "wie besehen" (as is) zur Verfügung
gestellt. Jegliche ausdrückliche oder implizite Haftung, einschließlich, aber
nicht beschränkt auf die implizite Haftung für Marktgängigkeit und Eignung für
einen bestimmten Zweck, wird ausgeschlossen.
In keinem Fall haftet der Urheber für direkte, indirekte, zufällige, besondere,
beispielhafte oder Folgeschäden (einschließlich, aber nicht beschränkt auf die
Beschaffung von Ersatzgütern oder -dienstleistungen, Datenverlust, entgangenen
Gewinn oder Betriebsunterbrechung), unabhängig von der Ursache und der Haftungstheorie
(ob aus Vertrag, Gefährdungshaftung oder unerlaubter Handlung, einschließlich
Fahrlässigkeit), die in irgendeiner Weise aus der Nutzung dieser Software entstehen,
selbst wenn auf die Möglichkeit solcher Schäden hingewiesen wurde.
*/
if (php_sapi_name() !== 'cli') {
die("Dieses Skript darf nur auf der Kommandozeile laufen.\n");
}
$wpLoad = __DIR__ . '/wp-load.php';
if (!file_exists($wpLoad)) {
die("Fehler: wp-load.php nicht gefunden. Bitte im WordPress-Root ausführen.\n");
}
require_once $wpLoad;
$uploads = wp_get_upload_dir();
$uploadsBaseDir = rtrim($uploads['basedir'], '/');
if (!is_dir($uploadsBaseDir)) {
die("Fehler: Upload-Verzeichnis nicht gefunden: {$uploadsBaseDir}\n");
}
$doQuarantine = in_array('--quarantine', $argv, true);
$dryRun = in_array('--dry-run', $argv, true);
$quarantineDir = $uploadsBaseDir . '/_media_cleanup_quarantine_' . date('Ymd_His');
echo "WordPress Media Cleanup Analyse\n";
echo "Uploads: {$uploadsBaseDir}\n";
echo $dryRun ? "Modus: DRY-RUN\n" : "Modus: ECHT\n";
echo "\n";
/**
* 1. Dateien ermitteln, die WordPress laut Datenbank kennt.
*/
$knownFiles = [];
$attachments = get_posts([
'post_type' => 'attachment',
'post_mime_type' => 'image',
'post_status' => 'any',
'numberposts' => -1,
'fields' => 'ids',
]);
foreach ($attachments as $attachmentId) {
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if ($file) {
$knownFiles[$file] = true;
}
$meta = wp_get_attachment_metadata($attachmentId);
if (!empty($meta['file'])) {
$knownFiles[$meta['file']] = true;
$dir = dirname($meta['file']);
if (!empty($meta['sizes']) && is_array($meta['sizes'])) {
foreach ($meta['sizes'] as $size) {
if (!empty($size['file'])) {
$knownFiles[$dir . '/' . $size['file']] = true;
}
}
}
}
}
/**
* 2. Alle Dateien im Upload-Verzeichnis scannen.
*/
$allFiles = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($uploadsBaseDir, FilesystemIterator::SKIP_DOTS)
);
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) {
continue;
}
$absolutePath = $fileInfo->getPathname();
if (strpos($absolutePath, '/_media_cleanup_quarantine_') !== false) {
continue;
}
$extension = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
continue;
}
$relativePath = ltrim(str_replace($uploadsBaseDir, '', $absolutePath), '/');
$allFiles[$relativePath] = [
'absolute' => $absolutePath,
'size' => $fileInfo->getSize(),
];
}
/**
* 3. Differenz bilden: Datei existiert, ist aber nicht in WordPress bekannt.
*/
$unusedFiles = [];
$totalUnusedSize = 0;
foreach ($allFiles as $relativePath => $info) {
if (!isset($knownFiles[$relativePath])) {
$unusedFiles[$relativePath] = $info;
$totalUnusedSize += $info['size'];
}
}
function human_filesize($bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
echo "Anhänge in WordPress: " . count($attachments) . "\n";
echo "Bekannte Bilddateien: " . count($knownFiles) . "\n";
echo "Dateien im Upload-Ordner: " . count($allFiles) . "\n";
echo "Nicht referenzierte Dateien:" . count($unusedFiles) . "\n";
echo "Möglicher Platzgewinn: " . human_filesize($totalUnusedSize) . "\n";
echo "\n";
if (!$doQuarantine) {
echo "Es wurde nichts verschoben.\n";
echo "Zum Testen:\n";
echo "php media-cleanup-analyse.php --quarantine --dry-run\n";
exit;
}
/**
* 4. Dateien in Quarantäne verschieben.
*/
if (!$dryRun && !is_dir($quarantineDir)) {
mkdir($quarantineDir, 0755, true);
}
$moved = 0;
$failed = 0;
foreach ($unusedFiles as $relativePath => $info) {
$source = $info['absolute'];
$target = $quarantineDir . '/' . $relativePath;
echo ($dryRun ? "[DRY-RUN] " : "") . $relativePath . " (" . human_filesize($info['size']) . ")\n";
if ($dryRun) {
continue;
}
$targetDir = dirname($target);
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
if (@rename($source, $target)) {
$moved++;
} else {
$failed++;
}
}
echo "\n";
echo $dryRun ? "Dry-Run abgeschlossen.\n" : "Quarantäne abgeschlossen.\n";
if (!$dryRun) {
echo "Verschoben: {$moved}\n";
echo "Fehler: {$failed}\n";
echo "Ordner: {$quarantineDir}\n";
}