Microsoft Graph PowerShell vs PnP PowerShell: Which One to Use

[AUTHOR: open on a specific moment you hit this. A task where you installed the wrong module, or watched someone run both with two sign-in prompts stacked up for a job that touched a single SharePoint list. One or two concrete sentences.]

Every comparison of these two modules lists the same feature differences and stops exactly where the useful part starts. Which one do you install, today, for the thing in front of you.

For SharePoint work, the honest answer is not the one the comparisons set up.

The short version

SharePoint work, meaning sites, lists, libraries, permissions and content: PnP PowerShell.

Everything else in the tenant, meaning users, groups, licences, Teams, Intune, conditional access: Microsoft Graph PowerShell.

They are built completely differently

Microsoft Graph PowerShell is autogenerated from the Graph API. Every endpoint Microsoft exposes becomes a cmdlet, automatically, which is why the full umbrella runs to over forty sub-modules. It reaches the entire tenant, and the cmdlet names carry the fingerprints of the machine that generated them rather than a person who sat down to name them.

PnP PowerShell is community-built, focused on SharePoint, and its names read like English. On my machine it exposes 775 commands, every one shaped Verb-PnPNoun:

Get-PnPList
Get-PnPListItem
Set-PnPListItem
Add-PnPFile
Get-PnPSiteCollectionAdmin

You can guess most of them without looking. Writing a script at speed, one you have not written before, that guessability does more real work than any feature table will ever capture.

The support difference nobody says out loud

Microsoft Graph PowerShell is a Microsoft product. Something breaks, you have a support path and a ticket to raise.

PnP PowerShell is community-maintained. Microsoft backs the PnP initiative, but the module itself carries no official support and no ticket queue. It is excellent and it is everywhere, and none of that helps you at 2am when a production job has stopped and your organisation’s rule is that automation runs on vendor-supported tooling. Somewhere with that rule, the support line decides everything before the feature comparison even starts.

The September 2024 change is the tell. Microsoft deleted the shared app registration PnP had always used, and overnight every PnP script needed its own app registration to sign in. That is a community timeline landing on production systems, and I wrote the whole episode up in Connect-PnPOnline: Specified Method Is Not Supported.

Where each one is genuinely better

PnP wins on SharePoint depth, and it is not close. Graph can read a list and write an item. The moment the job is a permission audit, a bulk field change, a site template, or version cleanup, PnP has a command for it and Graph has you hand-assembling raw API calls. Everything in my PnP PowerShell posts would be considerably more work in Graph.

Graph wins on everything that is not SharePoint. Users, groups, licence assignment, Teams, mailboxes, devices, sign-in logs, conditional access. PnP has crept into a little of this, it even ships Get-PnPEntraIDApp and sign-in report commands, but Graph is the native home and always will be, because that is the API it is generated from.

Both do authentication properly

Neither module makes you trade away an auth method to get it. Connect-PnPOnline covers the full range:

-Interactive        yes
-DeviceLogin        yes
-ClientId           yes
-ClientSecret       yes
-CertificatePath    yes
-Thumbprint         yes
-ManagedIdentity    yes
-AccessToken        yes

Interactive when you are at the keyboard, certificate or managed identity for the unattended jobs. Connect-MgGraph covers the same ground on the Graph side.

The part that changes the question

PnP can call Graph directly. It ships with two commands for exactly that:

Invoke-PnPGraphMethod
Get-PnPGraphAccessToken

From inside a PnP session that is already connected, you can hit any Graph endpoint that has no PnP command of its own:

Invoke-PnPGraphMethod -Url "v1.0/users?`$top=5" -Method Get

Most comparisons never mention this, and it quietly dissolves the whole versus. A job that is mostly SharePoint with a little Graph in it does not need two modules, two connections and two auth contexts held in your head at once. You connect with PnP, do the SharePoint work with PnP commands, and drop to Invoke-PnPGraphMethod for the one user lookup you actually need.

So, in practice

  • Mostly SharePoint: install PnP, and reach into Graph with Invoke-PnPGraphMethod when you need to.
  • Mostly users, groups, Teams, devices: install Graph. It is the native tool and it is supported.
  • Production automation under a vendor-support rule: that rule decides it. Graph.
  • Both all day: install both. They coexist without complaint, and each is best at its own half.

Treating this as a religious choice is the thing that irritates me about how it gets argued online. They are tools with different shapes. I reach for PnP first because I live in SharePoint, and Graph stays installed for the days I do not 🙂

Find and Revoke SharePoint Sharing Links with PowerShell

Sharing links are the quietest oversharing problem in SharePoint. Somebody clicks Share, picks “anyone in the organisation”, and eighteen months later that link is still live and nobody remembers it exists. It does not show up as a normal permission. It hides as a group with a name like SharingLinks.042f4d62-d446-4d80-83d1-fdb40ce5956d.Flexible.23ce70ce, which nobody reads.

Then you turn on Copilot, and Copilot is very good at finding files people can reach. Every one of those forgotten links is now one prompt away from the wrong person.

I could not find a free tool that lists these links across a tenant, rates how risky each one is, and lets you revoke the bad ones. So I wrote one. It is on the PowerShell Gallery:

Install-Module SharingLinkAudit

It is on the PowerShell Gallery if you want to see the version and what is in the package first, and the source is on GitHub, MIT licensed. This post is how to use it.

Before you start

It needs PnP.PowerShell and your own Entra ID app registration, the same one any PnP tool needs since the shared app was retired. If you have not set that up, I wrote it up separately in Connect-PnPOnline: Specified Method Is Not Supported. Once you have the client ID, you are ready.

Audit one site

Get-SharingLink -SiteUrl "https://contoso.sharepoint.com/sites/Sales" `
    -ClientId "your-app-id" -Interactive

A browser opens, you sign in, and you get one object per link. A risky one looks like this:

ItemName    : Monthly Report.txt
ItemPath    : /sites/Sales/Shared Documents/Monthly Report.txt
Scope       : Organization
Access      : Edit
Recipients  : Everyone in the organisation
Expiration  :
HasPassword : False
RiskLevel   : High
WebUrl      : https://contoso.sharepoint.com/:t:/s/Sales/IQBiTS8ERt...
LinkId      : d695e0ac-057f-49fb-8d01-eff6bb843a44

Read that in one line: anyone in the organisation can edit this file, through a link that never expires. That is why it scored High.

If a site is clean it tells you so, rather than printing nothing and leaving you wondering whether it worked:

No sharing links found across 1 site(s).

How the risk is scored

The RiskLevel is the whole point. It is what turns a long list into a short list of things to actually deal with.

  • Anonymous “anyone with the link” is the worst starting point. Organization “anyone in the company” is a real exposure but internal. Users, specific named people, is what sharing is supposed to be.
  • Edit is worse than View.
  • No expiry counts against the broad links, because those are the ones that linger.
  • An anonymous link with no password is a plain open door.

So a specific-person view link is Low, an org-wide edit link with no expiry is High, and an anonymous no-password edit link is Critical. You filter to what matters:

Get-SharingLink -SiteUrl $url -ClientId $id -Interactive -MinimumRisk High

Audit the whole tenant

This is the one people actually want. It needs SharePoint admin and the admin URL:

Get-SharingLink -TenantWide `
    -TenantAdminUrl "https://contoso-admin.sharepoint.com" `
    -ClientId "your-app-id" -Interactive -MinimumRisk High

It walks every site in the tenant. On a real tenant that is the slow mode, so it does not fight SharePoint’s throttling. If it gets a 429 it waits the amount SharePoint asks for and carries on rather than falling over.

The way it finds the links is the part worth understanding. It does not walk every file, which on a big library would be tens of thousands of calls. It reads the hidden SharingLinks groups, which tell it exactly which files have a link without touching the rest. A library of fifty thousand documents with two hundred shared items costs about two hundred lookups, not fifty thousand. That is what makes tenant-wide practical.

Turn it into a report

Nobody reads a thousand rows scrolling past. Send it to an HTML report instead:

Get-SharingLink -TenantWide -TenantAdminUrl $admin -ClientId $id -Interactive -MinimumRisk High |
    Export-SharingLinkReport -Path .\sharing.html -Html
Report generated: 14 link(s) written to C:\reports\sharing.html

That is a self-contained page, colour-coded by risk and sorted worst first with no external anything, that you can open in a browser and email to whoever asked “are we overshared”. There is a .csv option too if you want it in Excel.

[SCREENSHOT: the HTML report open in a browser, showing the colour-coded risk pills and the table of links. Take it from a run against the demo site, not a real tenant.]

Revoke the bad ones, carefully

Revoking a sharing link cannot be undone. The link is gone and whoever relied on it has to be re-shared. So always look before you leap:

Get-SharingLink -SiteUrl $url -ClientId $id -Interactive |
    Where-Object Scope -eq 'Anonymous' |
    Remove-SharingLink -WhatIf

-WhatIf shows you exactly what would be removed and deletes nothing. Read that list. Then, when you are sure, drop the -WhatIf:

Get-SharingLink -SiteUrl $url -ClientId $id -Interactive |
    Where-Object Scope -eq 'Anonymous' |
    Remove-SharingLink

Because it reads from the pipeline, you revoke exactly what you filtered, whether that is the anonymous ones, the expired ones, or everything on one site. I would not do a blanket “remove every link in the tenant”, because most of those links are shares people meant to create. Find the genuinely bad ones, agree them with the site owners, then remove that set.

Running it unattended

For a weekly report on a schedule, swap the browser sign-in for a certificate:

Get-SharingLink -TenantWide -TenantAdminUrl $admin `
    -ClientId $id -CertificatePath .\audit.pfx -Tenant contoso.onmicrosoft.com |
    Export-SharingLinkReport -Path .\weekly-sharing.csv

