How to Uninstall Software Using Python (Windows, macOS, Linux)

Last month I had to clean up a fleet of lab machines before a workshop: same set of tools installed, same set of tools to remove, and not enough time to click through uninstall wizards one by one. That’s the moment where “uninstalling software” stops being a purely UI task and becomes an automation problem.

Python is a solid fit here because it can:

  • Discover what’s installed (with the same mechanisms the OS uses)
  • Call the system’s uninstall entry points safely
  • Log exactly what happened for audits or rollbacks

You’ll learn how I approach uninstalls with Python across Windows, macOS, and Linux: what’s safe, what’s brittle, which commands map to real uninstallers (not just file deletion), and how to build a small cross-platform uninstaller script that you can actually run in production. I’ll also call out the traps—privilege prompts, silent flags, stale inventory data, and command-injection risks—because uninstall automation is one of those areas where “it ran” isn’t the same as “it removed the right thing.”

What “uninstall” means (and what it doesn’t)

When you uninstall correctly, you’re asking the OS (or its package manager) to reverse an installation: remove binaries, unregister services, tear down scheduled tasks, unregister COM components, remove receipts, and clean up configuration.

When you uninstall incorrectly, you delete an executable and leave behind:

  • Services that still try to start
  • Drivers or kernel extensions
  • Scheduled tasks
  • MSI product registrations
  • Security agents that keep hooks installed

I think of it like removing a home appliance: you don’t just throw away the microwave door; you unplug it, remove the mount, and cap the wiring safely. In software terms, the “wiring” is all the registration and system integration.

So my rule is simple:

  • If the software was installed by a package manager, uninstall via that package manager.
  • If it was installed by an installer technology (MSI/PKG), uninstall via that technology.
  • Only delete files directly when you’re dealing with portable apps, dev builds, or leftovers after a real uninstall.

Safety first: permissions, validation, and “dry run”

Uninstall scripts can be destructive. Before writing any code, decide what safety guarantees you want.

Here’s what I recommend for a Python-based uninstaller:

  • Dry run mode: print what you would run, do not run it.
  • Exact matching: require an exact package name or exact display name match by default.
  • Logs: record timestamps, command lines (sanitized), exit codes, and stderr.
  • Privilege awareness:

– Windows: expect UAC prompts unless running elevated.

– Linux: expect sudo for system packages.

– macOS: expect admin rights for /Applications changes and receipts.

  • Timeouts: a hung uninstaller is common (waiting for UI, or stuck on a custom action).
  • Non-interactive strategy: decide whether you support silent uninstalls; if yes, you must pass the right flags.

One more security point: never build shell command strings with untrusted input. You should treat “app name” as untrusted input unless it’s hard-coded or chosen from a list you fetched.

Here’s the baseline helper I use (works everywhere):

from future import annotations

import subprocess

from dataclasses import dataclass

@dataclass(frozen=True)

class CommandResult:

argv: list[str]

returncode: int

stdout: str

stderr: str

def run(argv: list[str], *, timeouts: int = 600, dryrun: bool = False) -> CommandResult:

if dry_run:

return CommandResult(argv=argv, returncode=0, stdout=‘(dry run)‘, stderr=‘‘)

completed = subprocess.run(

argv,

text=True,

capture_output=True,

timeout=timeout_s,

)

return CommandResult(

argv=argv,

returncode=completed.returncode,

stdout=completed.stdout,

stderr=completed.stderr,

)

I prefer subprocess.run([…]) over os.system() for two reasons:

  • You avoid shell parsing surprises (and injection bugs).
  • You get stdout/stderr and return codes cleanly.

Traditional vs modern patterns (what I see in 2026)

Goal

Traditional approach

Modern approach I recommend —

— Run uninstall command

os.system(‘…‘)

subprocess.run([‘…‘]) with timeouts + logs Windows inventory

WMI command-line tooling

Registry inventory + PowerShell + winget where available Windows MSI uninstall

“Find by name” via WMI

Uninstall by product code (MSI GUID) or UninstallString macOS app removal

