Efficient Media Migration in Sitecore Using PowerShell and Packages

Transferring large media libraries between Sitecore instances using PowerShell and content packages

June 18, 2025

By Roberto Barbedo

Migrating Media Items with a Two-Script Process

Migrating large numbers of media items between Sitecore instances can be challenging due to the size of blobs and the limitations of packages when handling large binary data. This blog post outlines a two-script process to transfer media items efficiently, leveraging Sitecore PowerShell Extensions (SPE) and publicly available media URLs.

Instead of exporting full binary blobs into the package (which can lead to huge files and timeouts), this method separates metadata and structure from the binary blob and transfers them independently:

  • Script 1 runs on the origin Sitecore instance (the source).
  • Script 2 runs on the destination Sitecore instance, after installing the metadata-only package.

Preparation and Safety First

Before running the scripts:

  1. Test in a non-production folder like /media library/test-folder.
  2. Optionally set $removeBackupAtTheEnd = $false to inspect backup content before deletion.
  3. Ensure that all source media items are published and available online.
  4. Make sure both instances have SPE (Sitecore PowerShell Extensions) installed.

Script 1 – Run on the Origin (Source) Sitecore Instance

This script:

  1. Copies all media items to a backup folder.
  2. Removes the Blob field from the original items.
  3. Generates and downloads a content package (without the binary blobs).
  4. Restores the original blobs to the source instance.
  5. Optionally removes the backup folder.
# CONFIGURATION
$mediaLibraryPath = "master:/sitecore/media library/test-folder"
$removeBackupAtTheEnd = $true

$backupFolderSuffix = "-backup"
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$packageName = "MediaLibraryExport-$timestamp"
# --------------

$sourceItem = Get-Item -Path $mediaLibraryPath
if (-not $sourceItem) {
    Write-Error "Source item not found: $mediaLibraryPath"
    return
}

$backupPath = "$mediaLibraryPath$backupFolderSuffix"

function Get-MediaUrl($mediaitem) {
    $siteContext = [Sitecore.Sites.SiteContext]::GetSite("website");
    $result = New-UsingBlock(New-Object Sitecore.Sites.SiteContextSwitcher $siteContext){
        [Sitecore.Resources.Media.MediaManager]::GetMediaUrl($mediaitem)
    }
    $result
}

# STEP 1: Copy recursively all items to backup folder
Write-Host "Creating backup at $backupPath"
Copy-Item -Path $mediaLibraryPath -Destination $backupPath -Recurse -Force

# STEP 2: Remove "Blob" field from all items
Write-Host "Removing Blob fields from original media items..."
$mediaItems = Get-ChildItem -Path $mediaLibraryPath -Recurse 
foreach ($item in $mediaItems) {
    if ($item["Blob"]) {
        $item.Editing.BeginEdit()
        $item["__Long description"] = Get-MediaUrl($item)
        $item["Blob"] = $null
        $item.Editing.EndEdit()
    }
}

# STEP 3: Generate content package without blobs
Write-Host "Generating content package without blobs..."

$ItemsToBeDeployed = @(
    @{ Recursive = $true; Source = $mediaLibraryPath }
)

$Package = New-Package -Name $packageName
$Package.Sources.Clear()
$Package.Metadata.Author = "PowerShell"
$Package.Metadata.Publisher = "Automated Script"
$Package.Metadata.Version = Get-Date -Format FileDateTimeUniversal
$Package.Metadata.Readme = "This package contains media items without blobs."

foreach ($item in $ItemsToBeDeployed) {
    if ($item.Recursive) {
        $source = Get-Item $item.Source | New-ItemSource -Name "Media Source" -InstallMode Overwrite
    } else {
        $source = Get-Item $item.Source | New-ExplicitItemSource -Name "Media Source" -InstallMode Overwrite
    }
    $Package.Sources.Add($source)
}

$packageFileName = "$($Package.Name)-$($Package.Metadata.Version).zip"
$packageFullPath = Join-Path -Path $SitecorePackageFolder -ChildPath $packageFileName

Export-Package -Project $Package -Path $packageFullPath -Zip
Download-File $packageFullPath

Write-Host "✅ Package exported: $packageFileName"

# STEP 4: Restore blobs from backup
Write-Host "Restoring blobs from backup..."
$backupItems = Get-ChildItem -Path $backupPath -Recurse

foreach ($backupItem in $backupItems) {
    $relativePath = $backupItem.Paths.FullPath.Replace($backupPath.Replace("master:",""), "")
    $originalPath = "$mediaLibraryPath$relativePath"
    $originalItem = Get-Item -Path $originalPath

    if ($originalItem -and $backupItem.Fields["Blob"]) {
        $originalItem.Editing.BeginEdit()
        $originalItem["__Long description"] = ""
        $originalItem["Blob"] = $backupItem["Blob"]
        $originalItem.Editing.EndEdit()
    }
}