That version needs the app to have Sites.FullControl.All, which is a high permission. Grant it deliberately, to a dedicated app, not to the interactive one you use day to day.

That is the whole thing

Audit a site or the tenant, filter to what is risky, report it, revoke what you agree to. It found genuine org-wide edit links the first time I pointed it at a real tenant, which is rather the point. This stuff is already there, quietly, waiting for Copilot to surface it.

It is free and open source. If you find a bug or it misses something, the GitHub repo is the place. I would rather fix it than have you work around it 🙂

List Every List and Library in a SharePoint Site with PowerShell

You want a quick inventory of a SharePoint site: what lists and libraries are in it, how many items each holds, how big they are. It sounds like one command. It is, but the obvious version lies to you in two ways, and the way most people get sizes is the slow way.

Run against a real site on PnP.PowerShell 2.12.0.

Get-PnPList returns far more than you asked for

$all = Get-PnPList
$all.Count
Total lists returned : 19
Hidden               : 11
Visible              : 8

Nineteen, on a site where I created four things. The rest are the machinery SharePoint runs on: Solution Gallery, TaxonomyHiddenList, Theme Gallery, the user information list, and so on. Eleven of the nineteen are hidden.

So the first job is filtering. You want visible lists, and you want to keep it to actual lists and libraries rather than galleries and form-template stores. Two base templates cover that: 100 is a list, 101 is a document library.

Get-PnPList |
    Where-Object { -not $_.Hidden -and $_.BaseTemplate -in @(100, 101) } |
    Select-Object Title,
        @{n='Kind'; e={ if ($_.BaseTemplate -eq 101) { 'Library' } else { 'List' } }},
        ItemCount,
        @{n='LastChanged'; e={ $_.LastItemUserModifiedDate }} |
    Sort-Object Kind, Title
Title             Kind    ItemCount LastChanged
-----             ----    --------- -----------
Agent Knowledge   Library         4 22/07/2026
Project Documents Library        14 22/07/2026
Departments       List            4 18/07/2026
Projects          List           25 22/07/2026

LastItemUserModifiedDate is worth having in there. It is the last time a person changed something, which is how you spot the libraries nobody has touched in a year.

ItemCount is not the number of files

Look at Project Documents above, ItemCount 14. But there are not fourteen documents in it. I checked:

Project Documents ItemCount   : 14
Actual files                  : 8
Folders (also in ItemCount)   : 6

ItemCount counts folders as items. A library with eight files across six folders reports fourteen. If you are telling someone “this library has 14 documents”, you are wrong by however many folders it has.

For the real file count you have to ask, and filter by object type:

@(Get-PnPListItem -List 'Project Documents' -PageSize 500 |
    Where-Object { $_.FileSystemObjectType -eq 'File' }).Count

Same trap applies to a list with folders in it, which is rarer but does happen.

Size: the fast way and the slow way

This is where an inventory script quietly becomes an overnight job, so it is worth understanding the two approaches before you pick one.

The fast way: ask the site for its own number

Every site collection already knows how much storage it uses. One call gets it, but the call lives in the admin centre:

Connect-PnPOnline -Url "https://contoso-admin.sharepoint.com" -ClientId "..." -Interactive
$site = Get-PnPTenantSite -Identity "https://contoso.sharepoint.com/sites/Demo"
$site.StorageUsageCurrent
Fast: 1 MB in 805ms

One request, under a second, and it does not care whether the site has ten files or ten million. The catch is that it is a whole-site figure and it needs SharePoint admin. You cannot break it down per library this way, and you cannot run it as an ordinary site member.

The slow way: add up every file

If you need it per library, or you do not have admin, you sum the file sizes yourself:

$libraries = Get-PnPList | Where-Object { -not $_.Hidden -and $_.BaseTemplate -eq 101 }

foreach ($lib in $libraries) {
    $bytes = 0
    Get-PnPListItem -List $lib -PageSize 500 |
        Where-Object { $_.FileSystemObjectType -eq 'File' } |
        ForEach-Object { $bytes += [long]$_.FieldValues['File_x0020_Size'] }

    [pscustomobject]@{
        Library = $lib.Title
        SizeMB  = [math]::Round($bytes / 1MB, 2)
    }
}
Library           SizeMB
-------           ------
Agent Knowledge     0.07
Project Documents   0.07
...

Took : 3.7s for 5 libraries

Note the field name, File_x0020_Size, with the encoded space. That is the internal name, and it will not work as “File Size”. I wrote up why that happens in finding a list’s internal column names.

The gap is unbounded, not four times

On my tiny demo site the fast way took 805ms and the slow way 3.7 seconds, roughly four times slower. That ratio is not the point, because it does not hold.

The fast way is one call regardless of size. The slow way is one call per item. On five near-empty libraries that is nothing. On a library of fifty thousand documents it is fifty thousand round trips, and now you are measuring in minutes per library rather than seconds per site. The gap grows with every file you add.

So the rule is simple. If you have admin and you only need whole-site totals, use StorageUsageCurrent and be done in a second. Only drop to summing files when you genuinely need a per-library breakdown, and when you do, expect it to be slow and scope it to one site at a time.

The version I keep

Get-PnPList |
    Where-Object { -not $_.Hidden -and $_.BaseTemplate -in @(100, 101) } |
    Select-Object Title,
        @{n='Kind';  e={ if ($_.BaseTemplate -eq 101) { 'Library' } else { 'List' } }},
        @{n='Items'; e={ $_.ItemCount }},
        @{n='LastChanged'; e={ $_.LastItemUserModifiedDate }} |
    Sort-Object Kind, Title |
    Format-Table -AutoSize

Fast, honest about what it is showing, and no admin needed. It counts folders in the item total, so if that matters for what you are doing, add the FileSystemObjectType filter, and know that you are trading speed for accuracy 🙂

Enough PowerShell to Be Useful: Six Commands and One Idea

You do not need to learn PowerShell to run PowerShell. Most SharePoint admin scripting is six commands and one idea, and once you have those the rest is looking things up as you go.

This is those six, shown on the kind of data you actually handle: list items with a title, a status, a budget and an owner. Everything below was run on PowerShell 7.

The one idea: the pipeline moves objects, not text