rm -rf /Applications/App.app

Prefer vendor uninstallers, receipts (pkgutil), or Homebrew for casks Linux removal

delete files

use apt/dnf/pacman/zypper, plus snap/flatpak

A quick note on Windows WMI CLI tooling: the legacy command-line interface often shown in older snippets is deprecated and may be missing on newer Windows images. Even when it exists, it can be slow and can trigger Windows Installer “self-heal” checks. In practice, I avoid it for automation unless I’m forced.

Windows: uninstalling safely without brittle shortcuts

Windows software comes in a few common forms:

  • MSI installs (best for automation)
  • EXE installers that register an uninstaller command (varies a lot)
  • Microsoft Store apps (AppX/MSIX)
  • Portable apps (no true uninstall; you delete files)

For MSI and EXE installs, the “source of truth” for automation is usually the Uninstall registry keys:

  • HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall
  • HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall
  • HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall (per-user installs)

Step 1: list installed apps via the registry

This is the most reliable inventory method I use from Python.

from future import annotations

import winreg

from dataclasses import dataclass

UNINSTALL_ROOTS = [

(winreg.HKEYLOCALMACHINE, r‘SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall‘),

(winreg.HKEYLOCALMACHINE, r‘SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall‘),

(winreg.HKEYCURRENTUSER, r‘SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall‘),

]

@dataclass(frozen=True)

class InstalledApp:

display_name: str

uninstall_string: str | None

quietuninstallstring: str | None

publisher: str | None

version: str | None

registry_key: str

def getvalue(key: winreg.HKEYType, name: str) -> str | None:

try:

value, _ = winreg.QueryValueEx(key, name)

if isinstance(value, str) and value.strip():

return value

except OSError:

return None

return None

def listinstalledapps() -> list[InstalledApp]:

apps: list[InstalledApp] = []

for hive, path in UNINSTALL_ROOTS:

try:

root = winreg.OpenKey(hive, path)

except OSError:

continue

try:

subkeycount, , _ = winreg.QueryInfoKey(root)

for i in range(subkey_count):

subkey_name = winreg.EnumKey(root, i)

fullkey = f‘{path}\\{subkeyname}‘

try:

sk = winreg.OpenKey(hive, full_key)

except OSError:

continue

displayname = get_value(sk, ‘DisplayName‘)

if not display_name:

continue

apps.append(

InstalledApp(

displayname=displayname,

uninstallstring=get_value(sk, ‘UninstallString‘),

quietuninstallstring=getvalue(sk, ‘QuietUninstallString‘),

publisher=getvalue(sk, ‘Publisher‘),

version=getvalue(sk, ‘DisplayVersion‘),

registrykey=f‘{hive=}, {fullkey}‘,

)

)

finally:

winreg.CloseKey(root)

apps.sort(key=lambda a: a.display_name.casefold())

return apps

If you print these results, you’ll quickly notice:

  • Some entries have no uninstall command (drivers, components, updaters).
  • Some uninstall strings are weird (they call helper EXEs).
  • Some apps provide a QuietUninstallString that’s ideal for automation.

Step 2: choose the right uninstall mechanism

I recommend this priority order:

  • QuietUninstallString (if present)
  • If it’s MSI: msiexec /x {PRODUCT-CODE} /qn /norestart
  • Otherwise: UninstallString (may be interactive)

The tricky bit is that registry uninstall strings are often stored as a single command line. Parsing Windows command lines correctly is annoying, so I usually avoid re-parsing when I can.

If you have a QuietUninstallString, you can often run it through cmd.exe /c … and let Windows handle parsing. That does involve a shell, so only do it if the string comes from the registry (trusted-ish) and you’re not injecting user-controlled parts.

Here’s a pragmatic uninstaller that:

  • Searches by exact display name (case-insensitive)
  • Prefers quiet uninstall
  • Falls back to uninstall
  • Supports dry-run

from future import annotations

import sys

from typing import Iterable

