Data pipeline¶
Move tabular data between Microsoft 365 and the Python data stack (pandas, CSV, Parquet, SQL, ...). The same surface works on every collection — SharePoint lists, Entra users/groups, Teams, OneDrive drives — and shares its engine with the migration toolkit.
The model¶
| Object | Meaning | Import | Export |
|---|---|---|---|
| Collection (list, users, ...) | a table | from_* / queue_* |
to_* / export_to |
| File / Folder | a blob (content) | read_dataframe |
write_dataframe |
Two rules keep the names predictable:
from_*is streaming (returns anImportResult— bounded, resumable, idempotent);queue_*is deferred queue-all (run withexecute_query()).- Collections use
to_*/from_*; files/folders useread_*/write_*and act on a file's content (metadata stays as plain properties).
Vocabulary¶
Six words carry the pipeline, and each names something the others are not:
| Word | Is | Is not |
|---|---|---|
| item | a model instance (User, ListItem, DriveItem) |
a dict |
| row | cells as a source delivers them (a CSV line, an Excel or DataFrame row), usually text | a typed value |
| record | a dict keyed by the declared OData names, in declaration order |
an item |
| format | bytes on a handle: csv, ndjson, json, excel, parquet, orc, feather, sql, dataframe | a record shape |
| profile | how the wire spells a value (JsonProfile, as_held(), scoped()) |
a format |
| map | the caller's batch-at-a-time reshape of records before the type is applied: a column copied, a value computed, a constant, a row dropped or fanned out | the schema — a column's type is converters or the declared dtype |
Direction is a separate axis, and the type states it once:
| Question | Answered by |
|---|---|
| What does the type declare? | schema.columns |
| May a request serialize this name? | schema.serializable yes, schema.readonly no |
| Did the caller change it? | the item's changes, set by set_property() |
| Must it be sent even when unchanged? | schema.persistable — the members a PATCH replaces whole (toRecipients, members, checklist) |
A request serializes serializable ∩ (changed ∪ persistable) — a delta, not the
type. An export carries columns, because a read projection has no direction to lose:
id and createdDateTime are part of what the type is. An import sits between the two
and may only set what a request may carry.
serialize/deserialize name the mechanism — a typed value ↔ its wire spelling — never
a set of names: a readonly column is serialized like any other, it is only not sent.
The conversion layer¶
A value crosses the wire in one of two directions, and each side has its own verb — they are not synonyms:
| Verb | Direction | Used by |
|---|---|---|
serialize_value |
stored → JSON | to_json(), a request body (build_payload), a CSV writer |
deserialize_value |
raw → declared type | set_property(), a response, deserialize_records |
converter_for |
a converters= spec → the callable it names |
any from_* |
parse_* |
one primitive (parse_date, parse_uuid, ...) |
the arms both directions dispatch to |
converters= names no direction: it is the user's per-column instruction, and
converter_for turns one into the single-argument callable that deserializes a cell. A
spec is a callable, a Python type, a type name, or a SharePoint FieldType — four
spellings of one callable. A name that is none of them raises, so a typo cannot quietly
mean "no conversion".
One table decides what a type means, and both directions read it: PRIMITIVES in
runtime/odata/edm.py. A row is (cell parser, every EDM name), so Edm.Decimal and
Decimal cannot end up with two answers, and serialize_value, deserialize_value,
converter_for and the CSV reader all resolve through that one row. A str-declared
column has no parser by design — text is the wire spelling already, and a raw 1 must
stay 1.
Which type a column is declared as is a schema question, answered by
TypeSchema.of(T).dtypes; the resolver lives in runtime/schema.py, beside the
declarations it reads, and depends on them in one direction only (value → schema).
What is left: time and Decimal have no serializer — serialize_value passes them
through, so a payload holding one is refused by json.dumps (Intune's
IosUpdateConfiguration.active_hours_start is declared time). Two lines in
serialize_value close it.
Quick start¶
import pandas as pd
from office365.sharepoint.client_context import ClientContext
ctx = ClientContext(site_url).with_username_and_password(tenant, client_id, username, password)
lst = ctx.web.lists.ensure_list("Stocks").execute_query()
# DataFrame -> list (streaming, bounded, resumable, idempotent)
lst.from_dataframe(pd.read_csv("stocks.csv", chunksize=2000), key=["Name", "date"]).execute_batch(concurrency=5)
# list -> DataFrame
df = lst.to_dataframe().execute_query().value
Columns¶
An export is a projection of the type, not of the response: the columns are the
type's declared ones (TypeSchema.of(T).columns), in declaration order, spelled the
way the client's service root spells them — so a stable client exports no preview column, and
every format, every page and every run answer the same headers. A name only the page
carries (a SharePoint list item's site columns, which no CSDL describes) follows,
sorted. .select(...) replaces that with the names you name — and adds to them, so a method
that selects a property it depends on (the identity a filter reads, the child to fan out over)
is not undone by the caller naming its own columns.
lst.to_csv("items.csv").execute_query() # the type's columns, then the rest
lst.select(["Title", "Modified"]).to_csv("s.csv") # exactly these two
One row per child¶
A collection-valued property is a table inside a row, and rows_from states it: the property
is added to $select, and each child becomes a row whose columns are the child's own fields.
client.users.get_licensed().select(["userPrincipalName"]).get_all().to_csv(
sys.stdout, rows_from="assignedLicenses"
).execute_query()
# userPrincipalName, skuId, disabledPlans
It exists because no projection can name a child: Graph refuses
$expand=assignedLicenses ("not a navigation property or complex property") and
$select=assignedLicenses/skuId ("Invalid property 'skuId' specified in $select"). Dotted
select fields (members/displayName) cover the other case — children the service does
expand — one record per child too, and the two cannot be combined.
A child's columns are the fields its type declares, in declaration order, so a child with an empty collection writes an empty cell rather than a shorter row. Select at least one column of the parent, or the rows say nothing about whose they are.
Every exporter takes it — to_csv, to_ndjson, to_excel, to_parquet, to_orc,
to_feather, to_sql, to_duckdb — and to_records(rows_from=...) returns the rows to a
caller instead of writing them.
The import side of the same statement reads those rows back: the format's reader, then
deserialize_records, which asks the child's schema what each column means, plus the one
column the child does not declare — its owner — to name the group. examples/entraid/licenses/import_licences.py
is that round trip.
A type states its identity above the class — @key("tenantId") — and a read always
asks for it even when .select(...) omitted it, because that column is what the next
request addresses the item by. Nine Graph types whose key the v1.0 CSDL states as
something other than id say so; everything else leaves it at the family default and
behaves as before.
Formats¶
csv, tsv, json (array), ndjson, excel, parquet, orc, feather,
dataframe — plus from_sql/to_sql and from_duckdb/to_duckdb for
databases. Optional dependencies are extras: [pandas], [excel], [parquet],
[sql], [duckdb].
Every reader/writer accepts a path, a PathLike or an open file object:
lst.from_csv("stocks.csv") # path
lst.from_csv(open("stocks.csv")) # file object
lst.to_parquet("stocks.parquet").execute_query()
Streaming databases (bounded memory):
lst.from_sql("postgresql://...", query="SELECT * FROM stocks", chunksize=5000).execute_batch(concurrency=5)
lst.from_duckdb(duckdb.connect("analytics.db"), query="SELECT * FROM stocks").execute_batch(concurrency=5)
Idempotency¶
Pass a natural key to make an import duplicate-proof: a SHA-256 hash of the
key column(s) is stored in a MigrationKey column (created automatically) and
already-present rows are skipped (on_conflict="skip") or updated
(on_conflict="update"). The key is checked on every run — fresh or resumed —
so a replayed or overlapping chunk never duplicates.
For long runs, add a checkpoint: the committed cursor is persisted after each
chunk and a source signature (format, chunk size, key columns) is recorded.
If the signature changes between runs, the chunk skip is discarded and the source
is re-scanned — the key keeps it duplicate-free. Checkpoints are written
atomically (temp file → fsync → rename), so a crash mid-save never leaves a
truncated file that would break the next resume.
The bulk pipeline and the single-entity verb share one on_conflict vocabulary
("skip" / "update"): to reconcile one entity by a natural key, use
EntityCollection.upsert(key, value, **properties) (see the
API reference).
lst.from_dataframe(
pd.read_csv("stocks.csv", chunksize=2000),
key=["Name", "date"],
checkpoint="stocks.run.json",
on_error="collect",
dead_letter="stocks.bad.jsonl",
).execute_batch(concurrency=5)
Typed columns¶
List.from_dataframe/from_records provision columns from the dtypes, or from an
explicit schema={column: FieldType}. A FieldType also drives value
coercion (multi-choice, lookup, user, URL, geolocation, ...):
from office365.sharepoint.fields.type import FieldType
lst.from_dataframe(
df,
schema={
"Status": FieldType.Choice,
"Tags": FieldType.MultiChoice, # "a; b" or a list
"Owner": FieldType.User, # email or {LookupId}
"Link": FieldType.URL, # "https://..." or (url, description)
},
)
Typing a cell¶
A Graph entity declares its property types, so an import types its cells for free.
Where nothing declares the type — a list item, an undocumented column, a spelling
that differs — pass converters={column: converter} to any from_*/queue_records.
One converter is one callable, spelled four ways:
lst.from_csv(
"stocks.csv",
converters={
"Traded": "Edm.DateTimeOffset", # a wire type name
"Price": Decimal, # a Python type
"Notes": lambda cell: cell.strip() or None, # a callable
},
).execute_batch()
lst.from_dataframe(df, schema={"Tags": FieldType.MultiChoice}) # FieldType is one too
Rules worth knowing:
- A
converterskey is the destination key — the namemapleft it. A key naming a column the type does not declare raises, so a typo cannot silently mean "the column was dropped". Targets that accept arbitrary columns (list items) are exempt. - The converter wins over the declared type, and runs once. It tolerates an
already-typed cell (
try_int(30) → 30), so re-importing typed data is safe. - Nothing is guessed. A column with no
convertersentry and no declared type is left exactly as the reader produced it —30stays anint,"abc"stays astr."18/2111"is a room number, not a date. - Per-cell vs per-row.
converterstypes one cell;mapreshapes the batch (one row into several, several into one).
Reshaping a row¶
A file's columns are rarely the payload. map= is the one reshape step: a function of
list[dict] to list[dict] that every format hands plain records (never its own chunk),
so it says what the request carries without knowing where the rows came from:
client.users.from_csv(
"users.csv",
map=lambda rows: [
{
"userPrincipalName": f"{r['userName']}@contoso.com", # computed
"mailNickname": r["userName"],
"accountEnabled": True, # a constant
"city": r["city"], # a source column
"passwordProfile/password": r["password"], # a nested path
}
for r in rows
],
).execute_batch()
A key map does not build is not sent — which is why userName above never reaches
the payload — and a column with no declared type and no converters entry is left
exactly as the reader produced it. map is the escape hatch for shape: typing a
column stays with converters, which is declarative, so it can also be reused, checked
and applied by a reader that never sees a callable. The two compose: map first, then
converters, then the declared dtype.
A lambda is never the schema. It cannot travel to the writer, cannot be pushed down into the reader, and cannot be reused by a migration — which is why the reshape is an option on the import verb and never the statement of the import.
Two shapes of sink¶
A file import is the same three stages every time; only the sink differs:
| Stage | Into a collection (rows are items to create) | Into a verb (rows are its arguments) |
|---|---|---|
| read + type | client.users.from_csv(path), lst.from_csv(path) |
deserialize_records(AssignedLicense, path, format="csv") |
| shape, if the row is not the payload | map= — one function of records |
{"userPrincipalName": …, "addLicenses": [licence]} |
| send | .execute_query() / .execute_batch() |
assign_licences(rows) + execute_batch(), or any verb |
from_csv is the packaged form of the first column — reader, type and sink in one call — and it
exists only where the service has a collection that creates items. assignedLicenses does not:
it is read-only, and assignLicense takes the licence list as an argument, so a
target.from_csv(...) there would pretend the service has something it does not. The verb is
bulk instead, deferred like every other write:
rows = []
for record in deserialize_records(AssignedLicense, "licences.csv", format="csv", allow_unknown=True):
upn = record.pop("userPrincipalName") # the one column the licence does not declare
rows.append({"userPrincipalName": upn, "addLicenses": [AssignedLicense().hydrate(record)]})
client.users.assign_licences(rows)
client.execute_batch()
userPrincipalName is the tell: it belongs to the user, not to the licence, so the row is a
licence plus its owner — which is why the type cannot be the whole schema here.
allow_unknown=True keeps that column, hydrate types the rest through the same set_property
path a collection sink uses, and the row becomes the verb's own body: the address, plus
addLicenses and removeLicenses exactly as Graph names them.
deserialize_records is what from_csv applies too, so a csv read yields the same rows whether
or not a collection is attached; the collection verb adds the sink, nothing else.
Files (document libraries)¶
folder = ctx.web.lists.get_by_title("Documents").root_folder
# DataFrame -> library file (content)
folder.write_dataframe("stocks.csv", df).execute_query()
# library file -> DataFrame (content)
df = folder.read_dataframe("stocks.csv").execute_query().value
df = file.read_dataframe(format="parquet").execute_query().value
# library file -> list (streaming, idempotent)
lst.from_file("Shared Documents/stocks.csv", key=["Name", "date"]).execute_batch()
CSV is written UTF-8 with a BOM so Excel keeps the columns.
Streaming export¶
Export a large list without loading it all:
page_size streams appendable formats page by page; other formats fall back to a
whole-collection write.
From async code the same export is awaitable — each page is fetched and written
on the worker pool, so the event loop stays free (async guide):
Errors & verification¶
on_error="collect"records a failing chunk and continues. With adead_letterpath the chunk is executed record-by-record, so each failing row is appended as{"row", "error", "record"}for remediation.- Verify a keyed import against the target:
report = lst.verify(df, key=["Name", "date"])
assert report.ok, report.summary() # counts + missing keys
Migration parity¶
The streaming import and the migration toolkit share the engine (keyed_queue,
UpsertTarget, Progress, OperationStats) and one VerificationReport:
result = lst.from_dataframe(df, key=["id"])
result.run(concurrency=5) # == execute_batch
report = result.verify(df, key=["id"])
Async¶
The import terminals gain awaitable twins — the same chunking, checkpointing,
dead-letter and progress semantics, only the wait differs:
result = lst.from_dataframe(df, key=["id"])
await result.execute_query_async() # sequential, awaited
await result.execute_batch_async(concurrency=5) # server-side batches
await result.run_async(concurrency=5) # == execute_batch_async
async for chunk in result: mirrors the sync iteration for callers that drive
execution themselves.