Guide
Automating Reporting WorkflowsDeep dive

Upload an Excel Report to SharePoint with Python

Publish a generated .xlsx to SharePoint or OneDrive from Python using Microsoft Graph — app-only tokens, resolving the drive, simple and chunked uploads, and sharing links.

SharePoint and OneDrive are where most office workers already look for documents, which makes them the right destination for a recurring report inside a Microsoft-based organisation. The route from Python is Microsoft Graph: get an app-only token, resolve the site and drive, and PUT the file. The details that decide whether it works unattended are the permission model, the difference between a simple upload and a resumable session, and getting a shareable link back. This guide covers all of it. It is the Microsoft 365 path from Publishing Excel Reports to Cloud Storage.

The four Graph calls behind a SharePoint upload A sequence of four steps. First the job posts its client ID and secret to the Entra token endpoint and receives an app-only access token. Second it resolves the site by hostname and path to a site ID. Third it looks up that site's default document library drive. Fourth it uploads, choosing a direct PUT for small files or a resumable upload session for large ones. Each step depends on the identifier returned by the previous one. 1 · authenticate client credentials POST to the token endpoint → access token 2 · resolve site site ID by hostname and server-relative path 3 · resolve drive drive ID the document library behind the site 4 · upload PUT or session a token is authentication; 403 means the consent or site grant is missing application permissions need admin consent, and Sites.Selected needs a per-site grant

Prerequisites

Bash
pip install requests pandas xlsxwriter

An app registration in Microsoft Entra ID with a client secret, and one of two application permissions granted with admin consent:

PermissionScopeWhen to use
Sites.Selectedonly sites explicitly grantedthe right default — least privilege
Files.ReadWrite.Allevery site in the tenantsimpler, far broader than a report job needs

Sites.Selected needs a second step that catches people out: after consenting to the permission, an administrator must grant the app access to the specific site. Without that grant every call returns 403 despite a perfectly valid token.

Bash
export GRAPH_TENANT_ID=...
export GRAPH_CLIENT_ID=...
export GRAPH_CLIENT_SECRET=...
export SP_HOSTNAME=contoso.sharepoint.com
export SP_SITE_PATH=/sites/finance

Step 1 — Get an app-only token

The client-credentials flow is a single POST. No user, no interactive prompt, nothing that breaks when somebody changes their password:

Python
import os
import time
import requests

TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"

class GraphClient:
    def __init__(self):
        self.tenant = os.environ["GRAPH_TENANT_ID"]
        self.client_id = os.environ["GRAPH_CLIENT_ID"]
        self.client_secret = os.environ["GRAPH_CLIENT_SECRET"]
        self._token = None
        self._expires_at = 0.0
        self.session = requests.Session()

    def token(self):
        """Return a valid token, refreshing a minute before it expires."""
        if self._token and time.time() < self._expires_at - 60:
            return self._token

        response = self.session.post(
            TOKEN_URL.format(tenant=self.tenant),
            data={
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "scope": "https://graph.microsoft.com/.default",
                "grant_type": "client_credentials",
            },
            timeout=30,
        )
        response.raise_for_status()
        payload = response.json()

        self._token = payload["access_token"]
        self._expires_at = time.time() + payload["expires_in"]
        return self._token

    def headers(self, **extra):
        return {"Authorization": f"Bearer {self.token()}", **extra}

Caching the token matters. Graph tokens last around an hour, and a job that requests a fresh one per file will be throttled on any fan-out of more than a handful of reports. The - 60 margin avoids the race where a token expires between the check and the request landing.

Note the scope: .default with client credentials means "every application permission this app has been consented for", which is how app-only access works — you do not list individual scopes.

Step 2 — Resolve the site and its drive

Graph addresses documents by drive and item, so a human-readable site URL has to be translated first:

Python
GRAPH = "https://graph.microsoft.com/v1.0"

class GraphClient(GraphClient):        # continuing the class above
    def site_id(self, hostname, site_path):
        """Resolve https://{hostname}{site_path} to a Graph site ID."""
        url = f"{GRAPH}/sites/{hostname}:{site_path}"
        r = self.session.get(url, headers=self.headers(), timeout=30)
        r.raise_for_status()
        return r.json()["id"]

    def drive_id(self, site_id, library=None):
        """The default document library, or a named one."""
        if library is None:
            r = self.session.get(f"{GRAPH}/sites/{site_id}/drive",
                                 headers=self.headers(), timeout=30)
            r.raise_for_status()
            return r.json()["id"]

        r = self.session.get(f"{GRAPH}/sites/{site_id}/drives",
                             headers=self.headers(), timeout=30)
        r.raise_for_status()
        for drive in r.json()["value"]:
            if drive["name"] == library:
                return drive["id"]
        raise LookupError(f"no document library named {library!r}")