This is the thing that makes PowerShell different from every shell before it, and it is the thing that makes the six commands work.

When you pipe something with |, you are not passing text along. You are passing objects, and each object has properties you can name:

$items | Get-Member -MemberType NoteProperty
Budget
Owner
Status
Title

Get-Member is how you find out what an object has. When a script somewhere returns something and you do not know what you can do with it, pipe it into Get-Member. It is the most useful command for learning that PowerShell has.

1. Where-Object: keep the rows you want

$items | Where-Object { $_.Status -eq 'Complete' }
Project 01
Project 04

$_ is “the current object”. Inside the braces it means the one item Where-Object is looking at right now.

Since PowerShell 3 there is a shorter form for simple checks:

$items | Where-Object Status -eq 'Complete'

Same result, no braces, no $_. Use the short form for one condition and the brace form when you need more than one.

The operators are words, not symbols

This trips up everyone coming from another language:

-eq  -ne  -gt  -lt  -ge  -le      not  ==  !=  >  <

The reason is that > already means "redirect output to a file" in a shell. So PowerShell spells its comparisons out. Type $a > $b expecting a comparison and you will silently create a file called $b.

Two more you will use constantly:

$items | Where-Object { $_.Title -like 'Project*' }    # wildcard
$items | Where-Object { $_.Title -match 'Q[0-9]' }     # regex

-like takes wildcards (* and ?). -match takes a regular expression. Reach for -like first; it is simpler and it is usually enough.

And one that causes real bugs, -eq is case-insensitive by default:

'Complete' -eq 'complete'    # True
'Complete' -ceq 'complete'   # False - the c prefix makes it case-sensitive

Usually the case-insensitive default is what you want. Just know it is the default, so a comparison you expected to fail on case will quietly pass.

2. Select-Object: choose columns, or take the first few

$items | Select-Object Title, Budget -First 3
Title      Budget
-----      ------
Project 01   1000
Project 02   2000
Project 03   3000

Two jobs in one command: pick which properties you want, and -First / -Last to take a slice.

The one worth knowing separately is -ExpandProperty. Plain Select gives you objects with one property. ExpandProperty gives you the raw values:

$items | Select-Object -ExpandProperty Owner -Unique
alice@contoso.com, bob@contoso.com, carol@contoso.com

When you want a plain list of email addresses to loop over rather than a table to look at, that is the one.

3. Sort-Object

$items | Sort-Object Budget -Descending | Select-Object -First 2 Title, Budget
Title      Budget
-----      ------
Project 05   5000
Project 04   4000

Sort, then take the first two, and you have "the two biggest", which is most of what "top N" reporting ever is.

4. Measure-Object: the sums you would otherwise export to Excel for

$items | Measure-Object Budget -Sum -Average -Maximum
Count   : 5
Sum     : 15000
Average : 3000
Maximum : 5000

Count on its own is the everyday one. "How many items match" is just (... | Measure-Object).Count, or on most objects simply .Count.

5. Group-Object: counts per category, in one line

$items | Group-Object Status | Select-Object Count, Name
Count Name
----- ----
    1 Blocked
    2 Complete
    2 In Progress

This is a pivot table in one command. "How many sites per template", "how many items per status", "how many files per owner", all Group-Object. It is the report people are most surprised PowerShell writes for free.

6. ForEach-Object: do a thing to each one

$items | ForEach-Object { "$($_.Title) = $($_.Budget)" }

Same $_ as Where-Object, but instead of keeping or dropping the item you do something with it. This is where the actual work happens, updating each item or building each line of a report.

ForEach-Object is not foreach

There are two, they look similar, and the difference matters:

$items | ForEach-Object { ... }     # a cmdlet, works IN a pipeline, uses $_

foreach ($item in $items) { ... }   # a statement, its own variable, NOT pipeable

ForEach-Object sits in a pipeline. foreach is a language keyword and cannot be piped into. Put a pipe after its closing brace and you get "An empty pipe element is not allowed", which does not obviously point at the mistake.

Use ForEach-Object in a pipeline, foreach when you are already writing a block of script. For large collections foreach is faster, but you will not notice until you are into tens of thousands of items.

One trap that is worth ten minutes now

When a filter returns exactly one item, older PowerShell gave you that single object, not a collection of one, so .Count was $null instead of 1:

$one = $items | Where-Object { $_.Status -eq 'Blocked' }
$one.Count        # 1 in PowerShell 7, was $null in 5.1

PowerShell 7 fixed this, but plenty of tenants still run 5.1, and a script that checks if ($result.Count -gt 0) will silently do the wrong thing there. Wrap anything you are going to count:

$one = @($items | Where-Object { $_.Status -eq 'Blocked' })
$one.Count        # 1 - reliable on every version

The @() forces an array even around a single result. It is the cheapest habit in PowerShell and it saves a specific, maddening bug.

That is the toolkit

Filter with Where-Object, pick with Select-Object, order with Sort-Object, total with Measure-Object, pivot with Group-Object, act with ForEach-Object. Six commands, one pipeline, and Get-Member to tell you what you are holding when you get lost.

Nearly everything in my SharePoint PowerShell posts is those six pointed at real data. Learn them on made-up objects like these, where nothing can break, and the SharePoint version is just the same commands with a live list on the front 🙂

PowerShell try/catch Is Not Catching Your Error

You wrapped something in try/catch, it failed, and the catch block never ran. The error printed in red. The script carried on as if nothing had happened, and whatever you put in the catch was ignored.

Nothing is broken. PowerShell has two kinds of error, and try/catch only ever reacts to one of them.

Everything below was run on PowerShell 7.5.8.

The demonstration

try {
    Get-Item 'C:\does\not\exist\nothing.txt'
    "...execution continued past the failing line"
} catch {
    "CAUGHT: $($_.Exception.Message)"
}
Get-Item:
Line |
   6 |      Get-Item $missing
     |      ~~~~~~~~~~~~~~~~~
     | Cannot find path 'C:\does\not\exist\nothing.txt' because it does not exist.
...execution continued past the failing line

The error was written out. The catch block never ran, and the line after it did.

Now the same thing with four extra words:

try {
    Get-Item 'C:\does\not\exist\nothing.txt' -ErrorAction Stop
} catch {
    "CAUGHT: $($_.Exception.Message)"
}
CAUGHT: Cannot find path 'C:\does\not\exist\nothing.txt' because it does not exist.

Why

Most cmdlet errors are non-terminating. The cmdlet reports a problem and PowerShell carries on, because when you pipe a thousand items into something you usually want the other 999 to be processed.

try/catch only reacts to terminating errors.

-ErrorAction Stop promotes a non-terminating error to a terminating one for that call. That is the whole trick, and it is why so much published PowerShell has try/catch blocks in it that do nothing at all.

Write-Error is not throw

Worth knowing if you are writing your own functions:

try { Write-Error 'this is a Write-Error' } catch { "caught" }
try { throw 'this is a throw' }             catch { "caught: $($_.Exception.Message)" }
Write-Error: this is a Write-Error
caught: this is a throw

Write-Error raises a non-terminating error and is not caught. throw is terminating and is.

So if you write a function that reports failure with Write-Error, nobody calling it can catch that failure unless they also pass -ErrorAction Stop. Use throw when you mean stop.

Turning it on for a whole script

Rather than putting -ErrorAction Stop on every line:

$ErrorActionPreference = 'Stop'
CAUGHT without -ErrorAction on the cmdlet

Put that at the top of a script and every cmdlet error becomes terminating. For an admin script that changes things, this is almost always what you want. Stopping on the first problem beats carrying on and doing half the job.

It is scoped. Set it inside a function and it applies there, not to the caller. That is useful, and it also means setting it in your profile does not protect your scripts.

Catching one kind of error and not others

A bare catch catches everything, including the failures you had not thought about. You can be specific:

try {
    Get-Item $path -ErrorAction Stop
} catch [System.Management.Automation.ItemNotFoundException] {
    "matched the typed catch: file not found"
} catch {
    "fell through to the generic catch"
}
matched the typed catch: file not found

