diff --git a/doc/02_Configuration/03_Index_Management.md b/doc/02_Configuration/03_Index_Management.md
index 83741e3d..f8070c6c 100644
--- a/doc/02_Configuration/03_Index_Management.md
+++ b/doc/02_Configuration/03_Index_Management.md
@@ -180,3 +180,17 @@ php bin/console generic-data-index:deployment:reindex
```
This command will update the index structure for all data object classes which were created/updated since the last deployment and reindex all data objects for relevant classes.
+
+### Cleaning Up Unused Indices
+
+To clean up old indices that are not referenced by any alias, use the following command:
+
+```
+php bin/console generic-data-index:cleanup:unused-indices
+```
+
+To preview what would be deleted without performing any changes, run:
+
+```
+php bin/console generic-data-index:cleanup:unused-indices --dry-run
+```
diff --git a/src/Command/CleanupUnusedIndicesCommand.php b/src/Command/CleanupUnusedIndicesCommand.php
new file mode 100644
index 00000000..cbb4162e
--- /dev/null
+++ b/src/Command/CleanupUnusedIndicesCommand.php
@@ -0,0 +1,99 @@
+setName('generic-data-index:cleanup:unused-indices')
+ ->addOption(
+ self::OPTION_DRY_RUN,
+ null,
+ InputOption::VALUE_NONE,
+ 'List unused indices without deleting them.'
+ )
+ ->setDescription(
+ 'Deletes Generic Data Index indices that are not referenced by any alias.'
+ );
+ }
+
+ /**
+ * @throws CommandAlreadyRunningException
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ if (!$this->lock()) {
+ throw new CommandAlreadyRunningException(
+ 'The command is already running in another process.'
+ );
+ }
+
+ try {
+ $dryRun = (bool) $input->getOption(self::OPTION_DRY_RUN);
+ $unusedIndices = $this->unusedIndexCleanupService->cleanupUnusedIndices($dryRun);
+
+ if (empty($unusedIndices)) {
+ $output->writeln('No unused indices found.');
+
+ return self::SUCCESS;
+ }
+
+ $output->writeln('Unused indices:');
+ foreach ($unusedIndices as $indexName) {
+ $output->writeln(sprintf(' - %s', $indexName));
+ }
+
+ if ($dryRun) {
+ $output->writeln(
+ sprintf('Dry run: %d indices would be deleted.', count($unusedIndices))
+ );
+ } else {
+ $output->writeln(
+ sprintf('Deleted %d unused indices.', count($unusedIndices))
+ );
+ }
+ } catch (Exception $e) {
+ $output->writeln('' . $e->getMessage() . '');
+ } finally {
+ $this->release();
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/src/Service/SearchIndex/UnusedIndexCleanupService.php b/src/Service/SearchIndex/UnusedIndexCleanupService.php
new file mode 100644
index 00000000..5755ce9d
--- /dev/null
+++ b/src/Service/SearchIndex/UnusedIndexCleanupService.php
@@ -0,0 +1,120 @@
+getAllManagedIndices();
+ if (empty($allManagedIndices)) {
+ return [];
+ }
+
+ $aliasedIndices = $this->getAliasedIndices();
+ $unusedIndices = array_values(array_diff($allManagedIndices, $aliasedIndices));
+ sort($unusedIndices);
+
+ return $unusedIndices;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function cleanupUnusedIndices(bool $dryRun = false): array
+ {
+ $unusedIndices = $this->findUnusedIndices();
+
+ if ($dryRun) {
+ return $unusedIndices;
+ }
+
+ foreach ($unusedIndices as $indexName) {
+ $this->searchIndexService->deleteIndex($indexName);
+ }
+
+ return $unusedIndices;
+ }
+
+ /**
+ * @return string[]
+ */
+ private function getAllManagedIndices(): array
+ {
+ $indexPrefix = $this->searchIndexConfigService->getIndexPrefix();
+
+ try {
+ $stats = $this->searchIndexService->getStats($indexPrefix . '*');
+ } catch (Exception) {
+ return [];
+ }
+
+ $indices = $stats['indices'] ?? null;
+ if (!is_array($indices)) {
+ return [];
+ }
+
+ $indexNames = array_keys($indices);
+
+ return array_values(array_filter(
+ $indexNames,
+ static fn (string $indexName): bool => str_starts_with($indexName, $indexPrefix)
+ && preg_match(self::INDEX_SUFFIX_PATTERN, $indexName) === 1
+ ));
+ }
+
+ /**
+ * @return string[]
+ */
+ private function getAliasedIndices(): array
+ {
+ $indexPrefix = $this->searchIndexConfigService->getIndexPrefix();
+ $aliases = $this->indexAliasService->getAllAliases();
+
+ $aliasedIndexMap = [];
+ foreach ($aliases as $aliasData) {
+ if (!is_array($aliasData)) {
+ continue;
+ }
+
+ $indexName = $aliasData['index'] ?? null;
+ if (!is_string($indexName) || !str_starts_with($indexName, $indexPrefix)) {
+ continue;
+ }
+
+ $aliasedIndexMap[$indexName] = true;
+ }
+
+ return array_keys($aliasedIndexMap);
+ }
+}
diff --git a/tests/Unit/Service/SearchIndex/UnusedIndexCleanupServiceTest.php b/tests/Unit/Service/SearchIndex/UnusedIndexCleanupServiceTest.php
new file mode 100644
index 00000000..295df8cc
--- /dev/null
+++ b/tests/Unit/Service/SearchIndex/UnusedIndexCleanupServiceTest.php
@@ -0,0 +1,146 @@
+createMock(SearchIndexServiceInterface::class);
+ $searchIndexService
+ ->method('getStats')
+ ->with('pimcore_*')
+ ->willReturn([
+ 'indices' => [
+ 'pimcore_asset-odd' => [],
+ 'pimcore_asset-even' => [],
+ 'pimcore_document-even' => [],
+ 'pimcore_custom' => [],
+ 'other_prefix_asset-odd' => [],
+ ],
+ ])
+ ;
+ $searchIndexService->expects($this->never())->method('deleteIndex');
+
+ $indexAliasService = $this->createMock(IndexAliasServiceInterface::class);
+ $indexAliasService
+ ->method('getAllAliases')
+ ->willReturn([
+ ['alias' => 'pimcore_asset', 'index' => 'pimcore_asset-even'],
+ ['alias' => 'pimcore_document', 'index' => 'pimcore_document-even'],
+ ])
+ ;
+
+ $searchIndexConfigService = $this->createMock(SearchIndexConfigServiceInterface::class);
+ $searchIndexConfigService
+ ->method('getIndexPrefix')
+ ->willReturn('pimcore_')
+ ;
+
+ $service = new UnusedIndexCleanupService(
+ $searchIndexService,
+ $indexAliasService,
+ $searchIndexConfigService
+ );
+
+ $this->assertSame(['pimcore_asset-odd'], $service->findUnusedIndices());
+ }
+
+ public function testDryRunDoesNotDeleteIndices(): void
+ {
+ $searchIndexService = $this->createMock(SearchIndexServiceInterface::class);
+ $searchIndexService
+ ->method('getStats')
+ ->willReturn([
+ 'indices' => [
+ 'pimcore_asset-odd' => [],
+ 'pimcore_asset-even' => [],
+ ],
+ ])
+ ;
+ $searchIndexService->expects($this->never())->method('deleteIndex');
+
+ $indexAliasService = $this->createMock(IndexAliasServiceInterface::class);
+ $indexAliasService
+ ->method('getAllAliases')
+ ->willReturn([
+ ['alias' => 'pimcore_asset', 'index' => 'pimcore_asset-even'],
+ ])
+ ;
+
+ $searchIndexConfigService = $this->createMock(SearchIndexConfigServiceInterface::class);
+ $searchIndexConfigService
+ ->method('getIndexPrefix')
+ ->willReturn('pimcore_')
+ ;
+
+ $service = new UnusedIndexCleanupService(
+ $searchIndexService,
+ $indexAliasService,
+ $searchIndexConfigService
+ );
+
+ $this->assertSame(['pimcore_asset-odd'], $service->cleanupUnusedIndices(true));
+ }
+
+ public function testExecuteDeletesUnusedIndices(): void
+ {
+ $searchIndexService = $this->createMock(SearchIndexServiceInterface::class);
+ $searchIndexService
+ ->method('getStats')
+ ->willReturn([
+ 'indices' => [
+ 'pimcore_asset-odd' => [],
+ 'pimcore_asset-even' => [],
+ ],
+ ])
+ ;
+ $searchIndexService
+ ->expects($this->once())
+ ->method('deleteIndex')
+ ->with('pimcore_asset-odd')
+ ;
+
+ $indexAliasService = $this->createMock(IndexAliasServiceInterface::class);
+ $indexAliasService
+ ->method('getAllAliases')
+ ->willReturn([
+ ['alias' => 'pimcore_asset', 'index' => 'pimcore_asset-even'],
+ ])
+ ;
+
+ $searchIndexConfigService = $this->createMock(SearchIndexConfigServiceInterface::class);
+ $searchIndexConfigService
+ ->method('getIndexPrefix')
+ ->willReturn('pimcore_')
+ ;
+
+ $service = new UnusedIndexCleanupService(
+ $searchIndexService,
+ $indexAliasService,
+ $searchIndexConfigService
+ );
+
+ $this->assertSame(['pimcore_asset-odd'], $service->cleanupUnusedIndices());
+ }
+}