def findexact(apps: Iterable[InstalledApp], displayname: str) -> InstalledApp | None:

target = display_name.casefold()

for app in apps:

if app.display_name.casefold() == target:

return app

return None

def uninstallwindowsapp(displayname: str, *, dryrun: bool = False) -> int:

apps = listinstalledapps()

app = findexact(apps, displayname)

if not app:

print(f‘No exact match for: {display_name}‘)

return 2

cmdline = app.quietuninstallstring or app.uninstall_string

if not cmdline:

print(f"Found ‘{app.display_name}‘ but no uninstall command was registered.")

return 3

# Run via cmd.exe to respect the stored command-line formatting.

result = run([‘cmd.exe‘, ‘/c‘, cmdline], timeouts=1800, dryrun=dry_run)

print(‘Command:‘, ‘ ‘.join(result.argv))

print(‘Exit code:‘, result.returncode)

if result.stdout.strip():

print(‘STDOUT:\n‘, result.stdout)

if result.stderr.strip():

print(‘STDERR:\n‘, result.stderr)

return 0 if result.returncode == 0 else 1

if name == ‘main‘:

if sys.platform != ‘win32‘:

raise SystemExit(‘This script is for Windows.‘)

# Example:

# python uninstall.py ‘Epic Games Launcher‘ –dry-run

name = sys.argv[1]

dry = ‘–dry-run‘ in sys.argv

raise SystemExit(uninstallwindowsapp(name, dry_run=dry))

In real deployments I add:

  • A fuzzy search option that prints candidates but does not uninstall.
  • A “require publisher match” safeguard for ambiguous names.

MSI-specific tip: uninstall by product code when possible

If the uninstall string is msiexec.exe /I{GUID} or similar, you can switch to /x and add silent flags. A common pattern:

  • Interactive repair/install: /I{GUID}
  • Uninstall: /x {GUID}

Example command:

result = run([

‘msiexec.exe‘,

‘/x‘,

‘{12345678-ABCD-1234-ABCD-1234567890AB}‘,

‘/qn‘,

‘/norestart‘,

], timeouts=3600, dryrun=False)

This is one of the few times I’m comfortable running an uninstall “blind” at scale, because MSI is consistent about exit codes and logging.

A detail I always bake into my automation: MSI exit codes aren’t strictly “0 success / non-zero failure.” The common one you’ll see in fleet work is “success but reboot required” (for example, 3010). In production, I treat that as success-with-action and record it explicitly so I can schedule reboots instead of letting them surprise me.

Microsoft Store (AppX/MSIX) apps

These are best handled with PowerShell cmdlets such as Get-AppxPackage and Remove-AppxPackage (and for provisioned apps, Remove-AppxProvisionedPackage). From Python you can call PowerShell safely with subprocess.

package_name = ‘Microsoft.WindowsNotepad‘ # example

ps = [

‘powershell‘,

‘-NoProfile‘,

‘-NonInteractive‘,

‘-Command‘,

f"Get-AppxPackage -Name ‘{package_name}‘ | Remove-AppxPackage",

]

result = run(ps, timeouts=1800, dryrun=False)

You should expect:

  • Per-user scope: removing for one user doesn’t always remove for all.
  • Admin requirements for provisioned package changes.

Windows (modern): using winget from Python

On many Windows setups in 2026, winget is available and can remove a lot of mainstream software cleanly. I use it when:

  • I know the exact package ID
  • I want a consistent UX and better logs
  • I’m okay with the fact that not everything is in the repository

List and uninstall with winget

First, list packages (manually once) to find the ID:

  • winget list

Then uninstall by ID:

from future import annotations

def wingetuninstall(packageid: str, *, dry_run: bool = False) -> CommandResult:

# –silent support varies by installer; winget will try.

argv = [

‘winget‘,

‘uninstall‘,

‘–id‘,

package_id,

‘–accept-source-agreements‘,

‘–accept-package-agreements‘,

‘–silent‘,

]

return run(argv, timeouts=3600, dryrun=dry_run)

# Example:

# result = wingetuninstall(‘EpicGames.EpicGamesLauncher‘, dryrun=True)

In practice, winget is great for “standard apps” and weaker for:

  • Older enterprise tools with custom installers
  • Internal line-of-business packages
  • Anything installed per-user with unusual uninstallers

Performance-wise, invoking winget typically adds noticeable overhead (often a fraction of a second to a couple seconds) before the real uninstaller even starts, because it does discovery and agreement checks.

macOS: uninstall patterns that don’t turn into file whack-a-mole

macOS has three common “install stories”:

  • Drag-and-drop .app bundles
  • Installer packages (.pkg) that leave receipts
  • Homebrew formulas/casks

1) Removing an .app bundle (and only when that’s correct)

If the software is truly self-contained, removing /Applications/SomeApp.app is enough.

But be honest: many apps leave LaunchAgents, login items, helper tools, and caches.

A safe Python example that only removes the app bundle (and nothing else):

from future import annotations

import shutil

from pathlib import Path

def removeappbundle(appname: str, *, dryrun: bool = False) -> None:

apppath = Path(‘/Applications‘) / f‘{appname}.app‘

if not app_path.exists():

raise FileNotFoundError(str(app_path))

if dry_run:

print(f‘(dry run) Would remove: {app_path}‘)

return

# App bundles are directories.

shutil.rmtree(app_path)

I still consider this a “lowest common denominator” method. It’s appropriate for:

  • Simple apps that genuinely live inside the bundle
  • Lab machines where you don’t care about per-user caches
  • Situations where you can reinstall cleanly later

It’s not my first choice for security tools, VPN clients, device management agents, audio drivers, printers, virtualization software, or anything that installs privileged helpers.

2) Uninstalling a PKG-installed app via receipts

When a .pkg installed files across the system, I treat receipts as the best map of what happened.

The mental model:

  • pkgutil can list “package IDs” (receipts) and the files associated with them.
  • Many vendors ship an uninstaller app or script; if they do, I prefer it.
  • If there’s no vendor uninstaller, I use receipts to identify what to remove.

The two pkgutil commands I reach for most:

  • List receipts: pkgutil –pkgs
  • List files for a receipt: pkgutil –files

From Python, I’ll call pkgutil and parse output. Here’s a minimal helper that searches for likely receipts and prints them (I keep it read-only by default):

from future import annotations

import re

def maclistpkgids(*, dryrun: bool = False) -> list[str]:

result = run([‘pkgutil‘, ‘–pkgs‘], timeouts=60, dryrun=dry_run)

if result.returncode != 0:

raise RuntimeError(result.stderr.strip() or ‘pkgutil –pkgs failed‘)

return [line.strip() for line in result.stdout.splitlines() if line.strip()]

def macfindpkgids(pattern: str, *, dryrun: bool = False) -> list[str]:

rx = re.compile(pattern, re.IGNORECASE)

return [pkg for pkg in maclistpkgids(dryrun=dry_run) if rx.search(pkg)]

# Example:

# print(macfindpkg_ids(r‘chrome|google\.chrome‘))

If I decide to remove files using receipts, I do it with extra caution:

  • I require an exact package ID match (not “whatever contains ‘vpn’”).
  • I print the file list first and sanity-check paths.
  • I avoid deleting shared locations that look generic (like /usr/local/bin/python).

A practical workflow I use on real machines:

1) Identify the vendor package ID(s)

2) Print the file list for each ID

3) If the list looks correct, remove those files

4) Forget the receipt (pkgutil –forget) so inventory stays clean

Here’s what that looks like in Python as a two-stage approach (preview then apply):

from future import annotations

from pathlib import Path

def macpkgfiles(pkgid: str, *, dryrun: bool = False) -> list[str]:

result = run([‘pkgutil‘, ‘–files‘, pkgid], timeouts=60, dryrun=dryrun)

if result.returncode != 0:

raise RuntimeError(result.stderr.strip() or f‘pkgutil –files {pkg_id} failed‘)

