Create Better Code by Avoiding Five Errors When Writing Graph PowerShell Scripts

Microsoft 365 tenant should now be through the transition from the Azure AD and MSOL PowerShell modules to use Graph API requests or Microsoft Graph PowerShell SDK cmdlets in scripts. There’s certainly been no shortage of script examples published in articles and blog posts for people to gain inspiration from.

Having many code examples to choose from is a good thing. There’s always the opportunity to learn from the way someone else approaches and solves a problem. I would never attempt to stop someone from publishing a script because that goes against the grain of a vibrant technical community. However, I’ve noticed that many scripts include several flaws that make the code less valuable than it should or could be. The flaws are easily addressed. I should know, because I have written and published code with these flaws. I therefore claim no expertise except the ability to write inefficient code.

The word inefficient is very important here. PowerShell is very flexible, and it’s possible to write code that works but runs in a less-than-efficient way. Everyone who starts with PowerShell is afflicted with the same issue of writing one-liners to get things done in a state of splendid inefficiency. It’s part of the PowerShell learning curve.

This article covers five areas where I have improved my Graph-based PowerShell scripts over the last few years. Hopefully, the points presented here will resonate and make sense to those grappling to master the sometimes arcane and obscure details of the Microsoft Graph.

Failure to Filter

Many examples of filtering are covered in depth at the end of this article. Suffice to say at this point that there are too many articles that include code examples that fetch every object from a certain resource (user, group, calendar, etc.), or even worse, just the first page of objects. This doesn’t often happen in real life. Production scripts might need to process every object of a certain type, but it’s far more common to fetch just a few well-chosen objects.

When I see this happen, I ask myself, “is there some good reason why no filter is present?” Usually, there isn’t, and it’s probably because the code runs perfectly in a test tenant when five objects are available for fetching. Maybe people simply forget that when their code is run by others, the code might have to fetch thousands of objects. Things don’t go so well at that point.

Fetching Too Many Properties

Resources like users and groups support many properties, yet the average Graph request to fetch information includes a default set of properties and doesn’t pay attention to minimizing traffic over the wire. Fetching the default set of properties (like the object identifier, display name, and user principal name for a user object) is absolutely acceptable when fetching just a few objects. It becomes more problematic when the number of objects to process scales up, and that’s why it’s a good idea to pay attention to the data that’s actually needed.

Most Graph resources return a default set of properties. If you want to use one of the non-default properties, like the signInActivity property for a user account, you must include that property in the set to be retrieved. If you don’t want all the default properties, you can override that set using the Select query operator to filter properties (Graph requests) or the Property parameter for Graph SDK cmdlets. Reducing the set of properties to what’s required speeds up performance.

Permission Abuse

Finding the lowest level of permissions to run a Graph request used to be quite difficult. The Microsoft documentation wasn’t at the level it needed to be, and information was often hard to find. That excuse doesn’t apply anymore. The documentation is very precise about the required permissions from lower to higher.

Of course, errors still happen, albeit more rarely than occurred a few years ago. Figure 1 shows the permissions listed for the List users API. The lowest level permission listed is User.ReadBasic.All, which returns a limited set of properties. The next permission (User.Read.All) is the most useful. The error here is that the documentation doesn’t list User.ReadBasic.All among the application permissions.

Permissions to list user information.
Figure 1: Permissions to list user information

To check up the exact access for Graph permissions, I usually consult the Graph permission explorer. Among the interesting information available on the site are details of which cmdlets use individual permissions.

Script authors should test and document the permissions required by their code and include the set of scopes (permissions) in a Connect-MgGraph command at the start of the script. It’s easier for all concerned when this is done.

The Beta Conundrum

Graph APIs come in production (V1.0) and beta versions. It is perfectly acceptable to use beta code in scripts, and sometimes, it’s the only way to access preview features. Although the code usually continues to work, it’s bad practice to leave beta requests or cmdlets in place after a production version exists. Availability of support is the true differentiation between production and beta, and it doesn’t make sense to use unsupported code when a supported version is available.

That being said, few relish the thought of needing to review scripts to find and replace beta code. That’s quite a human feeling. To avoid the need, the rule should be to always use production Graph code in scripts. Restrict beta code for testing, and if a reason is discovered to justify using beta code in scripts, make sure to note the details. Knowing why a decision was made to use beta code becomes essential knowledge when planning for future updates.

Pagination and Graph PowerShell

The last common error is a failure to include code to handle pagination or to even recognize that pagination might be required to fetch data for processing. We’ve all experienced product demonstrations that work wonderfully at technology exhibitions when code runs against ten or twenty objects, only to be sadly disappointed when the same code runs against the thousands of objects found in the production environment. Failure to paginate is in the same category: code that works beautifully with small amounts of data and fails thereafter.