To find the type name for something you are actually hitting, let it fail once and ask:

$Error[0].Exception.GetType().FullName
$Error[0].FullyQualifiedErrorId
$Error[0].CategoryInfo.Category
System.Management.Automation.ItemNotFoundException
PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand
ObjectNotFound

FullyQualifiedErrorId is the precise one. It names both the problem and the cmdlet that raised it, which is what you want when two different cmdlets can produce the same exception type.

Collecting errors without stopping

Sometimes you want the loop to finish and a list of what went wrong:

Get-Item $path -ErrorAction SilentlyContinue -ErrorVariable itemErr

$itemErr.Count
$itemErr[0].Exception.Message
1
Cannot find path 'C:\does\not\exist\nothing.txt' because it does not exist.

Use +itemErr with a plus sign to append across a loop rather than overwrite each time.

The one that catches everybody: native commands

try/catch does not work on robocopy, git, msiexec or anything else that is not a cmdlet.

try {
    cmd.exe /c "exit 7"
    "no exception was raised"
} catch {
    "caught"
}
no exception was raised

External programs do not raise PowerShell exceptions. They set an exit code, and you have to check it yourself:

cmd.exe /c "exit 7"
$?              # False
$LASTEXITCODE   # 7

Both measured immediately after the call, and that timing matters, because $? reflects the last thing that happened. Put a Write-Host between the command and the check and you are reading the result of the Write-Host. I made exactly that mistake while testing this post.

So for anything external:

robocopy $source $dest /E
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with $LASTEXITCODE" }

Note the -ge 8. Robocopy uses 0-7 for various kinds of success, so testing -ne 0 would fail every time it copied something. Check what your tool’s exit codes actually mean rather than assuming zero is the only good one.

PowerShell 7 can do this for you

There is a setting for it, and it is off by default:

$PSNativeCommandUseErrorActionPreference   # False

Turn it on, with $ErrorActionPreference set to Stop:

$PSNativeCommandUseErrorActionPreference = $true
$ErrorActionPreference = 'Stop'

try { cmd.exe /c "exit 7" } catch { "caught = true" }
caught = true

A non-zero exit code now raises a terminating error you can catch like anything else. Worth knowing, and worth being careful with, because anything that uses non-zero exit codes for non-failures, robocopy included, will start throwing at you.

finally

try {
    Get-Item $path -ErrorAction Stop
} catch {
    "catch ran"
} finally {
    "finally ran"
}
catch ran
finally ran

Runs whether or not anything failed. Use it for the cleanup that has to happen either way, like disconnecting a session or removing a temporary file.

What I actually put in a script

$ErrorActionPreference = 'Stop'

try {
    # everything that changes something
}
catch {
    Write-Error "Failed at $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.Message)"
    throw
}
finally {
    Disconnect-PnPOnline -ErrorAction SilentlyContinue
}

$_.InvocationInfo.ScriptLineNumber tells you where it broke, which the message on its own does not. And the bare throw at the end re-raises the original error rather than swallowing it, so whatever called your script still finds out it failed.

Catching an error and then carrying on quietly is worse than not catching it. At least the red text was honest 🙂

PowerShell: Running Scripts Is Disabled on This System

You try to run a script and PowerShell refuses:

File C:\Scripts\demo.ps1 cannot be loaded because running scripts is disabled
on this system. For more information, see about_Execution_Policies at
https:/go.microsoft.com/fwlink/?LinkID=135170.
    + CategoryInfo          : SecurityError: (:) [], ParentContainsErrorRecordException
    + FullyQualifiedErrorId : UnauthorizedAccess

Or this one, which is a different problem with the same cause:

File C:\Scripts\demo.ps1 cannot be loaded. The file C:\Scripts\demo.ps1 is not
digitally signed. You cannot run this script on the current system.

Every answer you will find says to run Set-ExecutionPolicy. That is usually right and often does not work, because there are three separate things that produce these messages and only one of them is fixed that way.

First, what execution policy is not

It is not a security boundary. Microsoft say so themselves in the documentation. Anyone who can run PowerShell can bypass it in about four keystrokes, and I will show you how further down.

It exists to stop you double-clicking something by accident. Treat it as a seatbelt, not a lock.

The five scopes, and which one is winning

This is the part that catches people. There is not one execution policy, there are five, and they override each other in a fixed order:

MachinePolicy  ->  UserPolicy  ->  Process  ->  CurrentUser  ->  LocalMachine

Highest wins. So Get-ExecutionPolicy on its own tells you the effective answer but not which scope produced it. Always ask for the list:

Get-ExecutionPolicy -List

Here is my own machine:

        Scope ExecutionPolicy
        ----- ---------------
MachinePolicy       Undefined
   UserPolicy       Undefined
      Process          Bypass
  CurrentUser       Undefined
 LocalMachine    RemoteSigned

The effective policy is Bypass, not RemoteSigned. Something set it at Process scope for this session, and Process beats LocalMachine.

MachinePolicy and UserPolicy come from Group Policy. If either of those is set, nothing you type will change anything, and you will spend an afternoon finding that out. Check those two first.

Fix 1 – set it for yourself only

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

-Scope CurrentUser does not need administrator rights, and it does not change the machine for anybody else. On a shared or managed machine that matters.

RemoteSigned is the sensible setting: scripts you wrote locally run, scripts that came from somewhere else need a signature. It is the default on Windows Server for that reason.

Fix 2 – bypass it for one command

If you cannot change the policy, or do not want to:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\demo.ps1

This changes nothing permanently. It applies to that one process and then it is gone.

I ran the same blocked script three ways to show the difference:

-ExecutionPolicy Restricted    "running scripts is disabled on this system"
-ExecutionPolicy AllSigned     "is not digitally signed"
-ExecutionPolicy Bypass        the script ran

Which is also the demonstration that execution policy is not security. Four extra words on the command line and the “blocked” script runs.

Fix 3 – the one nobody mentions

Here is the case that makes people think the policy is broken.

Your policy is RemoteSigned. You wrote a script locally, it runs fine. A colleague emails you one, or you download it, and it refuses, with the “not digitally signed” message this time rather than “disabled on this system”.

The policy is correct. The file is marked.

Windows adds a hidden alternate data stream to anything that arrives from the internet or an email client. You can see it:

Get-Item .\demo.ps1 -Stream *
Stream          Length
------          ------
:$DATA              30
Zone.Identifier     26

That Zone.Identifier stream is the mark of the web, and RemoteSigned is doing exactly what it says by treating the file as remote.

Remove it:

Unblock-File -Path .\demo.ps1

Same script, same policy, straight afterwards:

the script ran

Note that Unblock-File only removes the mark. It does not weaken your policy and it does not affect any other file. It is the right fix for this case, and changing the execution policy to work around it is the wrong one.

For a folder of scripts:

Get-ChildItem .\Scripts\*.ps1 -Recurse | Unblock-File

Fix 4 – you fixed the wrong PowerShell

This one cost me time, so it is worth knowing.

Windows PowerShell 5.1 and PowerShell 7 keep their execution policies in completely separate places. Setting it in one does nothing for the other.

They are different registry keys:

Windows PowerShell 5.1  HKLM\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell
PowerShell 7 (Core)     HKLM\SOFTWARE\Microsoft\PowerShellCore\ShellIds\Microsoft.PowerShell

On my machine 5.1 has RemoteSigned set at both LocalMachine and CurrentUser, and the PowerShell 7 keys do not exist at all.

So if you opened the blue window, ran Set-ExecutionPolicy, then went back to the black window and got the same error, that is the reason. Run $PSVersionTable.PSVersion in whichever one is refusing, and fix that one.

The order I would check them in

  1. Get-ExecutionPolicy -List – find which scope is actually winning
  2. If MachinePolicy or UserPolicy is set, stop. That is Group Policy and it is not your decision
  3. Get-Item .\script.ps1 -Stream * – if there is a Zone.Identifier, Unblock-File is your fix
  4. Check which PowerShell you are in
  5. Then, and only then, Set-ExecutionPolicy RemoteSigned -Scope CurrentUser