# STEP 5: Remove or Notify backup folder
if ($removeBackupAtTheEnd) {
    if (-not $backupItem) {
        Write-Error "Backup path not found: $backupPath"
        return
    }

    # Get all child items recursively, sorted bottom-up
    $allItemsToBeRemoved = Get-ChildItem -Path $backupPath -Recurse | Sort-Object { $_.Paths.FullPath } -Descending

    Write-Host "Removing back up folder: $backupPath"
    Write-Host "⌛This process may take several minutes, you can go ahead and install the downloaded package in the other Sitecore instance."

    # Delete all children
    foreach ($itemToDelete in $allItemsToBeRemoved) {
        try {
            Remove-Item -Path $itemToDelete.ItemPath -Permanently -Force
        } catch {
            Write-Warning "Failed to delete $($item.Paths.FullPath): $_"
        }
    }

    # Delete the root folder last
    try {
        Remove-Item -Path $backupPath -Permanently -Force
    } catch {
        Write-Warning "Failed to delete root item: $($backupItem.Paths.FullPath)"
    }
} else {
    Write-Warning "Backup created at: $backupPath"
    Write-Host "⚠️  Remember to manually delete the backup folder once you've verified the package is correct."
}

Write-Host "Finished"

The script processes each item, strips the blob, stores its expected URL in __Long description, and prepares the clean package for export.

Script 2 – Run on the Destination Sitecore Instance

Once you've installed the metadata-only content package on the target instance, you run this script.

It will:

  • Loop through all items under the same path.
  • Read the __Long description field, which contains the download URL.
  • Download the blob via WebClient.
  • Update the media item’s Blob, Mime Type, and Size fields.
# CONFIGURATION
# This value should be the same as in the first script
$mediaLibraryPath = "master:/sitecore/media library/test-folder"

# In XMCloud (Exp. Edge) the URL will be like "https://edge.sitecorecloud.io/mycompanyna44a2-proja11e-dev231a-1df8"
# In XP (with a CD server) the URL will be your front end: "https://www.mycompany.com"
$publicDownloadUrl = "https://www.mycompany.com"

# --------------

function Get-MimeType {
    param (
        [string]$extension
    )

    $extension = $extension.ToLower()

    switch ($extension) {
        'pdf' { return 'application/pdf' }
        'doc' { return 'application/msword' }
        'docx' { return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }
        'zip' { return 'application/zip' }
        'png' { return 'image/png' }
        'jpeg' { return 'image/jpeg' }
        'jpg' { return 'image/jpeg' }
        default { return 'application/octet-stream' }
    }
}

function Restore-MediaBlobs {
    $publicDownloadUrl = $publicDownloadUrl.TrimEnd('/')
    $rootItem = Get-Item -Path $mediaLibraryPath

    if (-not $rootItem) {
        Write-Error "Could not find item: $mediaLibraryPath"
        return
    }

    # Include root and all descendants
    $mediaItems = @($rootItem) + $rootItem.Axes.GetDescendants()

    foreach ($item in $mediaItems) {
        try {
            $relativePath = $item["__Long description"]
            if (-not $relativePath) {
                Write-Host "Skipping item (no path): $($item.Paths.FullPath) - No path found in __Long description - it may indicate a folder or any other item with no media."
                continue
            }

            # Construct the relative path from the media root
            if ($publicDownloadUrl.ToLower().IndexOf("edge.sitecorecloud.io") -ne -1 -AND $relativePath.StartsWith("/-")) {
                $relativePath = $relativePath.Substring(2) #remove /-
            }
            $mediaUrl = "$publicDownloadUrl$relativePath".ToLower()

            Write-Host "Downloading: $mediaUrl"

            $webClient = New-Object System.Net.WebClient
            $fileBytes = $webClient.DownloadData($mediaUrl)

            if ($fileBytes -and $fileBytes.Length -gt 0) {
                $item.Editing.BeginEdit()

                $item.Fields["Blob"].SetBlobStream([System.IO.MemoryStream]::new($fileBytes))
                $item["Mime Type"] = Get-MimeType -extension $extension
                $item["Size"] = $fileBytes.Length.ToString()
                $item["__Long description"] = ""
                $item.Editing.EndEdit()

                Write-Host "✅ Restored: $($item.Paths.FullPath)"
            } else {
                Write-Warning "⚠️ Empty file or download failed: $mediaUrl"
            }
        }
        catch {
            Write-Warning "❌ Error processing $($item.Paths.FullPath): $_"
        }
    }
}

# Execute
Restore-MediaBlobs

The script ensures each media item becomes functional and complete again by rehydrating it from the online media source.

Why This Works Well

  • Keeps package size small and manageable.
  • Relies on already-published media.
  • Ensures full roundtrip with backup/restore mechanism.

Pro Tips

  • Always run tests in a separate media folder to avoid irreversible changes.
  • Validate that your CDN/media URLs do not require authorization.
  • Sites requiring authentication, you can try to send special headers with the script 2 Web Client.

Fast and Reliable

After working on several projects and experimenting with different migration methods, I found this approach to be the most reliable and efficient. By separating the metadata from the binary content and leveraging Sitecore's published medias, this method simplifies large media transfers while minimizing complexity.

Photo of Fishtank employee Roberto Barbedo

Roberto Barbedo

Solutions Architect

Roberto is a Sitecore Solution Architect with experience in the build and implementation of large-scale Sitecore development projects for global clients.