From dataverse
Query, create, update, and bulk-import Dataverse records using the official Python SDK. Includes data profiling, CSV import, and Jupyter notebook patterns. Use when: "use the SDK", "query records", "create records", "bulk operations", "upsert", "Python script for Dataverse", "read data", "write data", "upload file", "bulk import", "CSV import", "load data", "data profiling", "data quality", "analyze data", "Jupyter notebook", "pandas", "notebook". Do not use when: creating forms/views (use dv-metadata with Web API), exporting solutions (use dv-solution with PAC CLI).
npx claudepluginhub anthropics/claude-plugins-official --plugin dataverseThis skill uses the workspace's default tool permissions.
> **This skill uses Python exclusively.** Do not use Node.js, JavaScript, or any other language for Dataverse scripting. If you are about to run `npm install` or write a `.js` file, STOP — you are going off-rails. See the overview skill's Hard Rules.
Guides Payload CMS config (payload.config.ts), collections, fields, hooks, access control, APIs. Debugs validation errors, security, relationships, queries, transactions, hook behavior.
Builds scalable data pipelines, modern data warehouses, and real-time streaming architectures using Spark, dbt, Airflow, Kafka, and cloud platforms like Snowflake, BigQuery.
Builds production Apache Airflow DAGs with best practices for operators, sensors, testing, and deployment. For data pipelines, workflow orchestration, and batch job scheduling.
This skill uses Python exclusively. Do not use Node.js, JavaScript, or any other language for Dataverse scripting. If you are about to run
npm installor write a.jsfile, STOP — you are going off-rails. See the overview skill's Hard Rules.
Use the official Microsoft Power Platform Dataverse Client Python SDK for data operations and basic table management in scripts and automation.
Official SDK: https://github.com/microsoft/PowerPlatform-DataverseClient-Python
PyPI package: PowerPlatform-Dataverse-Client (this is the only official one — do not use dataverse-api or other unofficial packages)
Status: Preview — breaking changes are possible
pip install --upgrade PowerPlatform-Dataverse-Client
If MCP tools are available (list_tables, describe_table, read_query, create_record) and the task is a simple query, single-record read, or small CRUD (≤10 records), use MCP directly — no script needed. Only write a Python script when the task requires bulk operations, multi-step logic, schema creation, or analytics.
If an operation is in the "supports" list below, you MUST use the SDK — not urllib, requests, or raw HTTP. This is non-negotiable.
Decision flow:
$ref, or $apply? → Use raw Web API (SDK doesn't support these)Correct imports for SDK scripts:
from auth import get_credential, load_env
from PowerPlatform.Dataverse.client import DataverseClient
WRONG — do NOT use for SDK-supported operations:
from auth import get_token, load_env # WRONG for SDK-supported ops
import requests # WRONG for SDK-supported ops
get_token() and requests exist ONLY for the narrow set of operations listed in "What This SDK Does NOT Support" below.
CreateMultiple, UpdateMultiple, UpsertMultipleselect, filter, orderby, expand, top, paging?sql= parameter)tables.get(), tables.list())These are the ONLY operations where raw Web API (get_token() + urllib/requests) is acceptable:
dv-metadata)$ref POST — distinct from N:N relationship creation, which the SDK handles)$expand (collection-valued navigation) — use the Web API directly$apply aggregation — use the Web API directly (or pull data with SDK + pandas)InstallSampleData) — use the Web API directlyFor anything not in this list, use the SDK. For anything in this list, use scripts/auth.py get_token() for token acquisition.
Dataverse uses two different naming conventions for properties. Getting this wrong causes 400 errors.
| Property type | Name convention | Example | When used |
|---|---|---|---|
| Structural (columns) | LogicalName (always lowercase) | new_name, new_priority | $select, $filter, $orderby, record payload keys |
| Navigation (relationships / lookups) | Navigation Property Name (case-sensitive, must match $metadata) | new_AccountId, new_DepartmentId | $expand, @odata.bind annotation keys |
$select, $filter, $orderby: always lowercase logical names (new_name, new_priority)$expand navigation properties: Navigation Property Name, case-sensitive (new_AccountId, new_DepartmentId)@odata.bind keys: Navigation Property Name, case-sensitive (new_AccountId@odata.bind)@odata.bind keys preserve the navigation property casingThe SDK handles this correctly: it lowercases structural property keys but preserves @odata.bind key casing.
import os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_credential, load_env
from PowerPlatform.Dataverse.client import DataverseClient
load_env()
client = DataverseClient(
base_url=os.environ["DATAVERSE_URL"],
credential=get_credential(),
)
get_credential() returns ClientSecretCredential (if CLIENT_ID + CLIENT_SECRET are in .env) or DeviceCodeCredential (interactive fallback). See scripts/auth.py.
guid = client.records.create("new_projectbudget", {
"new_name": "Q1 Marketing Budget",
"new_amount": 75000.00,
"new_status": 100000000,
# Lookup binding — see "@odata.bind rules" section below
"new_AccountId@odata.bind": "/accounts(<account-guid>)",
})
print(f"Created: {guid}")
Lookup binding (@odata.bind) notes:
"new_status": 100000000 (not "Draft").EntityDefinitions(LogicalName='...')/Attributes.for page in client.records.get(
"new_projectbudget",
select=["new_name", "new_amount", "new_status"],
filter="new_status eq 100000000",
orderby=["new_name asc"],
top=50,
):
for r in page:
print(r["new_name"], r["new_amount"])
record = client.records.get("new_projectbudget", "<record-guid>",
select=["new_name", "new_amount"])
print(record["new_name"])
client.records.update("new_projectbudget", "<record-guid>",
{"new_status": 100000001})
client.records.delete("new_projectbudget", "<record-guid>")
records = [{"new_name": f"Budget {i}"} for i in range(100)]
guids = client.records.create("new_projectbudget", records)
print(f"Created {len(guids)} records")
client.records.update("new_projectbudget",
[id1, id2, id3],
{"new_status": 100000001})
from PowerPlatform.Dataverse.models.upsert import UpsertItem
client.records.upsert("account", [
UpsertItem(
alternate_key={"accountnumber": "ACC-001"},
record={"name": "Contoso Ltd", "description": "Primary account"},
),
])
from enum import IntEnum
class BudgetStatus(IntEnum):
DRAFT = 100000000
APPROVED = 100000001
info = client.tables.create(
"new_ProjectBudget",
{
"new_Amount": "decimal",
"new_Status": BudgetStatus,
},
solution="MySolution",
primary_column="new_Name",
)
print(f"Created: {info['table_schema_name']}, entity set: {info['entity_set_name']}")
created = client.tables.add_columns("new_projectbudget", {
"new_Notes": "string",
"new_Active": "bool",
})
result = client.tables.create_lookup_field(
referencing_table="new_projectbudget",
lookup_field_name="new_AccountId",
referenced_table="account",
display_name="Account",
solution="MySolution",
)
print(f"Created lookup: {result.lookup_schema_name}")
# The nav property for @odata.bind is the lookup's Navigation Property Name (case-sensitive)
from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata
result = client.tables.create_many_to_many_relationship(
ManyToManyRelationshipMetadata(
schema_name="new_employee_project",
entity1_logical_name="new_employee",
entity2_logical_name="new_project",
),
solution="MySolution",
)
When creating or updating a record that sets a lookup field, you must use @odata.bind with the correct navigation property name. Getting this wrong is the #1 cause of 400 errors.
<NavigationPropertyName>@odata.bind = "/<EntitySetName>(<target-guid>)"
$metadata. For custom lookups this is typically the SchemaName (e.g., new_AccountId). Do NOT use the lowercase logical name.accounts, contacts).| Lookup field | Correct @odata.bind key | Wrong |
|---|---|---|
new_AccountId (custom) | new_AccountId@odata.bind | new_accountid@odata.bind |
customerid (system polymorphic) | customerid_account@odata.bind | customerid@odata.bind |
parentcustomerid (system) | parentcustomerid_account@odata.bind | _parentcustomerid_value@odata.bind |
result.lookup_schema_name is the navigation property name.GET /api/data/v9.2/EntityDefinitions(LogicalName='<entity>')/ManyToOneRelationships?$select=ReferencingEntityNavigationPropertyName,ReferencedEntity
new_AccountId). Always verify against $metadata if unsure.When using $select to include a lookup column in query results, use the _<logicalname>_value format:
# Correct — lookup value in $select uses underscore-wrapped format
for page in client.records.get("opportunity",
select=["name", "estimatedvalue", "_parentaccountid_value"],
top=10,
):
for r in page:
account_id = r.get("_parentaccountid_value")
account_name = r.get("_parentaccountid_value@OData.Community.Display.V1.FormattedValue")
To get the full related record instead, use $expand:
for page in client.records.get("opportunity",
select=["name", "estimatedvalue"],
expand=["parentaccountid"], # system nav props are lowercase
top=10,
):
for r in page:
account = r.get("parentaccountid")
if account:
print(account["name"])
Note: System table navigation properties (e.g.,
parentaccountid,ownerid) are lowercase. Custom lookup navigation properties are case-sensitive and must match$metadata(e.g.,new_AccountId). When in doubt, query the entity'sManyToOneRelationshipsmetadata.
Use the correct navigation property names (case-sensitive, must match $metadata):
# Expand multiple lookups — e.g., project budgets with account and department details
for page in client.records.get(
"new_projectbudget",
select=["new_name", "new_amount", "new_status"],
filter="new_status eq 100000001",
expand=["new_AccountId", "new_DepartmentId"],
orderby=["new_amount desc"],
):
for budget in page:
acct = budget.get("new_AccountId") or {}
dept = budget.get("new_DepartmentId") or {}
print(f"{budget['new_name']}: {acct.get('name', '')} / {dept.get('new_name', '')}")
Important:
expanduses the navigation property name (case-sensitive, e.g.,new_AccountId), not the lowercase logical name (new_accountid). Using lowercase causes a 400 error.
The SDK does not support $expand on N:N collection-valued navigation properties. This is one of the few cases where raw Web API is required:
# Web API required — SDK does not support N:N $expand
import urllib.request, json
from auth import get_token # get_token() is correct here — SDK can't do this
token = get_token()
url = f"{env}/api/data/v9.2/new_projectdocuments?$select=new_title&$expand=new_ProjectBudget_Documents($select=new_name)"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json",
})
with urllib.request.urlopen(req) as resp:
articles = json.loads(resp.read())["value"]
Use the SDK for all queries except N:N expand. Do not fall back to
requestsorurllibfor standard queries, lookups, or 1:N expand — the SDK handles these correctly.
from PowerPlatform.Dataverse.core.errors import HttpError
try:
guid = client.records.create("account", {"name": "Contoso"})
except HttpError as e:
print(f"Status {e.status_code}: {e.message}")
if e.details:
print(f"Details: {e.details}")
if e.status_code == 400:
# Bad request — check field names, @odata.bind format, required fields
pass
elif e.status_code == 403:
# Permission denied — check security roles
pass
elif e.status_code == 404:
# Table or record not found
pass
elif e.status_code == 429:
# Rate limited — the SDK handles retry automatically,
# but if you hit this, reduce batch sizes or add delays
pass
For loading records from a CSV file, use the SDK directly — it handles batching via CreateMultiple internally.
| Volume | Tool | Why |
|---|---|---|
| 1–10 records | MCP create_record | Simple, conversational |
| 10+ records | Python SDK client.records.create(table, list) | Built-in batching, error handling, retry |
import csv, os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_credential, load_env
from PowerPlatform.Dataverse.client import DataverseClient
from PowerPlatform.Dataverse.core.errors import HttpError
load_env()
client = DataverseClient(
base_url=os.environ["DATAVERSE_URL"],
credential=get_credential(),
)
# Read CSV
with open("data/tickets.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
# Map CSV columns to Dataverse fields
records = []
for row in rows:
records.append({
"new_name": row["Title"],
"new_description": row["Description"],
"new_priority": int(row["Priority"]),
# Lookup binding — use navigation property name (case-sensitive)
"new_AccountId@odata.bind": f"/accounts({row['AccountGuid']})",
})
# Bulk create — SDK uses CreateMultiple internally
guids = client.records.create("new_ticket", records)
print(f"Created {len(guids)} tickets")
Before bulk-creating records in a system table (account, contact, opportunity), create a single test record first:
client.records.create("account", {...})HttpError with status 400, the error message names the missing required fielddescribe_table) and only triggered by certain field combinationsFor simple aggregation, pull data with the SDK and use pandas (preferred). For server-side $apply aggregation, the SDK does not support it — use the Web API:
# Web API required — SDK does not support $apply
import os, json, urllib.request
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_token, load_env # get_token() is correct here — SDK can't do this
load_env()
env = os.environ["DATAVERSE_URL"].rstrip("/")
token = get_token()
# Example: count opportunities by status
url = f"{env}/api/data/v9.2/opportunities?$apply=groupby((statuscode),aggregate($count as count))"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
"Accept": "application/json",
})
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
for row in data["value"]:
print(f"Status {row['statuscode']}: {row['count']}")
For complex analytics (duplicates, cross-table joins, filtered aggregates), pull data into pandas:
import pandas as pd
# Pull all records into a DataFrame
all_records = []
for page in client.records.get("opportunity",
select=["name", "estimatedvalue", "statuscode", "_parentaccountid_value"],
):
all_records.extend(page)
df = pd.DataFrame(all_records)
# Now use pandas for analysis: groupby, pivot, merge, duplicates, etc.
print(df.groupby("statuscode")["estimatedvalue"].sum())
For interactive analysis with visualizations.
pip install --upgrade PowerPlatform-Dataverse-Client pandas matplotlib seaborn azure-identity
# Cell 1: Setup
import os
from azure.identity import InteractiveBrowserCredential
from PowerPlatform.Dataverse.client import DataverseClient
credential = InteractiveBrowserCredential()
client = DataverseClient(
base_url="https://<org>.crm.dynamics.com", # replace with your URL
credential=credential,
)
# Cell 2: Load data into pandas
import pandas as pd
accounts = []
for page in client.records.get("account",
select=["name", "industrycode", "revenue", "numberofemployees"],
):
accounts.extend(page)
df_accounts = pd.DataFrame(accounts)
# Cell 3+: Analysis with pandas, matplotlib, seaborn
When writing Python scripts on Windows (especially in Git Bash / Claude Code):
SyntaxError on Windows.python -c for anything beyond trivial one-liners. Write a .py file instead — multiline python -c commands break on Windows due to quoting differences.str(uuid.uuid4()) inside the script rather than backtick-substitution in shell commands.