Four of those five cost you nothing and change nothing. Reaching for Set-ExecutionPolicy first is how people end up with an unrestricted machine and a script that still does not run 🙂

Find a SharePoint List’s Internal Column Names with PowerShell

You write a script against a SharePoint list, ask for a column by the name you can see in the browser, and get nothing back. No error, just an empty value.

The column is called Project Status on screen. It is not called that underneath.

Run against a real list on PnP.PowerShell 2.12.0.

Every column has two names

The Title is the label in the browser. Anyone can change it and it can contain anything.

The InternalName is the key. It is set once, when the column is created, and it never changes again.

Here is the difference in one line:

$item = Get-PnPListItem -List 'Projects' -Id 1

$item['ProjectStatus']    # internal name
$item['Project Status']   # display name
By internal name 'ProjectStatus' : 'Complete'
By display name 'Project Status' : ''

An empty string, not an error. That is why this is so easy to lose an hour to. The script runs, it just quietly does nothing.

Getting the list

The obvious command gives you far more than you want:

Get-PnPField -List 'Projects'

On a list where I had created exactly five columns:

Total fields          : 90
Hidden                : 61
Inherited from base   : 83
Actually yours        : 5

Ninety. So filter it down to the ones you actually made:

Get-PnPField -List 'Projects' |
    Where-Object { -not $_.FromBaseType -and -not $_.Hidden } |
    Select-Object Title, InternalName, TypeAsString |
    Format-Table -AutoSize
Title          InternalName  TypeAsString
-----          ------------  ------------
Project Status ProjectStatus Choice
Project Owner  ProjectOwner  User
Due Date       DueDate       DateTime
Budget         Budget        Currency
Notes          Notes         Note

FromBaseType is the useful one. It is true for everything SharePoint gave you and false for everything you added.

What happens to spaces

Those internal names are clean because they were created by script. Columns created in the browser are not, because SharePoint derives the internal name from whatever you typed.

I made one with a space on purpose:

Add-PnPField -List 'Projects' -DisplayName 'Demo Review Date' `
             -InternalName 'Demo Review Date' -Type DateTime
Title        : Demo Review Date
InternalName : Demo_x0020_Review_x0020_Date
StaticName   : Demo_x0020_Review_x0020_Date

Every space becomes _x0020_. A column called “Date of Last Review” ends up as Date_x0020_of_x0020_Last_x0020_Review, and that is what your script has to use.

This is also why you are better off creating columns with a short name and renaming them afterwards.

Renaming does not change the internal name

I renamed that column and looked again:

Set-PnPField -List 'Projects' -Identity 'Demo Review Date' -Values @{ Title = 'Renamed Review Date' }
Title        : Renamed Review Date
InternalName : Demo_x0020_Review_x0020_Date

The label changed. The key did not, and it never will.

So a column that says Review Date today might be Temp_x0020_Column underneath because of what somebody typed two years ago. You cannot work out the internal name by looking at the browser. You have to ask.

The practical version of that: create the column with the internal name you want, then rename it to whatever reads well. Give it ReviewDate first, then change the label to “Date of Last Review”. You get a clean key and a readable heading.

Built-in columns are worse

Some of the ones you use most have internal names with no relationship to the label at all:

Title        InternalName TypeAsString
-----        ------------ ------------
Attachments  Attachments  Attachments
Created By   Author       User
Content Type ContentType  Computed
Created      Created      DateTime
Modified By  Editor       User
Name         FileLeafRef  File
URL Path     FileRef      Lookup
ID           ID           Counter
Modified     Modified     DateTime
Title        Title        Text

Created By is Author. Modified By is Editor. Those two are the ones everybody hits, and there is nothing on screen that hints at it.

In a document library it gets stranger still:

Title        InternalName    TypeAsString
-----        ------------    ------------
Created By   Author          User
Type         DocIcon         Computed
Modified By  Editor          User
File Size    File_x0020_Size Lookup
Name         FileLeafRef     File
URL Path     FileRef         Lookup

A file’s Name is FileLeafRef. Its path is FileRef. And File Size carries the _x0020_ encoding as a built-in, which tells you Microsoft named it in a browser too.

One thing that will not work at all

I tried to make a column called Cost & Time-Frame to see what an ampersand does:

An error occurred while parsing EntityName. Line 1, position 32.

Not an encoded name this time. A failure. PnP builds field XML underneath, and a raw ampersand breaks the parse before SharePoint ever sees it. The error mentions XML parsing and does not mention your column, which is not helpful when you are three lines into a provisioning script.

Note that this is a script limitation. The browser will happily create that column and encode it for you. So this is one of the few cases where the UI does something PowerShell will not.

The line worth keeping

Get-PnPField -List 'YourList' |
    Where-Object { -not $_.FromBaseType -and -not $_.Hidden } |
    Select-Object Title, InternalName, TypeAsString |
    Sort-Object Title

Run it before you write anything against a list you did not create yourself. Thirty seconds, and it saves the empty-string hour 🙂

What Copilot Chat (Basic) Can and Cannot Do

I wanted to build an agent over a few SharePoint documents on an account with no Microsoft 365 Copilot license, and I could not. Not because a button was greyed out. Because the option to point an agent at SharePoint is not there at all.

Copilot Chat (Basic) is the tier you have without buying a Copilot license. Everything below is from my own tenant, on that unlicensed account, and it is mostly about where the tier stops.

Where to check what tier you are on

Open Copilot Chat and look at the bottom left, under your name. Mine says Copilot Chat (Basic).

Worth checking before you conclude anything is broken. A lot of “Copilot cannot see my files” questions are this, and the interface does not otherwise tell you.

Microsoft 365 Copilot Chat showing the Copilot Chat (Basic) tier label under the account name in the bottom left corner

What works

Chat itself. Full conversational Copilot, grounded on the web.

Creating documents, charts and code. Agent Builder shows Word, Excel and PowerPoint capabilities enabled by default, alongside image generation.

Prompt Lab, behind the . at the end of the suggestion chips under the message box.

Organization prompts. This one surprised me.

Prompts published by an admin in the Microsoft 365 admin center appear in Prompt Lab on a Basic account, labelled Promoted by your organization. I had assumed they were a licensed feature. They are not.

So if you are rolling out Copilot to a mixed estate where some people are licensed and some are not, your published prompts still reach everybody. That is a genuinely useful thing to know before you decide who needs a license.

Where it stops – work data

Basic cannot see your Microsoft 365 content. Not SharePoint, not OneDrive, not email.

I put four invented policy documents into a SharePoint library, waited for them to index, and asked a question whose answer is in one of them:

What is the accommodation limit in London?

The document says 180 pounds. What I got back was a request to clarify, followed by an explanation of Airbnb short-let limits, employer allowances in general, university accommodation and Local Housing Allowance.

All from the web. It never touched the document. It never said it could not reach my files. It just answered a different question, and answered it well.

That is the thing to watch for. It does not fail visibly. It gives you a confident answer that is entirely irrelevant, and if you did not know your file was there you would not necessarily notice.

Where it stops – agents

You can open Agent Builder from New agent in the left navigation. The configuration screen is all there, instructions and capabilities and the rest of it.

Then look at the Knowledge section. On Basic it offers exactly three things:

  • Add specific websites
  • Search all websites
  • Only use specified sources

That is the entire list. There is no SharePoint option, no OneDrive, no Teams, no email. The picker does not appear greyed out with an upgrade prompt beside it. It is simply not there.

Agent Builder Knowledge section on an unlicensed account, offering only Add specific websites, Search all websites and Only use specified sources - no SharePoint, OneDrive or Teams

So an agent on Basic is a web agent. You can point it at documentation sites and it will answer from them. You cannot point it at your own content, which for most internal use cases is the entire reason you wanted an agent.

I have not tested whether the Create button completes, or whether website grounding actually works once an agent exists. I will update this when I have. What I can say for certain is what the Knowledge section offers, because that is the wall you hit first.

What this means if you are deciding on licenses

The split is cleaner than the marketing suggests. Basic gives you a good general assistant. A license gives you an assistant that knows about your organization.

Which reframes the question to one thing: who actually needs it grounded on your own content. Somebody drafting emails and summarizing pasted text is well served by Basic. Somebody asking “what did we agree with this supplier” is not, and no amount of prompt engineering closes that gap.

Two practical consequences:

Publish your organization prompts regardless of licensing. They reach everyone, so they are the cheapest thing you can do to make Copilot useful across the whole organization.

Expect the confident wrong answer. The failure mode on Basic is a plausible answer from the open web, never an error message. If you are running a pilot with a mix of licensed and unlicensed users, they are not having the same experience, and the unlicensed ones may not realize it.

A note on names, because they keep changing. What my tenant calls Copilot Chat (Basic) has been through several names, and Microsoft’s own documentation does not always use the one your interface shows. Go by what is printed under your account, not by what a comparison table calls it 🙂

Update SharePoint List Items from a CSV with PowerShell

You export a SharePoint list, change a few cells in the spreadsheet, push it back with a script, and every row reports success. Nothing errors. Some of those updates did not do what you think, and two of the ways they fail leave no trace at all.

Run against a real list on PnP.PowerShell 2.12.0.

A CSV has no types

That is the whole problem in one sentence. Export a list and read it back:

$row = Import-Csv .\projects.csv | Select-Object -First 1
foreach ($p in $row.PSObject.Properties) {
    "{0,-10} {1,-18} value: {2}" -f $p.Name, $p.Value.GetType().Name, $p.Value
}
Id         String             value: 1
Title      String             value: Project 01
Status     String             value: Not Started
DueDate    String             value: 7/28/2026 5:13:57 PM
Budget     String             value: 1000
Notes      String             value: Seeded row 1 for PowerShell examples.

Everything is a String. The list on the other side has Choice, DateTime, Currency and Note columns. Handing one to the other is where it falls apart.

The version everybody writes first

$rows = Import-Csv .\changes.csv

# Build the lookup ONCE. Searching the list per row is the usual reason
# these scripts take hours instead of seconds.
$lookup = @{}
Get-PnPListItem -List 'Projects' -PageSize 500 | ForEach-Object {
    $lookup[[string]$_['Title']] = $_.Id
}

foreach ($row in $rows) {
    $id = $lookup[$row.Title]
    if (-not $id) { Write-Host "no match for '$($row.Title)'"; continue }

    Set-PnPListItem -List 'Projects' -Identity $id -Values @{
        ProjectStatus = $row.Status
        DueDate       = $row.DueDate
        Budget        = $row.Budget
    }
}

I fed it seven rows, four good and three broken on purpose:

updated Project 01
updated Project 02
updated Project 03
updated Project 04
FAILED Project 05: The string was not recognized as a valid DateTime.
no match for 'Project 99'
updated Project 06

Looks like it mostly worked. It did not.

Failure one: an invalid Choice value is written anyway

Row four set the status to On Hold. That is not one of the four choices on that column. Look at what happened:

Project 04 status is now: 'On Hold'

Valid choices are:
Not Started
In Progress
Blocked
Complete

SharePoint does not validate Choice values on write. It took a value that was never an option and stored it without a word. It reported success.

The column now holds something no dropdown in the interface can produce. It hides from your filtered views and your Power Automate conditions alike, and nothing anywhere flags it. Do this to two thousand rows and you will find out months later.

You have to check it yourself:

$field = Get-PnPField -List 'Projects' -Identity 'ProjectStatus'
if ($value -notin @($field.Choices)) {
    # reject it - SharePoint will not
}

Failure two: an empty cell deletes data

Row six had an empty Budget. Not zero, empty. Afterwards:

Title      Status    DueDate              Budget
-----      ------    -------              ------
Project 06 Complete  7/1/2026 12:00:00 AM

The Budget is gone. It was 6000 before.

An empty cell is treated as “clear this field”, not “leave this alone”. Which is defensible, but it is the opposite of what most people mean when they send a spreadsheet with a few columns filled in. If your CSV only carries the columns you meant to change, every blank cell in it is a deletion.

Both behaviours are reasonable, so decide which one you want rather than finding out.

Failure three: one bad cell loses the whole row

Row five had not a date in the DueDate column, and it threw. That part is fine, loud is good.

What is not fine is that Status and Budget on that row were not written either. The update is one call per item, so a single unusable value throws all of it away. Project 05 kept its old status, plus the old date and budget with it.

Validate before sending and the good values survive:

[5] Project 05: ProjectStatus=Complete, Budget=5500

DueDate is skipped and reported, the other two go through.

The whole script

Reads the list’s real schema, validates every value against its actual field type, batches the writes, and supports -WhatIf:

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)] [string]    $SiteUrl,
    [Parameter(Mandatory)] [string]    $ClientId,
    [Parameter(Mandatory)] [string]    $List,
    [Parameter(Mandatory)] [string]    $CsvPath,
    [string]    $KeyColumn = 'Title',
    [hashtable] $ColumnMap = @{},
    [switch]    $ClearEmptyValues
)

$ErrorActionPreference = 'Stop'
if (-not (Test-Path $CsvPath)) { throw "CSV not found: $CsvPath" }

Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Interactive

$rows = @(Import-Csv -Path $CsvPath)
if ($rows.Count -eq 0) { throw "CSV is empty." }
Write-Host "Rows in CSV : $($rows.Count)" -ForegroundColor Cyan

# Read the real field types rather than assuming the CSV headers mean anything.
$fields = @{}
foreach ($f in Get-PnPField -List $List) { $fields[$f.InternalName] = $f }

$csvColumns = $rows[0].PSObject.Properties.Name | Where-Object { $_ -ne $KeyColumn }

$targets = @{}
foreach ($col in $csvColumns) {
    $internal = if ($ColumnMap.ContainsKey($col)) { $ColumnMap[$col] } else { $col }
    if (-not $fields.ContainsKey($internal)) {
        Write-Warning "CSV column '$col' maps to '$internal', which is not a field on '$List'. Ignored."
        continue
    }
    $targets[$col] = $fields[$internal]
}
if ($targets.Count -eq 0) { throw "No CSV columns matched fields on '$List'." }

Write-Host "Mapped columns:" -ForegroundColor Cyan
foreach ($col in $targets.Keys | Sort-Object) {
    "  {0,-14} -> {1,-18} ({2})" -f $col, $targets[$col].InternalName, $targets[$col].TypeAsString
}

# One pass to index the list. Searching per row is what makes these slow.
Write-Host "`nBuilding lookup..." -ForegroundColor Cyan
$lookup = @{}
$duplicates = [System.Collections.Generic.List[string]]::new()
foreach ($item in Get-PnPListItem -List $List -PageSize 500) {
    $key = [string]$item[$KeyColumn]
    if ([string]::IsNullOrWhiteSpace($key)) { continue }
    if ($lookup.ContainsKey($key)) { $duplicates.Add($key); continue }
    $lookup[$key] = $item.Id
}
Write-Host "  $($lookup.Count) item(s) indexed on '$KeyColumn'"
if ($duplicates.Count) {
    Write-Warning "$($duplicates.Count) duplicate key(s) - only the first of each was indexed."
}

