Microsoft Graph in Practice #5: Files in OneDrive and SharePoint — Upload Sessions and Sharing

5 min read

This part is files: auto-uploading build artifacts to SharePoint, backing up daily logs, generating sharing links for reports. The good news about the file APIs is that OneDrive and SharePoint share one model — learn it once and the same code runs against a personal drive or a team site.

Permissions: delegated starts with Files.ReadWrite (your own files); application defaults to Sites.ReadWrite.All — but since that opens every site in the organization, narrowing with Sites.Selected (covered at the end) is today’s standard practice.

The model — drive and driveItem, and that’s all #

  • drive — one file container. A user’s OneDrive is a drive; a SharePoint site’s document library is a drive; what’s behind a Teams team’s Files tab is, in the end, a SharePoint drive too.
  • driveItem — an item in a drive. Files and folders are both driveItems; a folder facet means folder, a file facet means file.

Only the path to reach a drive differs by container type.

paths to a drive
# a user's OneDrive
GET /users/{user-id}/drive

# a SharePoint site's default document library
GET /sites/{site-id}/drive

# finding the site ID by URL is the practical route
GET /sites/contoso.sharepoint.com:/sites/DevTeam
  → the id in the response is the site-id

# items within a drive — path-based addressing is convenient
GET /drives/{drive-id}/root:/reports/2026/weekly.xlsx

That last line — path-based addressing (the colon syntax) — is the workhorse. root:/folder/filename reaches items by the paths humans know, no IDs needed. The clean automation pattern: resolve “site URL → site-id → drive-id” once, pin them in config, then travel by path from there on.

Downloads and simple uploads #

Reading, and writing small files, are one call each.

download and simple upload
async def download(client, drive_id: str, path: str) -> bytes:
    return await (
        client.drives.by_drive_id(drive_id)
        .root.item_with_path(path)
        .content.get()
    )

async def upload_small(client, drive_id: str, path: str, data: bytes):
    # PUT .../root:/{path}:/content — up to 250MB in one shot
    return await (
        client.drives.by_drive_id(drive_id)
        .root.item_with_path(path)
        .content.put(data)
    )

Simple upload (PUT /content) supports files up to 250MB. The official recommendation, though, is to switch to upload sessions beyond 10MiB: a single large request restarts from zero if the network blips once, and resumability wins in operations.

Also: uploading to an existing path overwrites. If that’s not the intent, state the conflict behavior explicitly with @microsoft.graph.conflictBehavior (rename / fail / replace).

Large files — the upload session #

The standard for large files has three steps: ① create an upload session, ② PUT chunks in order, ③ the final chunk completes it automatically.

upload session — chunked upload
import httpx
from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import (
    CreateUploadSessionPostRequestBody,
)
from msgraph.generated.models.drive_item_uploadable_properties import (
    DriveItemUploadableProperties,
)

CHUNK = 5 * 1024 * 1024  # 5MiB — must be a multiple of 320KiB (327,680)

async def upload_large(client, drive_id: str, path: str, filepath: str):
    body = CreateUploadSessionPostRequestBody(
        item=DriveItemUploadableProperties(
            additional_data={"@microsoft.graph.conflictBehavior": "replace"},
        ),
    )
    session = await (
        client.drives.by_drive_id(drive_id)
        .root.item_with_path(path)
        .create_upload_session.post(body)
    )

    # the session URL needs no auth token (it's pre-authenticated)
    import os
    total = os.path.getsize(filepath)
    async with httpx.AsyncClient() as http:
        with open(filepath, "rb") as f:
            offset = 0
            while chunk := f.read(CHUNK):
                end = offset + len(chunk) - 1
                resp = await http.put(
                    session.upload_url,
                    content=chunk,
                    headers={
                        "Content-Range": f"bytes {offset}-{end}/{total}",
                        "Content-Length": str(len(chunk)),
                    },
                )
                resp.raise_for_status()
                offset = end + 1

A few rules — break them and the upload fails in the nastiest way, on the final chunk:

  • Chunk sizes must be multiples of 320KiB (327,680 bytes). On stable connections, 5–10MiB is the recommended range; the per-request cap is 60MiB.
  • Chunks go up in order (for OneDrive/SharePoint).
  • On 5xx or a dropped connection, GET the session URL, read nextExpectedRanges (what the server is still missing), and resume there. A 404 means the session expired — start over.
  • Sessions have an expiry, but every uploaded chunk extends it, so an in-progress upload stays alive.

Note that the language SDKs ship large-file upload task helpers wrapping this procedure (e.g., LargeFileUploadTask); use them where available. The code above shows the raw procedure so it works in any environment.

Sharing links — createLink #

The last piece delivers the uploaded report to people: createLink mints a link you can drop into part 4’s mail or part 6’s Teams message, closing the automation loop.

creating a sharing link
from msgraph.generated.drives.item.items.item.create_link.create_link_post_request_body import (
    CreateLinkPostRequestBody,
)

async def share_link(client, drive_id: str, item_id: str) -> str:
    body = CreateLinkPostRequestBody(
        type="view",           # view / edit
        scope="organization",  # organization / anonymous / users
    )
    perm = await (
        client.drives.by_drive_id(drive_id)
        .items.by_drive_item_id(item_id)
        .create_link.post(body)
    )
    return perm.link.web_url

The scope choice is a security decision. For internal automation the default should be organization (viewable inside the org only); anonymous (anyone with the link) is often blocked by org policy — and arguably should be. Automation mints links at scale, so a habit of wide scopes becomes a leak surface.

Narrowing application permissions — Sites.Selected #

This part closes with permissions. Application-side Sites.ReadWrite.All opens every SharePoint site, which invites the objection “we can’t open everything for one automation.” The answer is Sites.Selected.

Its behavior is unusual enough to spell out: the Sites.Selected permission by itself opens no sites at all. An admin must separately grant the app read/write on specific sites (site permissions — granted via Graph’s site permission APIs or PnP PowerShell) before any access exists. In other words: one permission in the app’s list, and the real scope is a per-site allowlist. For production file automation, make this the default and reserve .All for the cases that genuinely span all sites.

Summary #

  • The model is two nouns — drive (container) and driveItem (file/folder) — shared by OneDrive and SharePoint. Path-based (colon) addressing is the working default.
  • Simple upload covers up to 250MB, but beyond 10MiB the upload session is the standard: chunks in 320KiB multiples (5–10MiB recommended), resume via nextExpectedRanges.
  • Always state conflictBehavior on uploads; the default will eventually overwrite something you care about.
  • Sharing links default to organization scope. Anonymous links minted by automation are a leak surface.
  • The production permission pattern for application file automation is Sites.Selected plus per-site grants; treat .All as the exception.

Next is Teams — the area where reading and writing have very different permission terrain, organized around the three routes for sending notifications.

X