Skip to content

Instantly share code, notes, and snippets.

@fdcastel
Last active March 29, 2026 22:42
Show Gist options
  • Select an option

  • Save fdcastel/38ec25c8fc862e691c6d70d95c22fe4b to your computer and use it in GitHub Desktop.

Select an option

Save fdcastel/38ec25c8fc862e691c6d70d95c22fe4b to your computer and use it in GitHub Desktop.
Windows Powershell functions for system path
function Get-SystemPath {
$keyName = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'
$key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyName, 'ReadOnly')
try {
return $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') -split [IO.Path]::PathSeparator
} finally {
if ($null -ne $key) {
$key.Dispose()
}
}
}
function Add-SystemPath([Parameter(Mandatory=$true)][string[]]$Folder) {
$keyName = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'
$key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyName, $true)
try {
# Get current PATH
$currentPathFolders = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') -split [IO.Path]::PathSeparator
# Add new folders to the current PATH
$newPathFolders = $currentPathFolders + @($Folder)
# Normalize folders to remove trailing slashes and duplicates
$result = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase)
$newPathFolders |
ForEach-Object {
$normalized = $_.TrimEnd([IO.Path]::DirectorySeparatorChar).Trim()
if ($normalized -ne '') {
$result.Add($normalized)
}
} > $null
# Build new PATH and save it
$newPath = $result -join [IO.Path]::PathSeparator
$key.SetValue('Path', $newPath, 'ExpandString')
return $result
} finally {
if ($null -ne $key) {
$key.Dispose()
}
}
}
function Remove-SystemPath([Parameter(Mandatory=$true)][string[]]$Folder) {
$keyName = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'
$key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyName, $true)
try {
# Get current PATH
$currentPathFolders = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') -split [IO.Path]::PathSeparator
# Normalize folders to remove
$foldersToRemove = $Folder | ForEach-Object { $_.TrimEnd([IO.Path]::DirectorySeparatorChar) }
# Filter out the folders to remove (case-insensitive)
$result = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase)
$currentPathFolders |
Where-Object {
$normalizedFolder = $_.TrimEnd([IO.Path]::DirectorySeparatorChar)
$foldersToRemove -notcontains $normalizedFolder
} |
ForEach-Object { $result.Add($_) } > $null
# Build new PATH and save it
$newPath = $result -join [IO.Path]::PathSeparator
$key.SetValue('Path', $newPath, 'ExpandString')
return $result
} finally {
if ($null -ne $key) {
$key.Dispose()
}
}
}
@fdcastel
Copy link
Copy Markdown
Author

Why go through all this just to update the system PATH?

1. Must go through the registry directly
Using $env:PATH or [Environment]::SetEnvironmentVariable() reads/writes the already-expanded value — %SystemRoot%\System32 becomes System32. Save that back and you've permanently destroyed the portable, machine-agnostic references. So the script opens the registry key manually.

2. DoNotExpandEnvironmentNames when reading
This flag is how you get the raw, unexpanded string (%SystemRoot%\System32) instead of the resolved path. Without it, round-tripping PATH corrupts it.

3. ExpandString (REG_EXPAND_SZ) when writing
PATH must be stored as REG_EXPAND_SZ in the registry, not as a plain REG_SZ string. If you write it as the wrong type, Windows stops expanding %...% references for all processes.

4. Case-insensitive deduplication
Windows paths are case-insensitive, so C:\Foo and c:\foo are the same entry. A plain array comparison would miss this, hence the HashSet<string> with InvariantCultureIgnoreCase. Trailing-slash normalization (TrimEnd) handles C:\Foo\ vs C:\Foo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment