How-to
Automating Reporting WorkflowsDeep dive

Upload an Excel Report to Azure Blob Storage with Python

Publish generated Excel reports to Azure Blob Storage with the azure-storage-blob SDK — sign in without keys in the code, name blobs by period, set the content type and download file name, refuse to overwrite a published report, record a checksum in metadata, verify the upload and share it with a read-only link that expires.

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.

Publishing one report to Blob Storage The script signs in with a managed identity or developer login, builds a blob name from the report period, uploads the bytes with the spreadsheet content type and a SHA-256 checksum in metadata while refusing to overwrite, checks the stored MD5 against the local file, and finally creates a read-only link that expires in seven days. Sign in no keys in code Name by period monthly/2026/09/… Upload type, checksum, overwrite=False Verify stored MD5 matches Share read-only link

Prerequisites

Bash
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:

Python
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:

Python
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:

Python
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:

Python
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.

One upload, and what comes back The script sends Put Blob with the bytes, the content type, the content disposition and metadata, and the If-None-Match condition that overwrite=False adds. The service stores the blob and replies 201 Created with an ETag and the MD5 of the stored bytes, which the script compares with its own hash. A second attempt with the same name receives 409 BlobAlreadyExists. Report script Blob Storage 1. PUT blob, type, metadata, If-None-Match: * 2. 201 Created, ETag, Content-MD5 3. same name again 4. 409 BlobAlreadyExists

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:

Python
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:

Python
for item in container.list_blobs(name_starts_with="monthly/2026/"):
    print(item.name, item.size)

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:

Python
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:

Python
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:

Python
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.

Choosing how the script signs in A managed identity suits scripts running inside Azure and needs no secret. A developer login through az login suits local runs with the same code. A connection string with an account key suits Azurite and systems outside Azure, but it grants full access to the account and must be stored as a secret. SAS links for sharing need an account key or a user delegation key. Where it fits Secret to store Share links with managed identity VMs Functions containers none user delegation key az login developer machines none user delegation key connection string Azurite, outside Azure full account key account key

Common pitfalls

  • Wrong content type. Without ContentSettings, blobs are stored as application/octet-stream, and some browsers save them without the .xlsx extension.
  • Role assigned, still 403. Role assignments can take several minutes to apply. A fresh AuthorizationPermissionMismatch right 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_link does, 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.

More on publishing & sharing files

More on publishing & sharing files →