function Convert-Value {
    param($Raw, $Field)
    $type = $Field.TypeAsString

    if ([string]::IsNullOrWhiteSpace($Raw)) {
        if ($ClearEmptyValues) { return @{ Ok = $true; Value = $null } }
        return @{ Ok = $false; Reason = 'empty (skipped)' }
    }

    switch -Regex ($type) {
        '^Choice$|^MultiChoice$' {
            # The check SharePoint does not do for you.
            $valid = @($Field.Choices)
            if ($Raw -notin $valid) {
                return @{ Ok = $false; Reason = "'$Raw' is not a valid choice (allowed: $($valid -join ', '))" }
            }
            return @{ Ok = $true; Value = $Raw }
        }
        '^DateTime$' {
            $parsed = [datetime]::MinValue
            if (-not [datetime]::TryParse($Raw, [ref]$parsed)) {
                return @{ Ok = $false; Reason = "'$Raw' is not a date" }
            }
            return @{ Ok = $true; Value = $parsed }
        }
        '^Number$|^Currency$' {
            $parsed = 0.0
            if (-not [double]::TryParse($Raw, [ref]$parsed)) {
                return @{ Ok = $false; Reason = "'$Raw' is not a number" }
            }
            return @{ Ok = $true; Value = $parsed }
        }
        '^Boolean$' {
            if ($Raw -in @('1','true','True','yes','Yes')) { return @{ Ok = $true; Value = $true } }
            if ($Raw -in @('0','false','False','no','No')) { return @{ Ok = $true; Value = $false } }
            return @{ Ok = $false; Reason = "'$Raw' is not a yes/no value" }
        }
        default { return @{ Ok = $true; Value = $Raw } }
    }
}

$plan     = [System.Collections.Generic.List[object]]::new()
$problems = [System.Collections.Generic.List[object]]::new()

foreach ($row in $rows) {
    $key = [string]$row.$KeyColumn

    if (-not $lookup.ContainsKey($key)) {
        $problems.Add([pscustomobject]@{ Key = $key; Column = '(row)'; Reason = 'no matching item' })
        continue
    }

    $values = @{}
    foreach ($col in $targets.Keys) {
        $result = Convert-Value $row.$col $targets[$col]
        if ($result.Ok) {
            $values[$targets[$col].InternalName] = $result.Value
        } elseif ($result.Reason -ne 'empty (skipped)') {
            $problems.Add([pscustomobject]@{ Key = $key; Column = $col; Reason = $result.Reason })
        }
    }

    if ($values.Count) {
        $plan.Add([pscustomobject]@{ Key = $key; Id = $lookup[$key]; Values = $values })
    }
}

Write-Host "`nTo update : $($plan.Count) item(s)" -ForegroundColor Green
Write-Host "Problems  : $($problems.Count)"
if ($problems.Count) { $problems | Format-Table Key, Column, Reason -AutoSize }

if ($plan.Count -eq 0) { Write-Host "Nothing to do."; Disconnect-PnPOnline; return }