return [line.strip() for line in result.stdout.splitlines() if line.strip()]

def macremovepkgpayload(pkgid: str, *, dry_run: bool = False) -> None:

# pkgutil –files outputs paths relative to the install root.

files = macpkgfiles(pkgid, dryrun=dry_run)

# I delete files first, then attempt to remove now-empty directories.

# In production I add a denylist for ultra-sensitive roots.

roots = [Path(‘/‘)]

for rel in files:

p = Path(‘/‘) / rel

if dry_run:

print(f‘(dry run) Would remove: {p}‘)

continue

try:

if p.issymlink() or p.isfile():

p.unlink(missing_ok=True)

elif p.is_dir():

# Delete empty dirs later; payload lists many dirs.

pass

except PermissionError as e:

raise PermissionError(f‘Permission denied removing {p}: {e}‘)

# Forget receipt last (inventory hygiene).

run([‘pkgutil‘, ‘–forget‘, pkgid], timeouts=60, dryrun=dryrun)

This is intentionally conservative and incomplete (because safe “receipt-based deletion” is tricky). In a production tool, I add:

  • A strict allowlist of top-level directories I’ll touch (for example: /Applications, /Library, /usr/local only if I’m targeting brew-like layouts)
  • A dry-run preview that prints the first N paths and the total count
  • A “refuse to touch” denylist for paths like /System, /bin, /sbin, /usr/bin, /usr/sbin
  • A post-check that the app is gone and services are unloaded

The key point: receipts are great for discovery and auditing. Deleting the payload is a last resort unless you’re confident you’re targeting a well-scoped vendor package.

3) Homebrew uninstall (formulas and casks)

If the software came from Homebrew, I uninstall via Homebrew. That keeps dependencies and metadata consistent.

  • Formula (CLI tools, libraries): brew uninstall
  • Cask (GUI apps): brew uninstall –cask

From Python:

def brewuninstall(name: str, *, cask: bool = False, dryrun: bool = False) -> CommandResult:

argv = [‘brew‘, ‘uninstall‘]

if cask:

argv += [‘–cask‘]

argv += [name]

return run(argv, timeouts=1800, dryrun=dry_run)

If I’m automating on macOS fleets, I also decide upfront whether I’ll run brew cleanup afterward. Cleanup can remove old versions and save disk, but it can also be time-consuming and sometimes surprises people by removing caches they relied on.

4) LaunchAgents, login items, and privileged helpers

This is where macOS uninstall automation becomes “real.” A lot of apps drop extra components here:

  • Per-user agents: ~/Library/LaunchAgents
  • System-wide agents: /Library/LaunchAgents
  • Daemons: /Library/LaunchDaemons
  • Privileged helper tools: /Library/PrivilegedHelperTools
  • Support files: /Library/Application Support
  • Preferences: ~/Library/Preferences, /Library/Preferences

My approach:

  • If a vendor provides an uninstaller, I run it.
  • If not, I unload relevant launchd jobs (when I can identify them) before deleting anything.
  • I never glob-delete “everything that matches a vague string” on shared paths.

A safe middle-ground automation pattern is: discover, then ask for confirmation.

For example, I might search for launchd plists that contain the app’s bundle identifier (not the marketing name), print candidates, and require explicit selection before removal. This keeps me from wiping unrelated agents that happen to contain a similar word.

Linux: uninstalling with package managers (and avoiding manual deletion)

On Linux, the safest uninstalls are the boring ones: use the package manager that installed the package.

The first question I ask is not “what file do I delete?” It’s “how was this installed?” Common cases:

  • apt (Debian/Ubuntu)
  • dnf/yum (Fedora/RHEL/CentOS-like)
  • pacman (Arch)
  • zypper (openSUSE)
  • snap
  • flatpak
  • language-specific installers (pip, npm, cargo) in user directories
  • custom install scripts (curl | bash), which often means you need the vendor’s uninstall instructions

Identify the package manager quickly

In automation, I detect by executable presence, and I don’t assume the distro.

