Archive-StorageAccount.ps1 copies every container from a source storage account into one archive container, then optionally deletes the source. AzCopy's exit code is the wrong signal for that delete. This guide runs a read-only compare of blob names and Content-Length (and Content-MD5 when both sides have one) and only then treats the archive as safe.
The copy job itself is Cross-Tenant Azure Storage Archival. Leave $DeleteAfterCopy = $false in that script until the compare below exits 0.
The archive script calls AzCopy like this:
azcopy copy <source-container-url> <dest-container/prefix-url> --recursive --overwrite=false
--overwrite=false does not compare bytes. If a blob with the same name already exists at the destination, AzCopy skips it and still returns exit code 0. A previous partial copy, a truncated upload, or an older object left in the archive prefix all look like success.
The script then does this when delete is enabled:
azcopy remove "https://<source>.blob.core.windows.net/<container>?<sas>" --recursive
That remove runs per container, immediately after that container's copy returns 0. It does not look at the destination. A skipped blob is already gone from the source, and the archive still holds the old one.
The compare script never copies and never deletes. It lists both sides through the Blob REST API and fails if any source blob is missing, a different length, or a different MD5.
The archive script names the destination container from the source account (ToLower(), then any character outside a-z, 0-9 and - becomes -). Each source container is copied under a prefix of the same name.
Source account mySourceAccount, containers data and backups:
myarchiveaccount
└── mysourceaccount container
├── data/ prefix = source container
│ └── reports/2026.csv
└── backups/
└── vm1.vhdx
| Source blob | Archive blob | Compared |
|---|---|---|
data/reports/2026.csv |
mysourceaccount/data/reports/2026.csv |
name, Content-Length, Content-MD5 if both have it |
backups/vm1.vhdx |
mysourceaccount/backups/vm1.vhdx |
same |
Blobs that exist only under the destination prefix are logged as EXTRA. They do not fail the run. They also do not make the source safe to delete by themselves; only a full match of the source set does.
| Requirement | Details |
|---|---|
| PowerShell | 7.0 or later. Same host as the archive script is fine |
| Source SAS | Read and List on the Blob service |
| Destination SAS | Read and List on the Blob service |
| AzCopy | Not used by the compare. The archive copy still needs AzCopy 10+ |
The destination SAS from the archive script is often only Write and Create. Listing with that token returns HTTP 403. Generate a separate read token, or add Read and List to the existing one. Do not grant Delete on the token this script uses.
?.Account SAS (ss=b) is what the archive script already uses. A container SAS also works if it can list that container.
Edit only the configuration block at the top of Compare-StorageArchive.ps1. Account names must match the archive script, because the destination container name is derived the same way.
# --- Source Storage Account ---
$SourceAccountName = "mySourceAccount"
$SourceSasToken = "?sv=2022-11-02&ss=b&..."
# --- Destination / Archive Storage Account ---
$DestAccountName = "myArchiveAccount"
$DestSasToken = "?sv=2022-11-02&ss=b&..."
$LogDirectory = "$PSScriptRoot\Logs"
Run it after Archive-StorageAccount.ps1 finishes, and before you change $DeleteAfterCopy.
cd C:\Scripts
pwsh .\Compare-StorageArchive.ps1
echo $LASTEXITCODE
Exit code 0 means every source blob was found at the destination with the same Content-Length. Exit code 1 means at least one blob was missing, a different size, or a different MD5. The source is still intact as long as the archive script was left with $DeleteAfterCopy = $false.
The script pages through comp=list with maxresults=5000 until NextMarker is empty, so accounts larger than one page are covered. It writes Logs\compare-yyyyMMdd.log next to the script.
Example of a failed container:
[2026-10-07 09:15:01] [INFO] Comparing container 'data'
[2026-10-07 09:15:04] [ERROR] MISSING data/reports/2026.csv
[2026-10-07 09:15:04] [ERROR] SIZE data/reports/2025.csv source=1048576 dest=4096
[2026-10-07 09:15:04] [INFO] Container 'data': source=2 dest=1
[2026-10-07 09:15:06] [ERROR] Archive does not match the source. Leave DeleteAfterCopy set to $false.
Example of a match with a leftover object in the archive:
[2026-10-07 09:20:11] [WARNING] EXTRA mysourceaccount/data/old/retired.csv (not in source; source delete is still safe if nothing else failed)
[2026-10-07 09:20:11] [SUCCESS] Every source blob is present at the destination with the same length.
EXTRA is a blob under the archive prefix that the source no longer has. Re-run the archive copy if you still need it. It is not a reason to keep the source, and it is not a reason to ignore a MISSING or SIZE line in the same run.
$DeleteAfterCopy = $false and no failed containers in its own summary.$DeleteAfterCopy = $true and run the archive script again.The second archive run copies again with --overwrite=false (existing matches are skipped) and then removes each source container whose copy returns 0. If anything was written to the source between step 2 and step 3, run the compare again first. The compare is a point-in-time list, not a lock.
Download Compare-StorageArchive.zip{.button .button-primary}
The zip contains Compare-StorageArchive.ps1.
HTTP 403 on the container list or the blob list
: The SAS is missing List, it is a container SAS for the wrong container, or it has expired. The destination token used for the original copy is the usual cause.
HTTP 404 on the destination container
: The archive container was never created, or the source account name does not transform to the same container name. The compare uses $SourceAccountName.ToLower() -replace '[^a-z0-9-]', '-', which is the same expression as Archive-StorageAccount.ps1.
MISSING on every blob in one container
: The archive copy for that container did not land under the expected prefix. Check the archive log for FAIL on that container before you look at individual blobs.
SIZE on a blob AzCopy reported as copied
: The destination object was already there and --overwrite=false skipped it. Delete that one destination blob (not the source), run the archive script again with delete still off, then re-run the compare.
MD5 line, lengths match
: Both blobs published Content-MD5 and the hashes differ. Treat it like a size mismatch: the archive object is not the source object. Length alone would have passed.
EXTRA lines and exit code 0
: The source set is fully present. Leftover archive blobs are not deleted by this script. Remove them with a separate azcopy remove against the destination path if you want the archive to be exact.
No containers found : The source SAS cannot list the account, or the account really has no containers. The log includes the same container-list call the archive script uses.