CAML

Read SharePoint Lists, Items and Files with PnP PowerShell

In this post, I am going to show you how to read lists, items and files with PnP PowerShell, and the handful of things that come back looking nothing like what you expected.

All of it was run against a real site on PnP.PowerShell 2.12.0. The output below is what my machine printed.

Listing lists

The first command everyone runs:

Get-PnPList | Measure-Object | Select-Object Count
Count
-----
   19

Nineteen, on a site where I had created three. The rest are SharePoint’s own. So you filter out the hidden ones:

Get-PnPList | Where-Object { -not $_.Hidden } | Select-Object Title, ItemCount, BaseTemplate
Title             ItemCount BaseTemplate
-----             --------- ------------
Departments               4          100
Documents                 0          101
Events                    0          106
Form Templates            0          101
Project Documents         9          101
Projects                 25          100
Site Pages                1          119
Style Library             0          101

Down to eight, but still only three of those are mine. Documents, Events, Form Templates, Site Pages and Style Library are all created by SharePoint and are not hidden.

There is no flag for “lists a human made”. If you want that, filter on what you know – by title, or by BaseTemplate where 100 is a generic list and 101 is a document library.

Reading one item

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

A list with five columns of mine returned forty-five fields. Alongside Title, Budget and ProjectStatus you get ComplianceAssetId, MetaInfo, owshiddenversion, ScopeId, SMTotalFileStreamSize, WorkflowVersion and a couple of dozen more.

To read a value, index into the item with the internal name:

$item['Title']
$item['ProjectStatus']
$item['Budget']
Project 01
Not Started
1000

The important thing to note is that internal names are not display names. A column shown as Project Status can have an internal name of ProjectStatus, or Project_x0020_Status if it was created through the browser with a space in the name. Check before you guess:

Get-PnPField -List 'Projects' | Select-Object Title, InternalName

Not every field is a string

This is where exports go wrong. Look at what comes back for the built-in Author field:

Author   Microsoft.SharePoint.Client.FieldUserValue
Editor   Microsoft.SharePoint.Client.FieldUserValue

Person fields, lookup fields and taxonomy fields are objects. Pipe one straight into a CSV and that is the text you get in the file. You want the property off it:

$item['Author'].LookupValue    # display name
$item['Author'].Email
$item['Author'].LookupId

Same shape for lookups – LookupValue for the text, LookupId for the ID.

Ask for less

By default you pull every one of those forty-five fields for every item. Ask only for what you need:

Get-PnPListItem -List 'Projects' -Fields 'Title','ProjectStatus' -PageSize 500

And filter on the server rather than dragging everything down and filtering locally:

$caml = "<View><Query><Where><Eq><FieldRef Name='ProjectStatus'/><Value Type='Text'>Blocked</Value></Eq></Where></Query></View>"
Get-PnPListItem -List 'Projects' -Query $caml

I timed both against my 25 items. Filtering locally took 998ms, the CAML query 913ms. Barely a difference, and I am not going to pretend otherwise.

It matters at scale. Past 5,000 items the list view threshold turns the lazy version from slow into an outright error, and by then you are rewriting the script under pressure. Get in the habit while your lists are small.

Files, and the folder nobody asks for

Listing a library looks fine:

Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents'
Name      Type
----      ----
Archive   Folder
Contracts Folder
Forms     Folder
Reports   Folder

I created three of those. Forms is SharePoint’s, and it is in every library on every site.

Which becomes a real problem the moment you go recursive:

Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents' -ItemType File -Recursive
Old Contract.txt        /Project Documents/Archive/2024/Old Contract.txt
Statement of Work.txt   /Project Documents/Contracts/Statement of Work.txt
AllItems.aspx           /Project Documents/Forms/AllItems.aspx
Combine.aspx            /Project Documents/Forms/Combine.aspx
DispForm.aspx           /Project Documents/Forms/DispForm.aspx
EditForm.aspx           /Project Documents/Forms/EditForm.aspx
repair.aspx             /Project Documents/Forms/repair.aspx
template.dotx           /Project Documents/Forms/template.dotx
Thumbnails.aspx         /Project Documents/Forms/Thumbnails.aspx
Upload.aspx             /Project Documents/Forms/Upload.aspx
Monthly Report.txt      /Project Documents/Reports/Monthly Report.txt
Versioned Document.txt  /Project Documents/Reports/Versioned Document.txt

Twelve files, of which four are documents. The other eight are the library’s own form pages. If you are writing a report of “every file in this library”, or worse a script that deletes or moves things, filter Forms out:

Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents' -ItemType File -Recursive |
    Where-Object { $_.ServerRelativeUrl -notmatch '/Forms/' }

File metadata without downloading the file

Get-PnPFile -Url "/sites/Demo/Project Documents/Reports/Versioned Document.txt" -AsListItem

-AsListItem gives you the metadata only. Without it you are pulling the bytes down, which you rarely want if all you needed was the modified date.

Version history is one shorter than you think

Get-PnPFileVersion -Url "/sites/Demo/Project Documents/Reports/Versioned Document.txt"
VersionLabel Created              Size
------------ -------              ----
1.0          7/21/2026 5:17:06 PM   30
...
11.0         7/21/2026 5:17:17 PM   31

Eleven versions. But the file itself reports 12.0 as its current version.

Get-PnPFileVersion returns the previous versions, not the current one. So if you are counting versions to work out storage, or deciding what to trim, remember the live copy is not in that list and cannot be deleted from it 🙂