import shutil

def which(cmd: str) -> str | None:

return shutil.which(cmd)

def linuxpkgbackend() -> str | None:

for candidate in [‘apt-get‘, ‘dnf‘, ‘yum‘, ‘pacman‘, ‘zypper‘]:

if which(candidate):

return candidate

return None

This isn’t perfect, but it’s practical. On some systems multiple tools exist, and your policy should decide precedence.

apt (Debian/Ubuntu)

For apt-based systems, I usually pick between “remove” and “purge”:

  • remove: uninstall package binaries, keep config
  • purge: uninstall and remove system-wide config (still won’t touch per-user config)

From Python:

def aptremove(pkg: str, *, purge: bool = False, dryrun: bool = False) -> CommandResult:

base = [‘sudo‘, ‘apt-get‘, ‘-y‘]

if purge:

base += [‘purge‘]

else:

base += [‘remove‘]

base += [pkg]

return run(base, timeouts=1800, dryrun=dry_run)

A production nuance: apt and dpkg can be locked by other processes (unattended upgrades, packagekit). When that happens, “retry later” is often the correct action. I don’t try to kill random system processes unless I’m in an environment where I fully own the image.

dnf/yum (Fedora/RHEL-like)

For dnf:

def dnfremove(pkg: str, *, dryrun: bool = False) -> CommandResult:

return run([‘sudo‘, ‘dnf‘, ‘-y‘, ‘remove‘, pkg], timeouts=1800, dryrun=dry_run)

For yum (older systems):

def yumremove(pkg: str, *, dryrun: bool = False) -> CommandResult:

return run([‘sudo‘, ‘yum‘, ‘-y‘, ‘remove‘, pkg], timeouts=1800, dryrun=dry_run)

pacman (Arch)

Pacman distinguishes between removing just the package and also pruning dependencies.

  • Remove: pacman -R
  • Remove + deps: pacman -Rs

I’m careful with dependency pruning in automation because it can remove things I didn’t intend on multi-purpose developer machines.

def pacmanremove(pkg: str, *, removedeps: bool = False, dry_run: bool = False) -> CommandResult:

flag = ‘-Rs‘ if remove_deps else ‘-R‘

return run([‘sudo‘, ‘pacman‘, ‘–noconfirm‘, flag, pkg], timeouts=1800, dryrun=dry_run)

snap

If the app is a snap:

  • List: snap list
  • Remove: snap remove

def snapremove(name: str, *, dryrun: bool = False) -> CommandResult:

return run([‘sudo‘, ‘snap‘, ‘remove‘, name], timeouts=1800, dryrun=dry_run)

flatpak

Flatpak is often per-user, but can also be system-wide. I treat scope as a parameter.

  • List: flatpak list
  • Uninstall: flatpak uninstall

def flatpakuninstall(appid: str, *, user: bool = True, dry_run: bool = False) -> CommandResult:

argv = [‘flatpak‘, ‘uninstall‘, ‘-y‘]

argv += [‘–user‘] if user else [‘–system‘]

argv += [app_id]

return run(argv, timeouts=1800, dryrun=dry_run)

pip-installed “apps” (and why I avoid uninstalling them globally)

People sometimes ask: “Can I uninstall software using pip from Python?” Technically yes, but I treat it as a different category:

  • pip is a package manager for Python packages, not the OS
  • pip uninstall can break other Python tooling on the machine

If you installed a CLI tool with pipx, uninstall with pipx. If it lives in a virtualenv, delete the virtualenv. If it’s in a shared system Python, proceed only if you’re very sure what you’re doing.

A safer pattern I use for developer machines is: keep tools in isolated environments (pipx, uv tool install, conda envs), then “uninstall” is removing the environment.

Building a small cross-platform uninstaller you can actually run

By this point you’ve seen platform-specific tactics. The missing piece is structure: I want one script that does the safe parts consistently.