Resolve these once and cache the IDs — they do not change between runs, and looking them up on every upload wastes two round trips per file. Putting them in the job's configuration, as described in keeping Excel report settings in a config file, is the tidy version.

Step 3 — Upload a small report

For anything up to a few megabytes, a direct PUT to the content endpoint is all it takes:

Python
import io
import pandas as pd

XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

class GraphClient(GraphClient):
    def upload_small(self, drive_id, folder, filename, data):
        """PUT a file straight into a folder. Replaces any existing item."""
        path = f"{folder.strip('/')}/{filename}"
        url = f"{GRAPH}/drives/{drive_id}/root:/{path}:/content"

        r = self.session.put(
            url,
            headers=self.headers(**{"Content-Type": XLSX}),
            data=data,
            timeout=120,
        )
        r.raise_for_status()
        return r.json()          # the driveItem, including id and webUrl

graph = GraphClient()
site = graph.site_id(os.environ["SP_HOSTNAME"], os.environ["SP_SITE_PATH"])
drive = graph.drive_id(site)

df = pd.DataFrame({"region": ["North", "South"], "revenue": [159.92, 247.50]})
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
    df.to_excel(writer, sheet_name="Summary", index=False)

item = graph.upload_small(drive, "Reports/2026", "regional-2026-08.xlsx",
                          buffer.getvalue())
print(item["webUrl"])

A PUT to an existing path replaces the file and SharePoint keeps the previous version in its history — which is exactly the behaviour a monthly report wants. No staging dance is needed here: SharePoint does not expose a partially uploaded item, so the write is effectively atomic already.

Step 4 — Upload a large report resumably

Above a few megabytes, use an upload session. It chunks the transfer and, crucially, survives a dropped connection:

How an upload session survives a dropped connection A file is split into four chunks. Chunks one and two upload successfully and Graph returns a 202 with the byte ranges it still expects. Chunk three fails on a network drop. Because the session records progress, the job resumes from the start of chunk three rather than from the beginning of the file. The final chunk returns 201 with the completed driveItem. the session remembers what arrived chunk 1 → 202 bytes 0–10485759 chunk 2 → 202 next expected range returned chunk 3 → dropped connection lost chunk 4 → 201 driveItem returned resume from chunk 3 chunks 1 and 2 are not re-sent
Python
CHUNK = 10 * 1024 * 1024        # must be a multiple of 320 KiB

class GraphClient(GraphClient):
    def upload_large(self, drive_id, folder, filename, data):
        """Chunked, resumable upload for a big workbook."""
        path = f"{folder.strip('/')}/{filename}"
        create = self.session.post(
            f"{GRAPH}/drives/{drive_id}/root:/{path}:/createUploadSession",
            headers=self.headers(**{"Content-Type": "application/json"}),
            json={"item": {"@microsoft.graph.conflictBehavior": "replace"}},
            timeout=30,
        )
        create.raise_for_status()
        upload_url = create.json()["uploadUrl"]

        total = len(data)
        start = 0
        while start < total:
            end = min(start + CHUNK, total) - 1
            chunk = data[start:end + 1]

            r = self.session.put(
                upload_url,
                headers={
                    "Content-Length": str(len(chunk)),
                    "Content-Range": f"bytes {start}-{end}/{total}",
                },
                data=chunk,
                timeout=300,
            )
            if r.status_code in (200, 201):
                return r.json()             # finished
            r.raise_for_status()            # 202 means keep going

            # Graph reports the next byte it wants; trust it over our counter.
            expected = r.json().get("nextExpectedRanges")
            start = int(expected[0].split("-")[0]) if expected else end + 1

        raise RuntimeError("upload session ended without a completed item")

Two rules are non-negotiable. Chunk size must be a multiple of 320 KiB — Graph rejects anything else outright. And the upload URL from createUploadSession is already authorised, so do not send the Authorization header with the chunks; including it causes failures that look like permission problems.

Trusting nextExpectedRanges rather than your own counter is what makes the loop resumable. If a chunk partially landed, Graph tells you exactly where to pick up.

The webUrl on the returned item is the document's address, which works for anyone who already has site access. To hand out an explicit link, ask Graph to create one:

Python
class GraphClient(GraphClient):
    def share_link(self, drive_id, item_id, link_type="view",
                   scope="organization"):
        """Create a sharing link for an uploaded item."""
        r = self.session.post(
            f"{GRAPH}/drives/{drive_id}/items/{item_id}/createLink",
            headers=self.headers(**{"Content-Type": "application/json"}),
            json={"type": link_type, "scope": scope},
            timeout=30,
        )
        r.raise_for_status()
        return r.json()["link"]["webUrl"]

url = graph.share_link(drive, item["id"])
print(url)

