WMIC Is Gone. Here Is What To Use Instead

I hit this the way most people will hit it: not by reading a deprecation notice, but by watching something break.

I had a small utility script, one of those things that has been sitting in a repo for years quietly doing its job, and it needed the machine's BIOS serial number. It got that number the way everybody got that number in 2012, with wmic bios get serialnumber. On a freshly imaged Windows 11 box it came back with the least helpful error in the Windows catalogue:

'wmic' is not recognized as an internal or external command,
operable program or batch file.

My first instinct was the reflex every admin has developed over the last two years, which is to add the Feature on Demand back and move on:

DISM /Online /Add-Capability /CapabilityName:WMIC~~~~

That works today. It stops working shortly. Microsoft's release notes for the August 2026 servicing update remove the escape hatch along with the tool, and once that lands on your machines the reinstall command has nothing left to reinstall.

So I went looking for the rest of the calls instead, which is where this stopped being a five minute fix. A grep found plenty. What the grep would not tell me was which of those hits mattered. Some were a two second swap. At least one was a for /f block that would keep running perfectly after the swap and start writing wrong values into a CSV that feeds a CMDB. Those are not the same job, and no tool I could find would tell them apart.

This post covers what the replacements actually are, which parts of the migration are mechanical, which parts will bite you if you treat them as mechanical, and the scanner I ended up writing because that distinction is the whole problem.

What actually happened

WMIC has been on death row for a decade, which is exactly why nobody moved. Here is the full timeline as Microsoft documents it in KB5067470:

Year What changed
2016 Deprecated in Windows Server 2012
2021 Deprecated in Windows 10 (see note below)
2022 Converted to a Feature on Demand in Windows 11 22H2, still preinstalled and enabled
2024 Disabled by default in Windows 11 23H2 and 24H2, still installable as a FoD
2025 Removed on upgrade to 25H2, but you could add it back as a FoD
2026 Removed completely, no longer available as a FoD

A small inconsistency worth knowing about if your sources disagree: Microsoft's own KB puts the Windows 10 deprecation at version 21H2, while the Win32 SDK reference page and the deprecated features list both say 21H1. It does not change anything practical, but it will make you doubt your notes.

The 2026 step is the one that changes your job. Starting with Release Preview builds 26100.9267 for 24H2 and 26200.9267 for 25H2, Windows 11 no longer includes WMIC, and it is no longer available as a Feature on Demand. That is not a feature update gated behind a version bump you schedule. It arrives through normal servicing on machines you already have, which means the decision about when you deal with this is not entirely yours.

Worth being clear about the scope, because the headlines have been sloppy: WMI itself is not going anywhere. Only the wmic.exe command-line wrapper is being removed. The WMI service, the classes, the providers, and every programmatic interface remain part of Windows. Nothing you can query today becomes unqueryable. You just need a different front door.

The translation table

Most WMIC usage in the wild is a handful of one-liners copied off a forum post. Those map cleanly onto Get-CimInstance:

WMIC PowerShell
wmic bios get serialnumber (Get-CimInstance Win32_BIOS).SerialNumber
wmic computersystem get model (Get-CimInstance Win32_ComputerSystem).Model
wmic csproduct get uuid (Get-CimInstance Win32_ComputerSystemProduct).UUID
wmic os get caption,version Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version
wmic logicaldisk get deviceid,freespace Get-CimInstance Win32_LogicalDisk | Select-Object DeviceID, FreeSpace
wmic qfe list Get-HotFix
wmic process get name,processid Get-CimInstance Win32_Process | Select-Object Name, ProcessId
wmic process call create "notepad.exe" Invoke-CimMethod Win32_Process -MethodName Create -Arguments @{CommandLine='notepad.exe'}
wmic path win32_service where "state='Running'" get name Get-CimInstance Win32_Service -Filter "State='Running'" | Select-Object Name

The class names are identical, which is the good news. A lot of the migration is genuinely mechanical: strip the WMIC verb grammar, keep the Win32_ class, move the where clause into -Filter, and select the properties you actually wanted.

If you would rather keep your WQL, Get-CimInstance takes a raw query:

