Microsoft Planner is embedded into how many teams track work, especially when used together with Microsoft Teams. When it’s time to move plans between tenants or reorganize projects, though, there is still no native, end‑to‑end Planner migration tool from Microsoft. Admins end up stitching together PowerShell and Microsoft Graph calls to export and recreate plans by hand.
If you want a high‑level view of all your options before diving into scripting, check out our Planner migration overview.
We also explain in detail why Microsoft has never shipped a native cross‑tenant Planner migration feature in our article on why there is no native Planner migration.
In this deep‑dive, we’ll walk through how to build your own Planner migration using the Microsoft Graph API and PowerShell. You’ll see the exact endpoints involved, how to authenticate with Microsoft Entra ID, how to export plans and tasks, and how to recreate them in a new tenant. We’ll also be honest about the hard parts: rate throttling, comments, and attachments that make manual scripting painful at scale and where a dedicated tool like Apps4.Pro Migration Manager is a better fit.
- What a DIY Planner migration really means
- Planner Graph API endpoints you’ll use
- Authentication: Entra ID app registration and OAuth 2.0
- Using the Microsoft.Graph.Planner PowerShell module
- Step‑by‑step: export and recreate a Planner plan
- Comments and attachments: the hard edges
- Rate limiting and throttling (why 120 requests/min matters)
- Why manual Graph + PowerShell doesn’t scale forever
- When to use a dedicated Planner migration tool
1. What a DIY Planner migration really means
When you say “Planner migration using Graph API,” what you’re really talking about is:
- Enumerating all relevant plans in a source tenant or team.
- Exporting their structure and content (buckets, tasks, details).
- Recreating equivalent plans in a destination tenant or team.
- Mapping users, permissions, and linked resources along the way.
At a minimum, you need to reproduce:
- Plans: plan name, associated Microsoft 365 Group / Team, and basic settings.
- Buckets: all buckets in each plan, with their names and order.
- Tasks: titles, descriptions, assignments, labels, checklists, start/due dates, and completion status.
- Optional extras: comments, attachments (usually SharePoint / OneDrive links), and activity history.
In a cross‑tenant scenario, the high‑level pattern is:
- Connect to source tenant.
- Export plans → buckets → tasks → task details.
- Connect to destination tenant.
- Recreate plans → buckets → tasks → task details, using new IDs and destination user accounts.
If you’d like a broader context of where Planner fits in a full Microsoft 365 migration, the Planner migration overview walks through the big picture.
2. Planner Graph API endpoints you’ll use
If you’re new to Planner migrations, we recommend quickly reviewing our Planner migration overview and then coming back here for the low‑level Graph details.
The core of a Planner migration is the Microsoft Graph Planner API. Here are the main endpoints your scripts will rely on:
2.1 Core Planner endpoints
- List plans for a group
Use this to find all Planner plans attached to a specific Microsoft 365 Group or Team.
- Get a plan’s tasks
GET /planner/plans/{plan-id}/tasks
This is your primary “export all tasks in a plan” endpoint.
- Get buckets for a plan
GET /planner/plans/{plan-id}/buckets
Use this to preserve the structure into which tasks are grouped.
- Get task details
GET /planner/tasks/{task-id}/details
This is where you pull description, checklist items, references (links), and other richer metadata that isn’t in the basic task object.
- Create a task
POST /planner/tasks
This is how you create tasks in the destination plan.
- Update a task or task details
- PATCH /planner/tasks/{id}
- PATCH /planner/tasks/{id}/details
You’ll need these to set fields that aren’t available at create time, and to push descriptions, checklists, and references after the task exists. Both require the correct ETag via If-Match header.
2.2 Supporting Graph endpoints
While Planner endpoints do most of the heavy lifting, you inevitably touch other Graph surfaces:
- Groups & Teams
- GET /groups
- GET /groups/{id}
For discovering which groups/teams host the plans you want to migrate and for creating a destination plan attached to the right group.
- Users
- GET /users
You’ll need this to map source users to destination users, especially in cross‑tenant migrations, so task assignments stay meaningful.
- Files / attachments Attachments often live in SharePoint or OneDrive. Moving them may require:
- SharePoint and OneDrive Graph endpoints to copy files.
- Updating Planner task details with new reference URLs.
3. Authentication: Entra ID app registration and OAuth 2.0
Before you can call the Graph API, you need an app registration in Microsoft Entra ID (formerly Azure AD) and a way to obtain tokens securely.
3.1 Register an app in Microsoft Entra ID
- In the Azure portal, go to Microsoft Entra ID → App registrations → New registration.
- Give your app a friendly name (e.g., PlannerMigrationTool) and register it as a single tenant app, unless you have a multi‑tenant scenario.
- Note down:
- Application (client) ID
- Directory (tenant) ID
- Under Certificates & secrets, create a client secret, copy it immediately, and store it securely (Key Vault, secret manager, etc.).
- Under API permissions, add Microsoft Graph permissions:
- For reading/writing Planner: Group.Read.All, Tasks.ReadWrite, or broader like Tasks.ReadWrite.All / Group.ReadWrite.All.
- For user resolution: User.Read.All.
- Decide on permission type:
- Application permissions (app‑only): Best for unattended migration jobs run by admins.
- Delegated permissions: Used when running as a signed‑in user; less common for large migrations.
Make sure you grant admin consent for the app once permissions are added.
3.2 Getting a token in PowerShell
With the modern Microsoft Graph PowerShell SDK, authenticating with app‑only is straightforward:
# Variables from your app registration
$TenantId = "<your-tenant-id>"
$ClientId = "<your-client-id>"
$ClientSecret = "<your-client-secret>"
Install-Module Microsoft.Graph -Scope CurrentUser
Import-Module Microsoft.Graph
Connect-MgGraph -TenantId $TenantId `
-ClientId $ClientId `
-ClientSecret $ClientSecret `
-Scopes "https://graph.microsoft.com/.default"
Select-MgProfile -Name "v1.0"
All subsequent calls via the Graph SDK (or Invoke-MgGraphRequest) will automatically carry the access token.
If you prefer raw REST with Invoke-RestMethod, you can also obtain a token via a client‑credentials OAuth flow and supply it in the Authorization: Bearer <token> header.
4. Using the Microsoft.Graph.Planner PowerShell module
The Microsoft Graph PowerShell SDK breaks functionality into service‑specific modules, including one for Planner. It doesn’t cover every Planner feature, but it does save you from hand‑crafting many URLs.
4.1 Installing and loading the module
Install-Module Microsoft.Graph -Scope CurrentUser
Import-Module Microsoft.Graph.Planner
Common cmdlets you’ll likely use:
- Get-MgGroupPlannerPlan – list plans associated with a group.
- Get-MgPlannerPlanTask – list tasks in a plan.
- New-MgPlannerTask – create tasks.
- Get-MgPlannerTaskDetail / Update-MgPlannerTaskDetail – manage task details.
Where Planner support is missing or limited, you can fall back to Invoke-MgGraphRequest or Invoke-RestMethod with raw URLs.
4.2 Example: enumerate plans and tasks
This example lists all groups, their plans, and each task’s basic info:
# Assume Connect-MgGraph already run
# Get all groups (filter in real scripts to narrow scope)
$groups = Get-MgGroup -All -Property Id,DisplayName
foreach ($group in $groups) {
$plans = Get-MgGroupPlannerPlan -GroupId $group.Id
foreach ($plan in $plans) {
Write-Host "Plan:" $plan.Title "in group" $group.DisplayName
$tasks = Get-MgPlannerPlanTask -PlannerPlanId $plan.Id
foreach ($task in $tasks) {
Write-Host " Task:" $task.Title "Status:" $task.PercentComplete
}
}
}
This pattern is the backbone of any Planner inventory or export script.
For more advanced inventory generation (CSV/JSON export, filters, scheduling), you can also leverage the PowerShell scripts discussed in your existing knowledge‑base article on generating inventory.
5. Step‑by‑step: export and recreate a Planner plan
Let’s walk through a concrete migration flow. We’ll assume a cross‑tenant scenario, but the same steps apply within a single tenant.
5.1 Step 1 – Build your inventory
Before migrating anything, you should know what you have. A good inventory includes:
- Group name and ID.
- Plan name and ID.
- Bucket names and IDs.
- Task IDs, titles, statuses, due dates, and assigned users.
A simple pattern:
- Enumerate groups and plans (as in the previous section).
- For each plan, pull buckets and tasks.
- Save it all to a structured JSON file you can inspect and later feed into the migration flow.
Example sketch:
$inventory = @()
foreach ($group in $groups) {
$plans = Get-MgGroupPlannerPlan -GroupId $group.Id
foreach ($plan in $plans) {
$buckets = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/planner/plans/$($plan.Id)/buckets"
$tasks = Get-MgPlannerPlanTask -PlannerPlanId $plan.Id
$inventory += [PSCustomObject]@{
GroupId = $group.Id
GroupName = $group.DisplayName
PlanId = $plan.Id
PlanTitle = $plan.Title
Buckets = $buckets.value
Tasks = $tasks
}
}
}
$inventory | ConvertTo-Json -Depth 5 | Set-Content ".\planner-inventory.json"
If you don’t want to build this from scratch, download and adapt our PowerShell scripts to generate inventory , which are designed for pre‑migration reporting across Microsoft 365 tenants.
5.2 Step 2 – Export one plan’s structure and details
When you’re ready to move a specific plan, zoom in:
$planId = "<source-plan-id>"
$headers = @{ Authorization = "Bearer $((Get-MgContext).AccessToken)" }
# Buckets
$bucketsUrl = "https://graph.microsoft.com/v1.0/planner/plans/$planId/buckets"
$buckets = (Invoke-RestMethod -Headers $headers -Uri $bucketsUrl -Method GET).value
# Tasks
$tasksUrl = "https://graph.microsoft.com/v1.0/planner/plans/$planId/tasks"
$tasks = (Invoke-RestMethod -Headers $headers -Uri $tasksUrl -Method GET).value
$export = @()
foreach ($task in $tasks) {
$detailsUrl = "https://graph.microsoft.com/v1.0/planner/tasks/$($task.id)/details"
$details = Invoke-RestMethod -Headers $headers -Uri $detailsUrl -Method GET
$export += [PSCustomObject]@ {
Task = $task
Details = $details
}
}
$export | ConvertTo-Json -Depth 5 | Set-Content ".\plan-$planId-export.json"
$buckets | ConvertTo-Json -Depth 5 | Set-Content ".\plan-$planId-buckets.json"
Now you have a full snapshot of a plan’s structure and task content that you can recreate in the destination.
5.3 Step 3 – Create destination group, plan, and buckets
In the destination tenant:
- Authenticate using the destination app registration.
- Decide if you’ll attach the new plan to an existing group or create a new one.
- Create buckets with the same names and order.
Example sketch using REST:
# Assume you have $destHeaders with Authorization
$destGroupId = "<destination-group-id>"
# Create plan
$planBody = @{
owner = $destGroupId
title = "Migrated Plan - Sales FY26"
} | ConvertTo-Json
$planUrl = "https://graph.microsoft.com/v1.0/planner/plans"
$newPlan = Invoke-RestMethod -Headers $destHeaders -Uri $planUrl -Method POST -Body $planBody
# Recreate buckets
$bucketMap = @{} # sourceBucketId -> destBucketId
foreach ($sourceBucket in $sourceBuckets) {
$bucketBody = @{
name = $sourceBucket.name
planId = $newPlan.id
orderHint = $sourceBucket.orderHint
} | ConvertTo-Json
$bucketUrl = "https://graph.microsoft.com/v1.0/planner/buckets"
$newBucket = Invoke-RestMethod -Headers $destHeaders -Uri $bucketUrl -Method POST -Body $bucketBody
$bucketMap[$sourceBucket.id] = $newBucket.id
}
The bucketMap table is critical so you can place tasks into the right bucket.
5.4 Step 4 – Recreate tasks with POST /planner/tasks
Tasks are usually migrated in two phases:
- Create the bare task (title, bucket, plan, assignees, dates).
- Update details (description, checklist, references).
Example create call:
foreach ($sourceEntry in $export) {
$sourceTask = $sourceEntry.Task
$sourceDetails = $sourceEntry.Details
$destBucketId = $bucketMap[$sourceTask.bucketId]
# Map user IDs between tenants (simple example)
$assignmentMap = @{}
foreach ($key in $sourceTask.assignments.PSObject.Properties.Name) {
$destUserId = $userMap[$key] # you maintain $userMap separately
if ($destUserId) {
$assignmentMap[$destUserId] = @{ "@odata.type" = "microsoft.graph.plannerAssignment" }
}
}
$taskBody = @{
planId = $newPlan.id
bucketId = $destBucketId
title = $sourceTask.title
assignments = $assignmentMap
startDateTime = $sourceTask.startDateTime
dueDateTime = $sourceTask.dueDateTime
} | ConvertTo-Json
$taskUrl = "https://graph.microsoft.com/v1.0/planner/tasks"
$newTask = Invoke-RestMethod -Headers $destHeaders -Uri $taskUrl -Method POST -Body $taskBody
# Now push details
$detailsBody = @{
description = $sourceDetails.description
checklist = $sourceDetails.checklist
references = $sourceDetails.references
} | ConvertTo-Json
# Get current details and ETag
$newDetailsUrl = "https://graph.microsoft.com/v1.0/planner/tasks/$($newTask.id)/details"
$newDetailsResp = Invoke-WebRequest -Headers $destHeaders -Uri $newDetailsUrl -Method GET
$newDetails = $newDetailsResp.Content | ConvertFrom-Json
$etag = $newDetailsResp.Headers["ETag"]
$patchHeaders = $destHeaders.Clone()
$patchHeaders["If-Match"] = $etag
Invoke-RestMethod -Headers $patchHeaders -Uri $newDetailsUrl -Method PATCH -Body $detailsBody
}
A few important notes:
- You must specify the current ETag in If-Match when updating task details.
- User mapping between tenants (for assignments) typically comes from a CSV or directory lookup that you maintain separately.
6. Comments and attachments: the hard edges
The pieces above already require careful scripting, but two areas make Planner migration especially tricky.
6.1 Comments
Planner comments:
- Are not simple fields on the task object.
- Traditionally lived in the associated group mailbox as conversations, not in the Planner task record itself.
- Have only partial, evolving Graph support for pulling them in a structured way.
Implications:
- Many DIY scripts do not migrate comments at all.
- Others export comments via mailbox or audit APIs and paste them into the task description or attach them as a text file.
- Implementing a robust comment migration path significantly increases script complexity and test overhead.
If comments are a “nice to have” for your business, you might explicitly call out in your script output which plans have comments and warn users that they won’t be moved 1:1.
6.2 Attachments
Planner attachments are rarely simple files attached directly to a task. They are usually:
- SharePoint or OneDrive file references.
- External URLs.
To migrate them fully, you typically need to:
- Identify the underlying storage location (SharePoint site, OneDrive, etc.).
- Copy or move the file to a destination site or tenant using SharePoint/OneDrive Graph APIs.
- Update the Planner task details in the destination to point to the new file location.
This introduces:
- Additional authentication scopes and app permissions.
- Asynchronous operations (file copy jobs that take time to complete).
- More potential points of failure and throttling.
For that reason, many homegrown scripts either:
- Only move the link (if both tenants can see the source content), or
- Ignore attachments altogether, or
- Handle them only for specific, high‑value plans.
If you need full‑fidelity migration of attachments and comments with minimal scripting, Apps4.Pro Migration Manager for Planner is designed to cover those scenarios.
7. Rate limiting and throttling (why 120 requests/min matters)
Microsoft Graph applies throttling at several layers (service, tenant, app). Planner is no exception, and migrations tend to stress those limits because each plan requires a lot of calls.
In practice:
- Each plan requires dozens to hundreds of requests:
- Read buckets, read tasks, read task details.
- Create new plan, create buckets, create tasks, patch details, handle attachments.
- You can easily hit thousands of calls for a modest migration.
To avoid constant 429 Too Many Requests responses, many admins design their scripts to:
- Target a soft ceiling of around 120 requests per minute per app.
- Add a small delay between calls, such as 200–500 ms.
- Implement retry logic with exponential backoff.
A simple retry function pattern:
function Invoke-GraphWithRetry {
param(
[string]$Method,
[string]$Uri,
[hashtable]$Headers,
[object]$Body = $null
)
$maxRetries = 5
$delay = 2
for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
try {
return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $Headers -Body $Body -ErrorAction Stop
}
catch {
$response = $_.Exception.Response
if ($response -and ($response.StatusCode.value__ -eq 429 -or $response.StatusCode.value__ -ge 500)) {
$retryAfter = $response.Headers["Retry-After"]
if ($retryAfter) {
Start-Sleep -Seconds [int]$retryAfter
} else {
Start-Sleep -Seconds $delay
$delay *= 2
}
}
else {
throw
}
}
}
throw "Request to $Uri failed after $maxRetries attempts."
}
Using this for every Graph call helps your script survive transient throttling, but it also means large migrations can take hours or days, especially if you are cautious with the rate.
8. Why manual Graph + PowerShell doesn’t scale forever
For a single plan or a small set of team boards, a custom script is often fine. As the scope grows, the hidden costs become more obvious.
Many of these limitations exist because Planner was never designed with cross‑tenant migration as a first‑class scenario-something we cover in more detail in Why there is no native Planner migration.
Key limitations of DIY scripts:
- No batch endpoint for Planner
There is no native bulk export/import. You orchestrate thousands of individual HTTP calls and keep track of success/failures yourself.
- Complex mapping logic
You must maintain and persist mapping tables for:- Source vs destination group IDs.
- Plan IDs.
- Bucket IDs.
- Task IDs.
- User IDs between tenants.
- Partial API coverage : Comments, attachments, and activity history either have limited Graph coverage or live in other services (mailbox, SharePoint). You end up writing multiple mini‑migrators.
- PowerShell SDK gaps :
Microsoft.Graph.Planner simplifies some calls but doesn’t give complete coverage or a turnkey migration flow. You inevitably mix cmdlets and raw REST calls, juggling different patterns.
- Operational risk
Bugs in the script can:
- Create duplicate plans.
- Mis-assign tasks to the wrong users.
- Spam users with notifications.
- Leave you with partial migrations that are hard to roll back.
For many organizations, the tipping point comes when they need to migrate dozens or hundreds of plans or support a tenant‑to‑tenant consolidation on a tight timeline.
At that scale, building and maintaining your own migration pipeline tends to cost more time and risk than adopting a purpose‑built product like Apps4.Pro Migration Manager for Planner.
9. When to use a dedicated Planner migration tool
A dedicated migration tool such as Apps4.Pro Migration Manager for Planner uses the same underlying Graph APIs but wraps them in:
- A GUI for selecting, mapping, and scheduling plan migrations.
- Built‑in logic for ID mapping, retries, and throttling.
- Support for special cases such as:
- Teams‑linked Planner boards.
- Large batches of plans across multiple groups.
- More complete handling of comments and attachments where technically possible.
Apps4.Pro can migrate Planner plans from one tenant to another, or between different Microsoft 365 Groups/Teams in the same tenant, while preserving tasks, buckets, assignments, attachments, and comments.
A simple rule of thumb:
- If you just need to move 1–3 plans and you’re comfortable with PowerShell, the patterns in this article are usually enough.
- If you’re planning a broader tenant‑to‑tenant migration or re‑homing Planner at scale, using Apps4.Pro Migration Manager for Planner is almost always faster, safer, and cheaper than maintaining custom scripts long‑term.