Here’s the shape I aim for:

  • A single CLI entry point
  • Subcommands: list, find, uninstall
  • Dry-run everywhere
  • Strict matching by default
  • Per-platform backends with minimal shell usage
  • A JSONL log file so I can audit the fleet later

A minimal JSONL logger

I like JSON Lines for automation logs: one record per line, append-only, easy to grep, easy to ship.

from future import annotations

import json

from dataclasses import asdict

from datetime import datetime, timezone

from pathlib import Path

def utcnowiso() -> str:

return datetime.now(timezone.utc).isoformat()

def log_event(path: Path, event: dict) -> None:

record = {‘ts‘: utcnowiso(), event}

path.parent.mkdir(parents=True, exist_ok=True)

with path.open(‘a‘, encoding=‘utf-8‘) as f:

f.write(json.dumps(record, ensure_ascii=False) + ‘\n‘)

Then I wrap my run() helper so every command is logged in a consistent way:

def runlogged(argv: list[str], *, logpath: Path, timeouts: int = 600, dryrun: bool = False) -> CommandResult:

logevent(logpath, {‘type‘: ‘exec‘, ‘argv‘: argv, ‘timeouts‘: timeouts, ‘dryrun‘: dryrun})

result = run(argv, timeouts=timeouts, dryrun=dryrun)

logevent(logpath, {

‘type‘: ‘result‘,

‘argv‘: result.argv,

‘returncode‘: result.returncode,

‘stdout‘: result.stdout[-4000:],

‘stderr‘: result.stderr[-4000:],

})

return result

I cap stdout/stderr to keep log sizes reasonable. If I need full logs, I add an option to write stdout/stderr to separate files per run.

A CLI skeleton (argparse)

I keep flags consistent:

  • –dry-run
  • –timeout
  • –log
  • –yes (skip interactive confirmation)

from future import annotations

import argparse

import sys

from pathlib import Path

def build_parser() -> argparse.ArgumentParser:

p = argparse.ArgumentParser(prog=‘pyuninstall‘)

p.addargument(‘–dry-run‘, action=‘storetrue‘)

p.add_argument(‘–timeout‘, type=int, default=1800)

p.add_argument(‘–log‘, type=Path, default=Path(‘./uninstall.log.jsonl‘))

p.addargument(‘–yes‘, action=‘storetrue‘, help=‘Do not prompt‘)

sub = p.add_subparsers(dest=‘cmd‘, required=True)

sub.add_parser(‘list‘)

f = sub.add_parser(‘find‘)

f.add_argument(‘query‘)

u = sub.add_parser(‘uninstall‘)

u.addargument(‘nameor_id‘)

u.addargument(‘–exact‘, action=‘storetrue‘, default=True)

return p

def confirm(prompt: str, *, assume_yes: bool) -> bool:

if assume_yes:

return True

ans = input(prompt + ‘ [y/N]: ‘).strip().lower()

return ans == ‘y‘ or ans == ‘yes‘

def main(argv: list[str]) -> int:

args = buildparser().parseargs(argv)

# Dispatch by OS

if args.cmd == ‘list‘:

return cmd_list(args)

if args.cmd == ‘find‘:

return cmd_find(args)

if args.cmd == ‘uninstall‘:

if not confirm(f‘Proceed to uninstall: {args.nameorid}?‘, assume_yes=args.yes):

print(‘Canceled.‘)

return 0

return cmd_uninstall(args)

return 2

if name == ‘main‘:

raise SystemExit(main(sys.argv[1:]))

I’m not trying to be fancy here. The goal is to make accidental uninstalls harder, and intentional uninstalls repeatable.

Matching strategy: exact first, fuzzy second

Exact matching is safe but annoying. Fuzzy matching is convenient but dangerous.

So I do this:

  • By default, require exact match.
  • Provide a find subcommand that does fuzzy search and prints candidates.
  • Make uninstall accept only exact matches unless –force-fuzzy is given (and even then I usually require a second confirmation).

This is the single most important “practical value” choice in uninstall automation. Human brains see what they want to see in a fuzzy match list.

Verification: how I decide the uninstall “worked”

