Group vs direct licence assignment
31 Aug 2026
Group-based vs direct licence assignment (and the mess in between)
Nobody decides to run both. It happens: the tenant was built by hand, someone later assigned an E3 to a security group to stop doing it by hand, and now a share of your users are licensed twice — once directly, once through membership. Nothing breaks, which is the problem. The seat count looks right, the users work, and the only artefact recording why a person has a licence is a click somebody made in 2023.
Then a leaver gets removed from the group and keeps their E3. Or a joiner is added to the group and gets nothing, because their usage location was never set. This is the playbook for getting to one assignment model on purpose, in the order that doesn't leave anyone unlicensed halfway.
The one property that tells them apart
The user object's assignedLicenses tells you what a
user has. It doesn't tell you how they got it, so it is the wrong
place to start. The right one is
licenseAssignmentStates,
whose whole purpose is that distinction. Per entry: skuId, the
state (Active, ActiveWithError,
Disabled, Error), any error, and the one
that matters here —
assignedByGroup is null when the licence was assigned directly, and contains the group's object id when it was inherited through membership. That single field is your entire migration report — and a user with two entries for the same skuId, one of them null, is a user in the mess.
Nothing at tenant level will show you this. The
subscribedSku
resource gives you consumedUnits — "the number of licenses
that have been assigned" — with no breakdown by how they were assigned. 412 of
450 reads identically whether every seat is group-managed or every seat is a
manual click, which is also why it is the number that
hides licence
waste so well.
The migration, in the order that matters
Read licenseAssignmentStates for every user and bucket by SKU: direct only, group only, both. Do this before you touch anything — the size of the "both" bucket is what decides whether this is an afternoon or a project. Done when the three buckets add up to consumedUnits for each SKU. If they don't, you have users with an error and you'll meet them in step 5 anyway.
Group-based licensing doesn't support nested groups: assign a licence to a group that contains groups and only the first-level members get it. A tidy "All Staff → Sales, Engineering" hierarchy silently licenses nobody. Done when each SKU maps to exactly one group whose members are all users.
Under group-based licensing, users without a location inherit the location of the tenant. That is not a failure you get told about — it is a successful assignment of the wrong service set to someone in another country, and it is why Microsoft recommends setting location as part of user creation. Done when every member of the group has an explicit usage location.
The two APIs return different things for a reason. POST /users/{id}/assignLicense answers 200 OK with the updated user. POST /groups/{id}/assignLicense answers 202 Accepted: the request was taken, the work happens afterwards. Done when the user's licenseAssignmentStates shows an entry carrying the group's id with state: Active — not when the call returned.
Microsoft's own guidance for moving users between licensed groups is add first, confirm, remove second — because doing it the other way leaves the user unlicensed until processing finishes, and "processing time varies based on tenant size and current load". The direct-to-group move has exactly the same shape. Done when no user has a null assignedByGroup entry for that SKU and consumedUnits has not moved.
The five ways it fails, in the payload
Group assignment can fail per user, long after the API said 202. When it
does, the failure is a value in the error property of that user's
assignment state — five named ones plus Other. They are worth
knowing by name, because the admin center's Errors & issues
tab is where they surface and the name is the fix.
| error | What it means in practice |
|---|---|
CountViolation |
The group is bigger than the number of seats you own. Membership is now the thing that buys licences — the group grew and the subscription didn't. |
MutuallyExclusiveViolation |
Conflicting service plans — typically a user who already holds a SKU containing the same plan, often the direct one you were about to remove. |
DependencyViolation |
A plan needs another plan that this assignment disables. Common when the group's assignment turns services off that the direct one left on. |
ProhibitedInUsageLocationViolation |
Step 3 didn't happen. The service isn't available in the location the user ended up with — possibly the tenant's, inherited. |
UniquenessViolation |
A duplicate proxy address blocks provisioning. Nothing to do with groups; the group assignment is just what finally tripped over it. |
Note the second one. MutuallyExclusiveViolation and
DependencyViolation are the two you are most likely to create
during the migration, in the window where a user legitimately holds
both assignments. That window is fine — it just needs to be short and
deliberate rather than the permanent state of a third of your tenant.
Why it's worth the afternoon
Direct assignment is not wrong. It is undocumented. The seat exists because someone clicked, and the reason left with them — which is the same mechanism behind licensed accounts nobody signs into. Group-based assignment replaces the click with a membership rule you can read, review and revoke, and it makes deprovisioning a single removal instead of a checklist.
The catch is that neither the admin center nor consumedUnits
will ever show you the split, so "are we clean?" is a question you can only
answer by walking licenseAssignmentStates for every user.
GraphPaaS reads that per tenant on a
schedule and keeps it, so the "both" bucket is a number you watch go to zero
rather than a script someone runs once and never again.
- licenseAssignmentState resource type — that
assignedByGroupis null for direct assignments and holds the group id otherwise, plus the fullstateanderrorenumerations. - Assign or unassign licenses to a group — nested groups only licensing first-level members, location inherited from the tenant, and the add-confirm-remove order with its unlicensed-gap warning.
- group: assignLicense — licences on a group flow to all its users, and the call returns 202 Accepted rather than a finished result.
- user: assignLicense — direct licensing as the stated alternative to group-based, returning 200 OK with the updated user.
- subscribedSku resource type —
consumedUnitsas a flat count of assigned licences, with no property distinguishing how they were assigned.
Every licence in your tenant, with the one thing the seat count leaves out: how it got there.
See your licences →