Upload an Excel Report to Azure Blob Storage with Python
Azure Blob Storage is a natural place to publish reports when the rest of the organisation lives in Azure: cheap, durable, reachable from Power BI, Data Factory and a browser link. The SDK makes the upload a single call, but a report store needs more than an upload — predictable names, a download that opens in Excel, protection against overwriting last month's published file, and a way to prove what was uploaded. This guide, part of Publishing Excel Reports to Cloud Storage, covers each of those. Every call was run against Azurite, Microsoft's local storage emulator.
Prerequisites
pip install azure-storage-blob azure-identity pandas openpyxl
You need a storage account and a container — reports below. For local development, Azurite runs the same API on your machine (npx azurite-blob), and its well-known development connection string works with every call in this guide.
A report to publish, built in memory:
import hashlib
import io
import pandas as pd
buffer = io.BytesIO()
pd.DataFrame({"region": ["North", "South", "East", "West"],
"sales": [182_400, 141_950, 97_300, 120_880]}).to_excel(buffer, index=False)
report_bytes = buffer.getvalue()
Step 1 — Sign in without putting a key in the code
In Azure, use DefaultAzureCredential: it picks up a managed identity on a VM, container or Function, and your az login session on a developer machine, so the same code runs in both places without a secret. Grant the identity Storage Blob Data Contributor on the container — the Owner or Contributor role on the account manages it but does not grant access to blob data through Azure AD:
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient
service = BlobServiceClient(
account_url="https://reportstore.blob.core.windows.net",
credential=DefaultAzureCredential(),
)
Outside Azure, or against Azurite, a connection string works — keep it in an environment variable, never in the script:
import os
service = BlobServiceClient.from_connection_string(os.environ["AZURE_STORAGE_CONNECTION_STRING"])
container = service.get_container_client("reports")
Step 2 — Upload with a name, type and checksum
Blob names can contain slashes, which tools show as folders. Put the period in the name so reports sort naturally and never collide. Set the content type so a download opens in Excel, a Content-Disposition so browsers save it under a sensible file name, and record a SHA-256 of the bytes in metadata:
from azure.core.exceptions import ResourceExistsError
from azure.storage.blob import ContentSettings
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def publish(container, data, period, name="sales"):
filename = f"{name}_{period}.xlsx"
blob = container.get_blob_client(f"monthly/{period[:4]}/{period[5:]}/{filename}")
result = blob.upload_blob(
data,
overwrite=False,
content_settings=ContentSettings(
content_type=XLSX,
content_disposition=f'attachment; filename="{filename}"',
),
metadata={"period": period, "sha256": hashlib.sha256(data).hexdigest()},
)
if result["content_md5"] is not None and bytes(result["content_md5"]) != hashlib.md5(data).digest():
raise IOError(f"{blob.blob_name}: stored MD5 does not match the local file")
return blob
blob = publish(container, report_bytes, "2026-09")
print(blob.blob_name)
# monthly/2026/09/sales_2026-09.xlsx
overwrite=False is the important argument. It is also the SDK's default, which the SDK enforces by sending If-None-Match: * — spell it out anyway, so nobody "fixes" a failed rerun by flipping it without thinking. With overwrite=True, a rerun of last month's job or a mistyped period would silently replace a report people have already read. With False, the second upload fails with ResourceExistsError: The specified blob already exists. — a failure you can alert on, as in Send an Alert When an Excel Report Job Fails.
The service returns an MD5 of what it stored for uploads sent in a single request, which the SDK does for files up to 64 MB by default; comparing it with the local hash proves the bytes arrived intact. Larger uploads are sent in blocks and do not get a whole-file MD5, which is why the SHA-256 in metadata is the checksum to rely on.
Step 3 — Keep a stable "latest" name as well
Dashboards and colleagues often want one address that always holds the current report. Upload a second copy under a fixed name with overwrite=True — that name is meant to be replaced, while the dated copy remains the record:
latest = container.get_blob_client("latest/sales.xlsx")
latest.upload_blob(report_bytes, overwrite=True, content_settings=ContentSettings(content_type=XLSX))
List the archive by prefix to see what has been published:
for item in container.list_blobs(name_starts_with="monthly/2026/"):
print(item.name, item.size)
Step 4 — Share a read-only link that expires
The container should stay private. To give someone a download link, create a shared access signature for the single blob, with read permission only and an expiry:
import datetime as dt
from azure.storage.blob import BlobSasPermissions, generate_blob_sas
def share_link(service, blob, days=7):
expiry = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=days)
sas = generate_blob_sas(
account_name=service.account_name,
container_name=blob.container_name,
blob_name=blob.blob_name,
account_key=service.credential.account_key, # connection-string sign-in
permission=BlobSasPermissions(read=True),
expiry=expiry,
)
return f"{blob.url}?{sas}"
Downloading that link against Azurite returned the workbook with status 200, the spreadsheet content type and the attachment; filename="sales_2026-09.xlsx" header set in Step 2. With DefaultAzureCredential there is no account key; request a user delegation key with service.get_user_delegation_key(start, expiry) and pass it as user_delegation_key= instead. The link can then last at most seven days, and it stops working if the identity loses access. Posting the link to a channel is covered in Post an Excel Report to Microsoft Teams with Python.
Step 5 — Read it back
A final check that the published file is the report you meant — the right size, a readable workbook, the right checksum:
properties = blob.get_blob_properties()
downloaded = blob.download_blob().readall()
assert hashlib.sha256(downloaded).hexdigest() == properties.metadata["sha256"]
print(properties.size, properties.content_settings.content_type, pd.read_excel(io.BytesIO(downloaded)).shape)
# 5364 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (4, 2)
Against Azurite this printed the workbook's 5,364 bytes, the spreadsheet content type and the expected shape. Reading reports back into pandas directly from storage is also how downstream jobs consume them.
Step 6 — Keep the archive cheap and recoverable
Monthly reports are read heavily for a few weeks and rarely afterwards. Blobs have access tiers: Hot costs more to store and less to read, Cool and Cold the reverse. Move older reports down a tier from the script, or better, with a lifecycle management rule on the account that does it for everything under monthly/ after 30 days:
from azure.storage.blob import StandardBlobTier
old = container.get_blob_client("monthly/2025/09/sales_2025-09.xlsx")
old.set_standard_blob_tier(StandardBlobTier.COOL)
print(old.get_blob_properties().blob_tier)
# Cool
Two account settings protect against the mistakes code cannot prevent. Soft delete keeps deleted blobs recoverable for a set number of days, and versioning keeps the previous content whenever a blob is overwritten — which makes the "latest" copy from Step 3 recoverable too. Both are switched on once in the portal or with infrastructure code, not per upload. For reports that must never change after publication, such as regulatory filings, a time-based immutability policy on the container makes even an account owner unable to alter them until it expires.
Common pitfalls
- Wrong content type. Without
ContentSettings, blobs are stored asapplication/octet-stream, and some browsers save them without the.xlsxextension. - Role assigned, still 403. Role assignments can take several minutes to apply. A fresh
AuthorizationPermissionMismatchright after granting access usually clears on its own. - Account keys in scripts. A connection string grants full control of the whole storage account. Prefer identities; if you must use a key, keep it in a secret store and rotate it.
- A SAS start time of "now". If the machine's clock runs slightly ahead of Azure's, a link that starts now is rejected for its first few minutes. Leave the start time unset, as
share_linkdoes, or set it a few minutes in the past. - Public containers for convenience. Anonymous read on a container exposes every report in it, including ones uploaded later. Share individual blobs with SAS links instead.
Conclusion
Sign in with DefaultAzureCredential and a data-plane role, name blobs by period, upload with the spreadsheet content type, a download file name, a SHA-256 in metadata and overwrite=False, compare the stored MD5, keep a separate "latest" copy for fixed links, and share single reports with expiring read-only SAS links.
Frequently asked questions
How do I upload an Excel file to Azure Blob Storage in Python? Install azure-storage-blob, create a BlobServiceClient, get a blob client for a container and name, and call upload_blob with the file's bytes. Pass ContentSettings with the xlsx content type so browsers and Office treat the download as a spreadsheet.
How do I stop an upload from replacing an existing report? Call upload_blob with overwrite=False. If a blob with that name already exists the service refuses and the SDK raises ResourceExistsError with "The specified blob already exists", leaving the published report untouched.
How do I share an uploaded Excel report without making the container public? Generate a shared access signature (SAS) for that one blob with read permission and an expiry, and send the blob URL with the SAS appended. Anyone with the link can download it until it expires; the container stays private.
Which role does a script need to upload blobs with Azure AD? A data-plane role on the storage account or container, such as Storage Blob Data Contributor. Owner or Contributor on the resource alone manages the account but does not grant access to blob data through Azure AD.
Related
- Up one level: Publishing Excel Reports to Cloud Storage — where finished reports go.
- Upload an Excel Report to Amazon S3 with boto3 — the same job on AWS.
- Upload an Excel Report to SharePoint with Python — when readers live in Microsoft 365.
- Upload an Excel Report over SFTP with Python — for partners who expect a file drop.
More on publishing & sharing files
- How-toRead an Excel File from SharePoint or OneDrive into pandas
- How-toShare an Excel File in OneDrive with a Link Using Graph
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toSend an Excel Report with Amazon SES
- How-toGenerate an HTML Report from Excel Data with Jinja2
- How-toName, Version and Archive Generated Excel Reports