Exit code alone is not enough.

For each OS, I like at least one verification check:

Windows verification

  • The registry entry is gone (or DisplayName no longer present)
  • The primary executable path no longer exists (if known)
  • Services associated with the product are removed or stopped (if applicable)

If I have only a display name, the best quick check is: re-list installed apps and confirm the exact display name is absent. That’s not perfect (some uninstallers leave stale entries for a while), but it catches a lot.

macOS verification

  • The .app bundle is gone (if that’s the install type)
  • Related launchd jobs are not loaded (if I unloaded them)
  • Receipts are gone (if I forgot them intentionally)

Linux verification

  • The package manager no longer lists the package
  • The command is no longer on PATH (when that’s expected)

In fleet contexts, I also measure “residual disk usage” in broad strokes (ranges, not perfection). It’s common to see a successful uninstall still leave behind tens to a few hundred megabytes of caches in user profiles, which is fine as long as you made that decision consciously.

Performance considerations (what matters and what doesn’t)

Uninstall automation is rarely CPU-bound. The slow parts are:

  • Installer technology itself (custom EXE uninstallers can be slow)
  • Network lookups (some uninstallers phone home)
  • Package manager locks and dependency calculations
  • Waiting for UI prompts when you forgot silent flags

What I optimize:

  • Avoiding repeated inventory scans (cache inventory for the duration of one run)
  • Using timeouts so hung uninstallers don’t block the entire job
  • Running independent uninstalls sequentially unless I’m certain parallelism won’t collide

Parallel uninstalls can cause trouble:

  • Multiple MSI operations at the same time often collide
  • Package managers usually enforce a global lock
  • Two uninstallers can fight over shared runtimes

If I need speed on a fleet, I parallelize across machines, not within one machine.

Common pitfalls (the stuff that bites even careful scripts)

Here are the mistakes I’ve personally seen (and made):

  • Assuming “delete the folder” equals uninstall (it doesn’t)
  • Forgetting per-user installs (HKCU on Windows; ~/Library on macOS; –user flatpaks)
  • Running interactive uninstallers on headless sessions (they hang)
  • Treating all non-zero exit codes as failure (reboot-required is common)
  • Not sanitizing or controlling input to shell commands
  • Using fuzzy matching for uninstall without a second confirmation
  • Logging too little (you can’t explain what happened later)
  • Logging too much sensitive data (tokens, usernames, internal paths)

If you only fix one thing: make your script easy to run in dry-run mode and easy to audit afterward.

Alternative approaches (when I don’t use Python)

Python is great for orchestration, but sometimes it’s not the best tool to “own” the uninstall logic.

  • Windows-first environments: PowerShell can be the primary layer, with Python coordinating batches
  • macOS fleets: MDM (device management) policies can remove managed apps more reliably than ad-hoc scripts
  • Linux fleets: configuration management (Ansible, Salt, etc.) often models package state better than a custom script

Even then, I still like Python as the glue: it can call native tools, collect logs, and provide a single cross-platform interface.

A production checklist I actually use

Before I let an uninstall script run across more than a couple machines, I want:

  • Dry run output reviewed on a representative sample
  • Exact identifiers (package IDs, product codes, bundle IDs) documented
  • A rollback plan (reinstall instructions or imaging)
  • A logging destination (local JSONL + central collection if needed)
  • A timeout policy and a “what to do on timeout” policy
  • A clear scope decision about configs and user data
  • A plan for reboot-required results

Uninstall automation is one of those tasks where a small amount of extra discipline saves you from a lot of pain. I treat it like any other production change: scoped, logged, and reversible.

Quick recap

If you remember nothing else, remember this:

  • Uninstall via the same mechanism that installed it.
  • Default to dry-run and exact matching.
  • Prefer quiet uninstall entry points when available.
  • Verify after uninstall, don’t assume.

Once you’ve got those habits, Python becomes a reliable way to remove software across Windows, macOS, and Linux—without turning your cleanup job into a click-fest or a guessing game.

Scroll to Top