Upload an Excel Report to SharePoint with Python
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.
Prerequisites
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:
| Permission | Scope | When to use |
|---|---|---|
Sites.Selected | only sites explicitly granted | the right default — least privilege |
Files.ReadWrite.All | every site in the tenant | simpler, 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.
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:
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:
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:
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:
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.
Step 5 — Get a link people can use
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:
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
| Symptom | Cause | Fix |
|---|---|---|
403 with a valid token | Admin consent or site grant missing | Consent the application permission; grant the site for Sites.Selected. |
401 after an hour | Cached token expired | Refresh a minute before expires_in. |
400 invalidRequest on a chunk | Chunk size not a multiple of 320 KiB | Use 320 KiB multiples, e.g. 10 MB. |
| Chunk uploads fail oddly | Authorization sent with chunks | The session URL is pre-authorised; omit the header. |
404 resolving the site | Wrong hostname/path separator | The format is {hostname}:{/sites/name} with the colon. |
429 Too Many Requests | Throttled | Honour Retry-After; cache tokens and IDs. |
| File uploads but opens as binary | Content type not set on PUT | Send the spreadsheetml Content-Type. |
| Session upload restarts from zero | Ignoring nextExpectedRanges | Resume from the range Graph reports. |
Performance and scale notes
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:
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.
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.
Related
- Up to the parent: Publishing Excel Reports to Cloud Storage — naming, atomicity and retry patterns across every destination.
- Upload an Excel Report to Amazon S3 with boto3 — the object-storage equivalent.
- Save Excel Reports to Google Drive with Python — the Google Workspace path.
- Fetch API Data into Excel with Python requests — the same HTTP discipline on the way in.
- Retry a Failed Excel Report Job in Python — backoff around throttling and transient failures.