if ($WhatIfPreference) {
    Write-Host "`n-WhatIf: nothing was written. Planned changes:" -ForegroundColor Cyan
    foreach ($p in $plan | Select-Object -First 10) {
        "  [$($p.Id)] $($p.Key): " + (($p.Values.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ')
    }
    if ($plan.Count -gt 10) { "  ... and $($plan.Count - 10) more" }
    Disconnect-PnPOnline
    return
}

if ($PSCmdlet.ShouldProcess("$($plan.Count) items in '$List'", 'Update')) {
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $batch = New-PnPBatch
    foreach ($p in $plan) {
        Set-PnPListItem -List $List -Identity $p.Id -Values $p.Values -Batch $batch | Out-Null
    }
    Invoke-PnPBatch -Batch $batch
    $sw.Stop()
    Write-Host "`nUpdated $($plan.Count) item(s) in $([math]::Round($sw.Elapsed.TotalSeconds,1))s" -ForegroundColor Green
}

Disconnect-PnPOnline

Run it dry first

.\Update-ListFromCsv.ps1 `
    -SiteUrl "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -List "Projects" -CsvPath .\changes.csv `
    -ColumnMap @{ Status = 'ProjectStatus' } `
    -WhatIf
Mapped columns:
  Budget         -> Budget             (Currency)
  DueDate        -> DueDate            (DateTime)
  Status         -> ProjectStatus      (Choice)

Building lookup...
  25 item(s) indexed on 'Title'

To update : 6 item(s)
Problems  : 3

Key        Column  Reason
---        ------  ------
Project 04 Status  'On Hold' is not a valid choice (allowed: Not Started, In Progress, Blocked, Complete)
Project 05 DueDate 'not a date' is not a date
Project 99 (row)   no matching item

-WhatIf: nothing was written. Planned changes:
  [4] Project 04: Budget=4400, DueDate=09/01/2026 00:00:00
  [5] Project 05: ProjectStatus=Complete, Budget=5500

Note rows 4 and 5. The bad cell is dropped and reported, the good ones still go through. The naive version threw all of row 5 away.

Then run it for real:

Updated 6 item(s) in 4.5s

The -WhatIf run is the one that matters. Thirty seconds of reading a problem list beats finding out in six months that a Choice column is full of values that were never options 🙂

Report Who Has Access to a SharePoint Site with PowerShell

A script told me a site had zero Everyone claims. Every person in the company could edit it.

Reporting who can reach a SharePoint site looks like a one-liner. It is not, because access arrives by five routes and the two obvious scripts each miss a different one.

Run against real sites on PnP.PowerShell 2.12.0.

Access arrives by five routes

  • SharePoint groups: Owners, Members, Visitors and any custom ones
  • A direct grant to one person on the site itself
  • The Microsoft 365 group behind a group-connected site
  • Sharing links
  • An Everyone or Everyone except external users claim

Most scripts read the first one. That is why sites look tidy and are not.

SharePoint groups

foreach ($group in Get-PnPGroup) {
    $permissions = (Get-PnPGroupPermissions -Identity $group.Title | ForEach-Object { $_.Name }) -join ', '
    "$($group.Title) [$permissions]"
    Get-PnPGroupMember -Group $group.Title | ForEach-Object { "    $($_.Title)" }
}
Demo Members [Edit]
Demo Owners [Full Control]
    VJ
Demo Visitors [Read]

Get-PnPGroupPermissions is the tidy way to get the permission level. You can walk role assignments by hand, but there is no need for groups.

Direct grants

$web = Get-PnPWeb
$assignments = Get-PnPProperty -ClientObject $web -Property RoleAssignments

foreach ($ra in $assignments) {
    $member = Get-PnPProperty -ClientObject $ra -Property Member
    $roles  = Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings
    "{0,-30} {1}" -f $member.Title, (($roles | ForEach-Object { $_.Name }) -join ', ')
}

This is the authoritative list of who holds permission on the site. Everything else either appears here or sits underneath something that does.

The Everyone problem, and two ways to get it wrong

Both obvious approaches fail here, in opposite directions.

Wrong answer one: scanning the user list

Get-PnPUser | Where-Object { $_.Title -match '^Everyone' }
Title                          LoginName
-----                          ---------
Everyone                       c:0(.s|true
Everyone except external users c:0-.f|rolemanager|spo-grid-all-users/03889806-...

Both of them, on a completely private site. Get-PnPUser returns the User Information List, everyone the site has ever heard of, and SharePoint puts those principals there whether or not anything has been granted to them.

I checked the same site’s role assignments:

Principals with a role assignment on this web:
    Demo Owners
    Demo Visitors
    Demo Members

Everyone-type principal actually granted access: False

So a report built this way flags every site in the tenant as overshared. People stop reading it, and then it is worth nothing on the day it is right.

Wrong answer two: only checking role assignments

Having found that out, the obvious fix is to only trust role assignments on the web. That is what I did, and it is also wrong.

Here is a different site, a group-connected one:

Route                            Principal                      Type          Permission
-----                            ---------                      ----          ----------
via group: All Company Members   Everyone except external users SecurityGroup Edit

Everyone except external users is a member of the Members group, and that group has Edit. Everyone in the organisation can edit that site. And because the claim is not itself in the web’s role assignments, my “fixed” script reported Everyone claims: 0.

That is the failure that matters. The first version cries wolf. The second stays quiet while the whole company has write access.

The check that actually works

An Everyone claim counts if it holds a role assignment or is a member of a group that holds one. And match on the login name, not the title, because the title is whatever your tenant renamed it to:

function Test-EveryoneClaim {
    param([string] $LoginName, [string] $Title)

    return $LoginName -match 'spo-grid-all-users' -or
           $LoginName -match 'c:0\(\.s\|true' -or
           $Title -match '^Everyone'
}

Adding the claim to the Members group is the normal way this happens. Somebody wants everyone in the company to be able to contribute, clicks two things, and it never shows up in an audit again.

Group-connected sites have a second membership list

A Teams or Microsoft 365 group site has SharePoint groups and a Microsoft 365 group, and they are not the same list:

$site = Get-PnPSite
Get-PnPProperty -ClientObject $site -Property GroupId

Get-PnPMicrosoft365GroupOwner  -Identity $site.GroupId
Get-PnPMicrosoft365GroupMember -Identity $site.GroupId

Miss this and your report is missing the actual members of every Teams-connected site. I wrote about the same split causing trouble in the site inventory post, where group sites report no owner at all.

Sharing links

Sharing a file creates a SharePoint group to hold whoever the link is for. I made one on a demo file to see what it looks like:

Add-PnPFileOrganizationalSharingLink -FileUrl "/sites/Demo/Project Documents/Reports/Monthly Report.txt" -ShareType View

And then listed the site’s groups:

Demo Members
Demo Owners
Demo Visitors
SharingLinks.042f4d62-d446-4d80-83d1-fdb40ce5956d.OrganizationView.7cf4060c-7919-4531-8d2e-0bc4b5ddd2d3

I had expected these to be hidden. They are not. It is right there in an ordinary Get-PnPGroup listing. The name is SharingLinks.<file guid>.<type>.<link id>.

Which is worse, in a way. Nothing conceals it, it just looks like machine noise, so anyone scanning a list of group names slides straight past it. Twenty of those in a site and nobody reads a single one.

Get-PnPGroup | Where-Object { $_.Title -like 'SharingLinks*' }

The ShareType values available are View, Review, Edit, Embed, BlocksDownload, CreateOnly, AddressBar and AdminDefault.

One warning if you go on to manage these. The three cmdlets do not agree with each other:

Add-PnPFileOrganizationalSharingLink   -FileUrl                 (no -Identity)
Get-PnPFileSharingLink                 -Identity or -FileUrl    (-FileUrl obsolete)
Remove-PnPFileSharingLink              -FileUrl AND -Identity   (-Identity = the LINK)

So -FileUrl is deprecated on one, required on another, and -Identity means the file on two of them and the link on the third. Check with Get-Command <name> -Syntax before writing anything. It cost me four attempts.

The whole script

Everything above in one place, with a CSV option:

[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string] $SiteUrl,
    [Parameter(Mandatory)] [string] $ClientId,
    [string] $OutputCsv
)

$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Interactive

$access = [System.Collections.Generic.List[object]]::new()

function Add-Access {
    param($Route, $Principal, $Type, $Permission, $Detail = '')
    $access.Add([pscustomobject]@{
        Route = $Route; Principal = $Principal; Type = $Type
        Permission = $Permission; Detail = $Detail
    })
}

function Test-EveryoneClaim {
    param([string] $LoginName, [string] $Title)
    return $LoginName -match 'spo-grid-all-users' -or
           $LoginName -match 'c:0\(\.s\|true' -or
           $Title -match '^Everyone'
}

Write-Host "Reading site..." -ForegroundColor Cyan
$web = Get-PnPWeb
$assignments = Get-PnPProperty -ClientObject $web -Property RoleAssignments

foreach ($ra in $assignments) {
    $member = Get-PnPProperty -ClientObject $ra -Property Member
    $roles  = Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings
    $levels = ($roles | ForEach-Object { $_.Name }) -join ', '

    $route = if (Test-EveryoneClaim $member.LoginName $member.Title) { 'EVERYONE CLAIM' }
             elseif ($member.PrincipalType -eq 'SharePointGroup') { 'SharePoint group' }
             else { 'Direct grant' }

    Add-Access $route $member.Title $member.PrincipalType $levels $member.LoginName
}

Write-Host "Expanding groups..." -ForegroundColor Cyan

foreach ($group in Get-PnPGroup) {
    $isSharingLink = $group.Title -like 'SharingLinks*'

    $permissions = try {
        (Get-PnPGroupPermissions -Identity $group.Title | ForEach-Object { $_.Name }) -join ', '
    } catch { '(none on this web)' }

    $members = @(Get-PnPGroupMember -Group $group.Title -ErrorAction SilentlyContinue)

    if ($isSharingLink) {
        $parts = $group.Title -split '\.'
        $linkType = if ($parts.Count -ge 3) { $parts[2] } else { 'unknown' }
        if ($members.Count -eq 0) {
            Add-Access 'SHARING LINK' '(link with no named recipients)' 'SharingLink' $permissions $linkType
        }
        foreach ($m in $members) {
            Add-Access 'SHARING LINK' $m.Title $m.PrincipalType $permissions "$linkType | $($m.LoginName)"
        }
        continue
    }

    foreach ($m in $members) {
        # The claim usually gets in as a group MEMBER, not a direct grant.
        $route = if (Test-EveryoneClaim $m.LoginName $m.Title) { 'EVERYONE CLAIM' }
                 else { "via group: $($group.Title)" }
        $detail = if ($route -eq 'EVERYONE CLAIM') {
            "inside group '$($group.Title)' | $($m.LoginName)"
        } else { $m.LoginName }

        Add-Access $route $m.Title $m.PrincipalType $permissions $detail
    }
}

$site = Get-PnPSite
$null = Get-PnPProperty -ClientObject $site -Property GroupId

if ($site.GroupId -and $site.GroupId -ne [guid]::Empty) {
    Write-Host "Group-connected site - reading Microsoft 365 group..." -ForegroundColor Cyan
    try {
        foreach ($o in Get-PnPMicrosoft365GroupOwner -Identity $site.GroupId -ErrorAction Stop) {
            Add-Access 'M365 group owner' $o.DisplayName 'User' 'Owner' $o.UserPrincipalName
        }
        foreach ($m in Get-PnPMicrosoft365GroupMember -Identity $site.GroupId -ErrorAction Stop) {
            Add-Access 'M365 group member' $m.DisplayName 'User' 'Member' $m.UserPrincipalName
        }
    }
    catch { Write-Warning "Could not read the Microsoft 365 group: $($_.Exception.Message)" }
}

foreach ($u in Get-PnPUser) {
    if ($u.LoginName -match '#ext#' -or $u.IsShareByEmailGuestUser) {
        Add-Access 'EXTERNAL USER' $u.Title 'Guest' '(see routes above)' $u.LoginName
    }
}

$everyone  = @($access | Where-Object Route -eq 'EVERYONE CLAIM')
$links     = @($access | Where-Object Route -eq 'SHARING LINK')
$externals = @($access | Where-Object Route -eq 'EXTERNAL USER')

Write-Host ""
Write-Host "Site           : $($web.Title)"
Write-Host "Access entries : $($access.Count)"
Write-Host "Sharing links  : $($links.Count)"
Write-Host "External users : $($externals.Count)"
Write-Host "Everyone claims: $($everyone.Count)"

if ($everyone.Count) {
    Write-Warning "This site grants access to an Everyone claim. Anyone in the organisation can reach it."
    foreach ($e in $everyone) {
        Write-Host "  $($e.Principal) - $($e.Permission) - $($e.Detail)" -ForegroundColor Red
    }
}

if ($OutputCsv) {
    $access | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
    Write-Host "`nWritten to $OutputCsv" -ForegroundColor Green
} else {
    $access | Sort-Object Route, Principal | Format-Table Route, Principal, Type, Permission -AutoSize
}

Disconnect-PnPOnline

On a private site:

Site           : PowerShell Demo
Access entries : 4
Sharing links  : 0
External users : 0
Everyone claims: 0

And on the one that is not:

Site           : All Company
Access entries : 9
Everyone claims: 1

WARNING: This site grants access to an Everyone claim. Anyone in the organisation can reach it.
  Everyone except external users - Edit - inside group 'All Company Members'

That last line is the one you want. It tells you which group the claim is sitting in, which is where you go to remove it 🙂