Guides · Excel automation
Close Only the Excel Your PowerShell Script Started
Published · Tested with Microsoft 365 Excel 16.0 (build 20430, 64-bit), PowerShell 7.6.6, Windows 11 Pro
A script opens Excel through COM, does its work and calls Quit(). Later someone finds EXCEL.EXE still running with no window, so the script gets a last line: Stop-Process -Name EXCEL. That line also closes the Excel the person had open, without saving. This guide reproduces the three usual failures and replaces them with one PowerShell function that cleans up its own Excel, and only that one, even when the job fails.
Before you start
- Windows with Excel desktop and a person signed in. Microsoft does not support Office automation from unattended, non-interactive code; Run an Excel Macro from PowerShell with a Timeout covers that, and how to stop a macro that hangs on a dialog. This guide is only about the end of the run: closing Excel.
- PowerShell 7 (pwsh). The tests below ran in pwsh 7.6.6, whose main thread is an STA thread; that matters for one of the findings.
Every result below comes from test scripts written for this guide. The “person's Excel” is a visible Excel that a separate script started, with a saved workbook user-budget.xlsx whose cell A1 was then changed and not saved. The workbooks are invented.
Three ways the cleanup goes wrong
1. Stopping Excel by name. With the person's Excel open, the script started its own Excel and ran the usual cleanup line:
Get-Process EXCEL | Stop-Process -Force
# Test run (output of our test script)
Excel before: 36140, 53488; this script's Excel: 27008
Excel after Stop-Process -Name EXCEL: none
A1 in user-budget.xlsx on disk: saved value
Every Excel ended, including 36140, the person's. Their unsaved edit in A1 was gone: the file on disk still had the old value, and Excel never asked. (53488 was a leftover from the third test below.)
2. Quit() while PowerShell still holds COM objects. Each object you touch (a workbook, a sheet, a range) is a COM reference that PowerShell keeps until .NET releases it. We called Quit() and watched the process for 60 seconds while the script kept running:
$wb = $excel.Workbooks.Add()
$ws = $wb.Worksheets.Item(1)
$ws.Range('A1').Value2 = 42
$wb.Close($false)
$excel.Quit()
# Test runs, one per line
Quit() called, references kept; Excel 18452 : still running after 60 s
Quit() + ReleaseComObject($excel) only; Excel 33300 : still running after 60 s
Quit() + variables cleared + GC.Collect; Excel 25832 : gone after 0.0 s
Releasing only the application object was not enough; the workbook and sheet references kept Excel alive. When the script's process ended, those Excels did exit, within about 20 seconds. In a long-running host (a scheduler loop, an open console, a service) they stay.
3. An error before Quit(). The script throws halfway, so Quit() never runs:
$wb = $excel.Workbooks.Add()
$wb.Worksheets.Item(1).Range('A1').Value2 = 42
throw 'input file not found'
$excel.Quit() # never reached
# Test run, the error caught by the host
caught: input file not found
Excel 53488 : still running after 60 s
This one did not go away when the script ended. In an earlier run of the same test, the hidden Excel was still running 13 minutes after its PowerShell process had exited, with its unsaved workbook, until we ended it by process id. This is the Excel nobody can see in the taskbar, and the one that tempts people to stop Excel by name.
Which Excel is yours
To end only your Excel you need its process id. Two common ways:
- Compare the Excel processes before and after starting yours. If something else starts Excel at the same moment, the difference holds both.
- Ask Windows which process owns Excel's window.
Application.Hwnd“returns a Long indicating the top-level window handle of the Microsoft Excel window” (Microsoft Learn), andGetWindowThreadProcessIdturns a window handle into the id of the process that created it (Microsoft Learn). The window exists even when Excel is hidden.
We started two scripts at the same time, three times; each one recorded both answers:
run 1:
diff found: 31996, 53384 | Hwnd says: 53384
diff found: 31996, 53384 | Hwnd says: 31996
run 2:
diff found: 36712, 55372 | Hwnd says: 55372
diff found: 36712, 55372 | Hwnd says: 36712
run 3:
diff found: 4512, 41232 | Hwnd says: 4512
diff found: 4512, 41232 | Hwnd says: 41232
The process comparison was ambiguous in all six cases; the window handle gave each script a different, single Excel. The function below uses the window handle, and keeps a Process object for that Excel right away, so it can later wait for it or end it without looking it up by name again.
The code
Save this as ExcelJob.ps1 and dot-source it. Invoke-ExcelJob starts a private Excel, records its process, runs your script block, and in finally releases the COM objects, calls Quit(), waits a few seconds for Excel to exit and, only if it is still running, ends that one process.
# ExcelJob.ps1: start a private Excel, run your code against it, and always clean up
# that Excel (and only that one), even when your code throws.
# Dot-source it: . ./ExcelJob.ps1
Add-Type -Namespace ExcelJob -Name Native -MemberDefinition @'
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(System.IntPtr hWnd, out uint processId);
'@
function Invoke-ExcelJob {
param(
# Receives the Excel.Application object as its only argument.
[Parameter(Mandatory)] [scriptblock] $ScriptBlock,
# How long Excel gets to exit after Quit() before this function ends it.
[int] $QuitTimeoutSeconds = 10
)
$excel = $null
$excelProcess = $null
try {
$excel = New-Object -ComObject Excel.Application
# The process id of *this* Excel, from its main window handle.
$excelPid = [uint32] 0
[void] [ExcelJob.Native]::GetWindowThreadProcessId([IntPtr] $excel.Hwnd, [ref] $excelPid)
$excelProcess = Get-Process -Id $excelPid | Where-Object ProcessName -eq 'EXCEL'
if (-not $excelProcess) { throw "Could not find the process of this Excel (window owner: $excelPid)." }
$excel.DisplayAlerts = $false
$excel.Visible = $false
& $ScriptBlock $excel
}
finally {
# Release the COM objects the script block left behind (workbooks, sheets, ranges)
# while Excel is still running: released after Quit(), they stalled for 60 s.
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
if ($excel) {
# With DisplayAlerts off, Quit() closes open workbooks without saving them:
# saving is the job's decision, inside the script block.
try { $excel.Quit() } catch { Write-Warning "Quit: $($_.Exception.Message)" }
[void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($excel)
$excel = $null
}
if ($excelProcess) {
# Last resort: end this Excel, and only this one, if it did not exit in time.
# Poll instead of WaitForExit(ms): on PowerShell's STA thread that call
# overran its time limit in our tests (60 s instead of 15).
$deadline = [DateTime]::UtcNow.AddSeconds($QuitTimeoutSeconds)
while (-not $excelProcess.HasExited -and [DateTime]::UtcNow -lt $deadline) {
Start-Sleep -Milliseconds 250
}
if ($excelProcess.HasExited) {
Write-Verbose "Excel $($excelProcess.Id) exited after Quit()."
}
else {
Write-Warning "Excel $($excelProcess.Id) still running after $QuitTimeoutSeconds s; ending it."
$excelProcess.Kill()
[void] $excelProcess.WaitForExit(5000)
}
}
}
}
Use it like this; everything that touches Excel goes inside the script block, and so does the save:
. ./ExcelJob.ps1
Invoke-ExcelJob {
param($excel)
$wb = $excel.Workbooks.Open('C:\reports\input.xlsx')
$wb.Worksheets.Item('Report').Range('A1').Value2 = 'Updated'
$wb.SaveAs('C:\reports\out.xlsx')
}
Why each part is there, from the test runs:
- The order in
finally.Quit()withDisplayAlertsoff quits “without saving” open workbooks (Microsoft Learn, Application.Quit), so no dialog can block it. The garbage collection runs beforeQuit(): in our first version it ran after, andGC.WaitForPendingFinalizers()then took 60 seconds whenever another Excel was open. FinalReleaseComObjecton the application. Microsoft suggests it overReleaseComObjectwhen a COM object must be released “at a determined time”, because it “will release the underlying COM component regardless of how many times it has re-entered the CLR” (Microsoft Learn).- A short wait, then end only that process. Even with all of the above, Excel did not always exit on its own. In three runs per case, with the job's objects left to the garbage collector, Excel had to be ended in 1 of 3 runs for a script block that touched nothing, 1 of 3 for one chained call, and 3 of 3 for a job that kept a workbook in a variable. The wait is a loop on
HasExitedbecauseProcess.WaitForExit(15000)returned after about 60 seconds, not 15, in this STA thread. - Never by name, and never twice. The function ends the
Processobject it took at the start; it never searches for Excel again. Ending it afterQuit()loses nothing the job saved: the job saves inside the script block, beforefinallyruns.
If you want Excel to exit on its own every time, release the objects yourself at the end of the script block, innermost first. In three runs each, that version exited after Quit() every time:
$books = $excel.Workbooks; $wb = $books.Add(); $sheets = $wb.Worksheets
$ws = $sheets.Item(1); $cell = $ws.Range('A1'); $cell.Value2 = 42
foreach ($o in $cell, $ws, $sheets, $wb, $books) {
[void] [Runtime.InteropServices.Marshal]::ReleaseComObject($o)
}
# Test runs (the same job, three times each; 8 s limit)
release run 1 : 3.2 s, exited after Quit()
release run 2 : 3.0 s, exited after Quit()
release run 3 : 2.9 s, exited after Quit()
vars run 1 : 8.9 s, ended by PID
vars run 2 : 8.9 s, ended by PID
vars run 3 : 8.9 s, ended by PID
That needs a variable for every intermediate object, including the ones a chained call like $excel.Workbooks.Add() creates silently. The process fallback is what makes the cleanup bounded when one slips through.
Results
With the person's Excel (36140) open and the leftover from the third test (53488) still running, three jobs through Invoke-ExcelJob (output copied from the test runs):
== the job works and saves out.xlsx
WARNING: Excel 24928 still running after 10 s; ending it.
Excel before: 36140, 53488; after: 36140, 53488; took 11.1 s
== the job throws 'input file not found'
WARNING: Excel 52260 still running after 10 s; ending it.
job failed: input file not found
Excel before: 36140, 53488; after: 36140, 53488
== the job leaves a reference outside the script block (5 s limit)
WARNING: Excel 48140 still running after 5 s; ending it.
Excel before: 36140, 53488; after: 36140, 53488
In every case the job's own Excel was gone when the function returned, the other two were untouched, and the error from the failed job reached the caller. The saved out.xlsx had 42 in A1 when we opened it again. With no other Excel open, the same successful job ran twice more: once its Excel exited after Quit() (3.6 seconds in all), once it was ended at the 10-second limit (11.1 seconds).
Limits
- Timings are from one machine. The 60-second stalls and which runs needed the fallback depended on what the job touched and on whether another Excel was open. Treat the numbers as an example from our tests, and keep the fallback.
- Ending the process is not a save. Anything the job did not save before
finallyis discarded, byQuit()or by the fallback. That is deliberate: a failed job should not overwrite a good file. - Do not let COM objects escape the script block. A workbook or range returned from the script block, or stored in a global variable, keeps Excel alive until the fallback ends it, and becomes unusable after that.
- It does not handle dialogs or a hung macro. If a
MsgBoxor an error dialog blocks a COM call inside the script block, the call never returns andfinallynever runs. For that, run the job in its own process with a time limit, as the macro timeout guide shows. - It is not for a server. Same rule as all Office automation: a desktop session with a person signed in.
Just want the scripts? The Scripts Pack · €12 has the code of this guide and the other guides ready to run, with examples and tests. The code on this page stays free.
The code on this page was written for this guide and tested on the versions above. The workbooks and their data are invented.