Get-CimInstance -Query "SELECT Name, ProcessId FROM Win32_Process WHERE Name = 'svchost.exe'"

Finding the class behind the alias

The table above covers the common cases. For anything else you need to know which class an alias was actually hitting, and the aliases are not always obvious. wmic csproduct is Win32_ComputerSystemProduct. wmic nicconfig is Win32_NetworkAdapterConfiguration. wmic qfe is Win32_QuickFixEngineering. wmic rdtoggle is Win32_TerminalServiceSetting, which nobody guesses.

Two commands cover the discovery. To find classes by name:

Get-CimClass -Namespace root/CIMV2 -ClassName Win32_*Disk* |
    Select-Object CimClassName

And to see what a class actually exposes, which is often more than the alias showed you:

Get-CimInstance Win32_ComputerSystemProduct | Get-Member -MemberType Properties

That second one matters more than it looks. WMIC aliases frequently exposed a curated subset of the underlying class. If a script's output looked thin, it was the alias, not the class, and you may find the property you always wanted was there the whole time.

The part that is not a find and replace

Here is where migrations go wrong. WMIC emitted formatted text. PowerShell emits objects. If a human read the output, swapping the command is enough. If a script parsed that text, the swap will appear to work and then quietly produce wrong values, which is a considerably worse outcome than a crash.

The classic offender is a batch file doing this:

for /f "skip=1 tokens=2 delims==" %%a in ('wmic os get caption /value') do set OSNAME=%%a

That is not a query, it is a text scraper. It is compensating for WMIC's specific key=value layout, its header row, and the trailing carriage returns WMIC left on every line. Point it at PowerShell output and none of those assumptions hold. Rewrite the whole block rather than the command inside it:

for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command ^
  "(Get-CimInstance Win32_OperatingSystem).Caption"`) do set OSNAME=%%a

-NoProfile is not optional in my opinion. It skips the user profile, which makes the call faster and, more importantly, deterministic on machines where somebody has customised their shell.

Four more differences that catch people:

Dates change shape. WMIC returned raw DMTF datetime strings, the 20260818103000.000000-420 format, and every script that touched a timestamp had string surgery wrapped around it. Get-CimInstance hands you a real DateTime object instead. That is better, but your substring extraction is now operating on something like 08/18/2026 10:30:00 and will produce nonsense. Uptime calculations are the usual casualty:

(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime

Array properties are real arrays. WMIC flattened multi-valued properties into a brace-wrapped string, so wmic nicconfig get ipaddress gave you something like {"192.168.1.5","fe80::1"} and scripts split on quotes and commas. Get-CimInstance returns an actual array. Index it or join it, do not parse it.

Empty results are empty, not a message. When a query matched nothing, WMIC printed No Instance(s) Available. to the console. That is text, and plenty of scripts checked for it. Get-CimInstance returns nothing at all, so the check to write is a straight emptiness test on the result rather than a string comparison.

Methods return an object, not a printed line. wmic process call create printed a return value block you could read or scrape. Invoke-CimMethod gives you an object with a ReturnValue property, and zero means success:

$result = Invoke-CimMethod Win32_Process -MethodName Create `
    -Arguments @{CommandLine = 'notepad.exe'}
if ($result.ReturnValue -ne 0) {
    throw "Process creation failed with code $($result.ReturnValue)"
}

Remote queries

wmic /node:SERVER01 becomes a CIM session, and this is one of the few places where the migration is a genuine upgrade rather than a lateral move:

$session = New-CimSession -ComputerName SERVER01
Get-CimInstance Win32_OperatingSystem -CimSession $session
Remove-CimSession $session

The old WMI path used DCOM, which needs port 135, port 445, and a range of dynamically assigned ports open on the target. CIM sessions use WS-Man over WinRM, the same single port as PowerShell Remoting. If you have a target that only speaks DCOM, New-CimSessionOption -Protocol DCOM gets you back there, but treat that as the documented exception rather than the default.

Two habits worth building while you are in there. First, sessions are reusable, so if a script queries five classes from one host, open one session rather than five connections. Second, restrict the properties you pull:

Get-CimInstance Win32_OperatingSystem -CimSession $session `
    -Property Caption, Version, LastBootUpTime

By default a WMI query drags back every property whether you use it or not. Locally that is invisible. Across a hundred machines it is not.

One more thing about /node: sweeps. They very often carry /user: and /password: on the same command line, which means a credential is sitting in a checked-in script and in the command history of every machine that ran it. Migrating the call is a good moment to fix that too, and it is worth tracking as a separate piece of work, because moving the query to a CIM session does not by itself remove the password.

Three traps worth knowing about

Do not carry wmic product forward as Win32_Product. It is the most common inventory one-liner and the worst class in WMI. Querying it triggers an MSI consistency check against every installed package, which is slow and can cause installers to reconfigure themselves mid-query. Translating it faithfully into Get-CimInstance Win32_Product keeps the actual problem, because the problem was never the command, it was the class. Read the uninstall keys instead:

Get-ItemProperty HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*,
                 HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* |
    Where-Object DisplayName |
    Select-Object DisplayName, DisplayVersion, Publisher

Do not migrate to Get-WmiObject. Microsoft's own migration guidance lists it as a supported alternative alongside Get-CimInstance, which is a strange thing to find in a document about retiring legacy tooling. The WMI cmdlets are themselves deprecated and are not available in PowerShell 6 and later. Rewriting a WMIC call as Get-WmiObject produces a script that works in Windows PowerShell 5.1 and fails the moment anyone runs it in PowerShell 7. Go straight to CIM.

-Filter speaks WQL, not PowerShell. This one produces confusing errors because the parameter looks like it should take a script block. It does not. Strings are single-quoted, comparison is = rather than -eq, and wildcards are LIKE '%pattern%':

# Correct
Get-CimInstance Win32_Service -Filter "State='Running' AND StartMode='Auto'"

# Wrong, and the error will not make this obvious
Get-CimInstance Win32_Service -Filter { $_.State -eq 'Running' }

Filtering server-side with -Filter is meaningfully faster than pulling everything and piping to Where-Object, especially remotely, so it is worth getting right rather than avoiding.

Is there a tool that does this for me

Short version: nothing rewrites your code, and there is a good reason for that.

For detection there are two existing options. PSScriptAnalyzer ships a rule called AvoidUsingWMICmdlet, which is the only first-party choice and has a gap that matters here: it flags the five deprecated cmdlets and does not flag wmic.exe at all. It is looking for the PowerShell cmdlets, not the binary being removed, so it will happily pass a script full of wmic bios get serialnumber. It also never opens a .bat file, which is where most of the remaining WMIC in the world lives.

The community option is Find-WmiUsage, a single PowerShell 7 function that matches fifteen patterns and does cover the gaps: wmic as an external command, the gwmi/iwmi/swmi/rwmi aliases, the System.Management .NET classes, and the SWbemLocator COM objects. Arbitrary extensions, CSV and JSON output. It is detection only.

Nobody has built the converter, and after spending a while thinking about how one would work, I am fairly sure nobody should. A mechanical rewriter would produce confident garbage for three reasons. Aliases are not class names, and the alias-to-class mapping is a hand-maintained lookup table that does not exist completely anywhere public. The hard part is usually the parsing wrapped around the call rather than the call itself, so a tool that fixed only the command would leave you with a script that runs and returns wrong values. And some translations are judgment calls rather than substitutions: no transpiler is going to decide on its own that wmic product should become a registry read.

Detection is automatable. Translation needs a person who knows what the script was for.

Sorting by effort instead of by presence

Which leaves a gap in the middle, and that gap is where the actual cost of this migration sits. Everyone already knows WMIC is deprecated. PSScriptAnalyzer answers the yes-or-no question for PowerShell files and a grep answers it for everything else. Neither answers the question a migration is actually scheduled against, which is: of these four hundred hits, which one is a two second swap and which one is a day?

So I wrote a small module that answers that instead. It is called WmicTriage, and it is three functions:

Function What it does
Invoke-WmicScan Scans files and returns one classified finding per call site
Export-WmicScanReport Writes those findings as CSV or SARIF
Get-WmicRule Lists the rules, so a classification can be argued with

There is nothing to install. It has no RequiredModules, makes no WMI calls, and is not Windows-only, because it is text and XML parsing over files on disk. Scanning a deployment share from a Linux build agent works fine.

Import-Module .\WmicTriage.psd1
Invoke-WmicScan -Path '\\dp01\Deploy$'

Four tiers, ordered by effort

The output is a classification rather than a match list. Four named tiers, in increasing order of how much work the replacement is. Effort, not severity, and that distinction turns out to matter:

Tier Meaning Example
Mechanical Swap the command, done wmic bios get serialnumber on its own line
Wrapped Output is captured and parsed, so the wrapper must be rewritten too for /f ... in ('wmic ...')
Semantic The correct translation is a judgment call, not a substitution wmic product, anything touching a datetime or multi-value property
Environmental PowerShell may not exist at the call site WMIC inside startnet.cmd or a WinPE task sequence step

Wrapped is the reason it exists

No other tool detects this case, and it is the one that produces silently wrong output instead of a clean failure. Here is a real line out of the example deployment share that ships with the module:

for /f "skip=2 tokens=1,2 delims=," %%A in ('wmic csproduct get vendor^,name /format:csv') do (
    set VENDOR=%%A
    set MODEL=%%B
)

Swap the command for Get-CimInstance and nothing breaks. The for /f keeps running. It keeps setting VENDOR and MODEL from whatever the token positions now land on. Nothing errors, and the CSV that reaches the CMDB is wrong in a way nobody notices for a quarter.

The skip=2 tokens=1,2 delims=, spec, not the command, is the thing that has to be rewritten. That spec is not always on the same line as the call, which is why a Wrapped finding spans the whole block and records the for options as their own field:

Invoke-WmicScan -Path .\Scripts -Tier Wrapped |
    Format-List Command, ForOptions, Reason, SuggestedReplacement

Wrapped is not only for /f, either. Output redirected to a file, /output: and /append:, a pipe into find or findstr, assignment into a PowerShell variable, and .Exec() in VBScript all mean the same thing: something downstream is reading the WMIC text layout, and that reader has to change when the command does.

Escalation by effort, not first match

When several rules match one call, the finding takes the hardest tier, and every rule that fired is kept in a RuleIds field so nothing is lost. This produces one result that looks wrong until you think about it. A mechanical one-liner sitting in a WinPE script is reported as Environmental, not Mechanical:

rem Straight to the console for the technician standing there
wmic computersystem get model

The substitution really is trivial. But whether the boot image has PowerShell in it at all is not trivial, and that question has to be answered before the easy part means anything. Ordering by effort rather than severity is what makes the tier come out right here.

Each file type gets the reader it deserves

.bat and .cmd lead, because that is the gap PSScriptAnalyzer leaves. Then .ps1, .psm1, .vbs, .vbe, .wsf, .js, .py, and .xml. Each gets its own reader rather than one regex for everything:

  • Batch joins ^ continuations, tracks comment state, and balances parentheses across lines to find where a block actually ends.
  • PowerShell uses the real parser. Capture detection is exact rather than guessed, because $x = wmic, (wmic ...), wmic ... | and wmic ... > are four different syntactic positions that a regex has to guess at and the syntax tree simply knows.
  • Task sequence XML uses an XML parser, so a finding names the step rather than a line number nobody is ever going to edit. The runIn attribute, or the enclosing group names, is what decides whether that step is WinPE.

There is also a table of per-language knowledge covering what a comment looks like and what quotes a string. That one is fussier than it sounds. An apostrophe delimits a string in PowerShell, JScript and Python, and is just an apostrophe in batch, where treating it as a quote runs every echoed "don't" into the command on the following line. Getting it wrong does not throw. It produces a command in the report with half the host language stapled to the end of it.

The ratio is the point

The module ships with a fake deployment share to point it at: a collector carried forward from an XP rollout, a vendor file nobody is allowed to edit, a WinPE startup script, a task sequence export, and a migration somebody started and abandoned halfway.

Invoke-WmicScan -Path .\Examples\DeploymentShare

Nine files. Twenty-nine deprecation findings plus one security finding, breaking down as 6 Mechanical, 4 Wrapped, 15 Semantic, 4 Environmental.

Six of twenty-nine are the simple swap that the whole job is usually assumed to be. That ratio is the entire reason the tool exists. If you scope this migration off a grep count, you will scope it as twenty-nine swaps and deliver six.

The half-migrated file in that example is worth a look on its own, because it is the state most estates are actually in:

# Migrated. Real properties off a real object.
$inventory['Serial'] = (Get-CimInstance -ClassName Win32_BIOS).SerialNumber

# Not migrated. Captured into a variable and split on '=', which only works
# because WMIC prints /value output as name=value text.
$bootLine = wmic os get lastbootuptime /value
$inventory['BootRaw'] = @($bootLine -split '=')[-1]

Somebody did the easy calls and stopped. Both survivors are Wrapped. They look like leftovers, and they are the expensive part.

Reports, and using it in CI

Two output formats, because two different people read them and they want opposite things:

$findings = Invoke-WmicScan -Path .

# For whoever is planning the migration
$findings | Export-WmicScanReport -Path .\wmic.csv

# For a pipeline, so the tiers annotate the actual lines
$findings | Export-WmicScanReport -Path .\wmic.sarif -Format Sarif

The SARIF carries a partialFingerprints entry built from path, rule and command, and deliberately not the line number. Add a comment at the top of a batch file and every call below it shifts down. A fingerprint that included the line would close two hundred findings and open two hundred identical ones, and the second report anyone read would be noise.

For CI, exit non-zero on Mechanical and Wrapped, where a machine can judge the work. Zero with a warning on Semantic and Environmental, because both end in a judgment call, and a build that fails on a judgment call is a build people learn to bypass.

The module never calls exit itself. A library that terminates its host is a library you cannot call from anything else, so the verdict rides on the findings instead:

$summary = Invoke-WmicScan -Path . -Summary
$summary.ByTier

if ($summary.FailsBuild) {
    Write-Error 'Mechanical or Wrapped WMIC usage found'
    exit 1
}

Security findings are the one deliberate disagreement with that rule. A checked-in password is raised as a SARIF error, because it is one by any reading, but it never fails the build, since this gate is about WMIC deprecation and letting one problem close the other helps nobody. They are also raised as separate findings rather than folded into the tier, so a line carrying both /node: and /password: appears twice. Neither problem can be closed by fixing the other.

Suggestions are advisory, always

Every finding carries a SuggestedReplacement, and nothing in the module will ever apply one. Advisory is $true on every finding in every format.

That restraint is what makes printing them defensible at all. On the Wrapped tier the honest suggestion is "restructure this block" rather than a command to paste, because a one-line replacement for a block would be a confident lie. On the Semantic tier no tool can be right, which is what the tier means.

What it deliberately does not do

It does not resolve variables. A batch file that does this:

set WMICEXE=%SystemRoot%\System32\wbem\wmic.exe
"%WMICEXE%" computersystem get domain,totalphysicalmemory >> %OUTFILE%

gets a Semantic finding on the set line and nothing at all on the call. Chasing call sites is an AST problem in PowerShell and unsolvable in batch, and it would have eaten the whole project. So the finding says in as many words that the count is an undercount until somebody greps for that variable by hand. It says it in the report, where it gets read, rather than only in the documentation, where it does not.

It does not score confidence. Four named tiers that people can filter on beat a 0-100 number nobody calibrates.

It does not skip comments by default. A WMIC command in a rem line is usually documentation that will mislead someone later, and it is the example the next person copies. It never fails a build, because it does not run, and -ExcludeComment drops it entirely if you disagree.

It does not have a -Recurse switch. Directories are walked in full, always. An inventory that silently covered only the top level of a deployment share is worse than no inventory, because it will be believed.

The rules are data

Every detection is one entry in Data\WmicRules.psd1. The engine holds no WMIC knowledge of its own, so a rule that appears in that file is live with no code change:

@{
    Id     = 'WMIC211'
    Name   = 'Something worth catching'
    Tier   = 'Semantic'
    Match  = @{ Alias = @('nicconfig'); Verb = @('call') }
    Reason = @( 'Why this is not a swap.' )
    Suggestion = @( 'What to do instead, with {Class} and {Properties} filled in.' )
}

Every key in Match must hold, and within one key any listed value will do, so it is an AND of ORs. An empty Match matches everything, which is how the baseline Mechanical rule works.

The loader validates hard, and that is deliberate. A rule with a typo in a Match key would not throw, it would simply never fire, and the report would come back quietly short. A short report is the one failure mode a tool like this cannot have, so an unknown match key, an unknown tier, a duplicate id, and a reference to a property group that does not exist are all errors at load time.

Two other data files are worth editing. Data\WmicAliases.psd1 maps aliases to classes, with a NonObvious flag on the ones a reader will not guess, which is how the report can tell you qfe means Win32_QuickFixEngineering instead of leaving you to look it up. Data\WmicProperties.psd1 holds the property groups behind the Semantic tier: DateTime, Interval, MultiValue, Boolean. Adding a property there is the cheapest way to make the scanner smarter, because those groups are what let it notice that a particular call is going to change shape rather than just change syntax.

And if you want to argue with a classification, the tool will tell you why it made it:

(Get-WmicRule -Id WMIC200).Reason

A tier nobody can argue with is a tier nobody trusts.

One deliberate compatibility choice

It targets Windows PowerShell 5.1 as well as PowerShell 7, which is against my usual habit for anything new. The reasoning is specific to this problem: the estates that still run WMIC are the same estates whose jump boxes and build servers never got pwsh. A migration tool that cannot run where the migration is happening is not much use.

Finding your callers

Whether or not you use any of the above, the hard part of this migration is the inventory rather than the rewriting. WMIC calls hide in logon scripts, imaging task sequences, vendor installers, monitoring agents, and Node modules. A recursive grep is the cheap first pass and will tell you whether you have a problem at all:

Get-ChildItem -Recurse -Include *.bat,*.cmd,*.ps1,*.vbs,*.js,*.py |
    Select-String -Pattern '\bwmic\b' |
    Select-Object Path, LineNumber, Line

That catches your own code. It does not catch third-party dependencies, and those are real: the widely used pidtree npm package shells out to wmic and fails on machines without it, which means anything depending on it inherits the problem. The fastest way to surface that category is to stop reading code and start breaking things on purpose:

DISM /Online /Remove-Capability /CapabilityName:WMIC~~~~

Do that on a test box now, while the capability can still be added back, then run your builds, your logon scripts, your imaging sequence, and your monitoring agent against it. You get a free preview of every failure before it arrives on its own schedule.

When PowerShell is not on the table

Two situations where "just use PowerShell" is not an answer.

The first is WinPE and WinRE. PowerShell is not present in either by default. It is available as an optional component you add through the Windows ADK when building a custom image, and the components have to be added in dependency order. If your deployment sequence used WMIC inside the preinstall environment, budget real time for this one, because it is an image engineering task rather than a script edit. This is exactly why Environmental exists as a separate tier: the line itself might be a five second fix, but you cannot start it until somebody has answered a question about the boot image, and frequently nobody remembers what went into that boot image.

The second is anywhere invoking a shell is awkward or the caller is a fixed-format batch context. Several WMIC uses have plain command-line equivalents that have been there all along:

Need Command
System summary systeminfo
Running processes tasklist
Kill a process taskkill /PID 1234 /F
Service state and control sc query, sc config
Installed updates dism /online /get-packages
Event logs wevtutil
Disk and volume info diskpart, fsutil

These are not object-oriented and they are not pleasant to parse, but they are not being removed either.

The actual takeaway

This is not a hard migration. It is a wide one, and it is unevenly distributed. The commands map close to one for one, the class names do not change, and nothing you could query before becomes unavailable. What makes it painful is that WMIC calls accumulated over twenty years in places nobody owns anymore, and that the hits are not interchangeable. Most of them are nothing. A few of them will keep running after you fix them and quietly report the wrong thing for a quarter.

The removal is arriving through servicing rather than a feature update you schedule, so the window where you can test on your own terms is the one you are in right now. Pull the capability off one machine, run everything you own against it, and fix what falls over. Point something at your repos that tells you which of the findings are real work rather than just how many there are. That is a much better afternoon than the one where you find out from a ticket, and a much better estimate than the one you get from a grep count.