Use scope="organization" so the link works for signed-in colleagues and nobody else. scope="anonymous" produces a link anyone can open, which most tenants disable by policy — and should, for a report.

Common pitfalls and fixes

SymptomCauseFix
403 with a valid tokenAdmin consent or site grant missingConsent the application permission; grant the site for Sites.Selected.
401 after an hourCached token expiredRefresh a minute before expires_in.
400 invalidRequest on a chunkChunk size not a multiple of 320 KiBUse 320 KiB multiples, e.g. 10 MB.
Chunk uploads fail oddlyAuthorization sent with chunksThe session URL is pre-authorised; omit the header.
404 resolving the siteWrong hostname/path separatorThe format is {hostname}:{/sites/name} with the colon.
429 Too Many RequestsThrottledHonour Retry-After; cache tokens and IDs.
File uploads but opens as binaryContent type not set on PUTSend the spreadsheetml Content-Type.
Session upload restarts from zeroIgnoring nextExpectedRangesResume from the range Graph reports.

Performance and scale notes

Graph calls per report, before and after caching Two bars showing calls made per published report. Without caching, each report costs four calls: a token request, a site lookup, a drive lookup and the upload itself. With the token and the resolved identifiers cached, only the upload remains, cutting the request volume to a quarter and making tenant throttling far less likely on a fan-out job. Graph calls per published report no caching token site lookup drive lookup upload IDs cached upload one quarter of the request volume on a forty-report fan-out that is 160 calls versus 40 — the difference between throttled and not

Graph throttles per app and per tenant, and a report fan-out is exactly the shape that triggers it. Honouring Retry-After is not optional — it is the documented contract, and ignoring it extends the throttling window:

Python
import time

def request_with_backoff(session, method, url, attempts=5, **kwargs):
    for attempt in range(1, attempts + 1):
        response = session.request(method, url, **kwargs)
        if response.status_code == 429 and attempt < attempts:
            wait = int(response.headers.get("Retry-After", 2 ** attempt))
            print(f"throttled; sleeping {wait}s")
            time.sleep(wait)
            continue
        return response
    return response

Three habits keep a multi-report job inside the limits. Cache the token — one per hour, not one per file. Cache the site and drive IDs in configuration rather than resolving them per upload; that removes two calls from every report. And keep concurrency modest: four to six parallel uploads is usually the sweet spot, beyond which throttling costs more than the parallelism gains.

Python
from concurrent.futures import ThreadPoolExecutor

def publish_region(region):
    buffer = build_report(region)                 # returns bytes
    return graph.upload_small(drive, "Reports/2026",
                              f"{region}-2026-08.xlsx", buffer)

with ThreadPoolExecutor(max_workers=4) as pool:
    for item in pool.map(publish_region, ["north", "south", "west", "east"]):
        print(item["webUrl"])

Threads share the GraphClient and therefore the cached token, which is the point — each worker reuses one authentication rather than requesting its own. requests.Session is thread-safe for this pattern, and reusing it also reuses the underlying connection pool.

For memory, the chunked path holds only one chunk at a time in the request body, but the example above keeps the whole file in data. On genuinely large reports, stream from disk instead by slicing a file object per chunk, and build the workbook itself with the streaming approach in writing large DataFrames with write-only mode so peak memory never holds the full grid.

Conclusion

Publishing to SharePoint from Python is four Graph calls: token, site, drive, upload. Use a client-credentials app registration so the job does not depend on a person's account, prefer Sites.Selected and remember the per-site grant that goes with it, and cache both the token and the resolved IDs. A direct PUT covers small reports and replaces the previous version while keeping history; anything larger belongs in an upload session with 320 KiB-multiple chunks and no Authorization header. Then hand out an organisation-scoped sharing link rather than an attachment.

Frequently asked questions

Which permissions does the app registration need? The application permission Files.ReadWrite.All, or Sites.Selected with the site granted explicitly. Sites.Selected is the tighter choice because it limits the app to the one site holding the reports rather than every site in the tenant.

Why do I get 403 even though the token was issued? A token proves authentication, not authorisation. Application permissions require admin consent in Entra ID, and with Sites.Selected the app must also be granted access to that specific site. Check both.

What is the file size limit for a simple upload? Graph accepts a direct PUT to the content endpoint for files up to about 250 MB, but the practical limit is much lower over an unreliable link because nothing can resume. Use an upload session for anything above a few megabytes.

How do I overwrite an existing report each month? A PUT to the item content endpoint replaces the file by default and keeps the version history. For upload sessions set conflictBehavior to replace in the session request body.

Can I write directly into a Teams channel's files? Yes — a Teams channel's Files tab is a folder in the team's SharePoint document library, so the same drive endpoints work once you resolve the group's drive.