Graph API requests work with pages of data that contain a certain number of objects. The number differs from resource to resource. The point is that Graph code, including code written to explore a technique, should at least be cognizant that pagination might be needed to fetch all available data. It’s a lot easier to do with the Graph PowerShell SDK because many cmdlets support the All parameter to instruct the Graph to return all data. Of course, you might to return just a few objects, and that’s why the Top parameter exists, but both All and Top are ignored in many cases.

Practical Examples of Fetching Entra ID Accounts

To finish our review with some practical examples, let’s build from a simple command to more complex commands used to fetch Entra ID user accounts. The simplest command is:

Get-MgUser

The Graph returns 100 user objects (or less) because 100 is the default page size for the users endpoint. To get all users, add the All parameter. This is a good example of how easy pagination is with the Microsoft Graph PowerShell SDK, but it’s not recommended in large tenants unless you want to clear some time to have a coffee:

Get-MgUser -All

Use the Top parameter to return a limited set of objects. This command returns the first five objects:

Get-MgUser -Top 5

Unlike instructed otherwise, Entra ID returns both member and guest accounts when it fetches objects. To reduce the set to only member accounts, apply a filter.

Get-MgUser -Filter "usertype eq 'Member'" -All

Filters can cover multiple properties:

Get-MgUser -Filter "usertype eq 'Member' and department eq 'Sales'"

Entra ID (but not other workloads) supports the concept of advanced queries where the response comes from a different database that’s “consistent.” In other words, transactions have been finalized and are not in train. This example finds all member accounts that have no assigned licenses. The presence of the consistency level parameter marks it as an advanced query. Accounts used for shared mailboxes (unless they have an Exchange license to use an archive or have an extended quota), room mailboxes, and accounts synchronized from other tenants in a multi-tenant organization come into this category:

Get-MgUser -Filter "assignedLicenses/`$count eq 0 and usertype eq 'Member'" -ConsistencyLevel "Eventual" -CountVar count -PageSize 500 -All

Note the use of the PageSize parameter to increase the number of objects fetched in a single page. Increasing the page size reduces the number of round trips required to fetch all objects. You can’t change the page size for some Graph resources (like audit logs).

Another example of an advanced query is where you filter for accounts that hold a specific product SKU identifier. This example fetches accounts with Office 365 E3 licenses.

Get-MgUser -filter "assignedLicenses/any(s:s/skuId eq 6fd2c87f-b296-42f0-b197-1e91e994b900)" -All -PageSize 500

You can’t mix standard and advanced queries, so this won’t work:

Get-MgUser -filter "assignedLicenses/any(s:s/skuId eq 6fd2c87f-b296-42f0-b197-1e91e994b900) and department eq 'Sales'" -All -PageSize 500

Client and Server-Side Filters

The advantage of using license status to filter accounts is that it’s a good way to distinguish accounts used by real humans with those used for utility purposes. All the filters discussed here are server-side. In other words, the server processes the filter and returns objects that meet the filter criteria. You can also apply client-side filters by piping objects through the Where-Object cmdlet. From a performance perspective, it’s always better to use server-side filters and only resort to client-side filters when a server-side filter isn’t possible. The solution to combining advanced and standard queries to find objects is one such case. For example:

Get-MgUser -filter "assignedLicenses/any(s:s/skuId eq 6fd2c87f-b296-42f0-b197-1e91e994b900)" -All -PageSize 500 -Property DisplayName, Department | Where-Object {$_.department -eq "Sales"} | Format-Table DisplayName, Department

You’ll see that the Property parameter is used to make sure that the department property is included in the data retrieved from the Graph. If this isn’t done, the client-side filter can’t work because the necessary data isn’t available.

Filter, Properties, and Permissions

There’s lots more that could be discussed on the topic of optimizing the use of Graph-based cmdlets. At least, more than can be covered in a single article. Hopefully, the tips outlined here will help you write better code. Mastering these points has helped me enormously over the last few years. Feel free to add your own tips about how to write solid Graph PowerShell code in the comments. I love hearing how people make PowerShell better.

About the Author

Tony Redmond

Tony Redmond has written thousands of articles about Microsoft technology since 1996. He is the lead author for the Office 365 for IT Pros eBook, the only book covering Office 365 that is updated monthly to keep pace with change in the cloud. Apart from contributing to Practical365.com, Tony also writes at Office365itpros.com to support the development of the eBook. He has been a Microsoft MVP since 2004.

Leave a Reply