This guide assumes Powershell 7+ (some sections work on 5.1, but the modern profile path layout is 7's). Most of what's here also benefits from PSReadLine 2.2+.
Half of "Powershell productivity" advice is colored prompts and a thousand aliases nobody remembers. The other half is actually useful. This post is the second half.
Where Your Profile Lives
Powershell looks at four locations, in order:
$PROFILE | Format-List * -Force
The five fields:
| Variable | Scope |
|---|---|
$PROFILE.AllUsersAllHosts |
Every user, every host |
$PROFILE.AllUsersCurrentHost |
Every user, this host |
$PROFILE.CurrentUserAllHosts |
You, every host |
$PROFILE.CurrentUserCurrentHost |
You, this host (default $PROFILE) |
For 99% of cases, edit $PROFILE.CurrentUserAllHosts so VSCode, Windows Terminal, and pwsh.exe all share the same setup:
code $PROFILE.CurrentUserAllHosts
If the file doesn't exist, create it:
New-Item -ItemType File -Path $PROFILE.CurrentUserAllHosts -Force
Don't put everything in
CurrentUserCurrentHost. It only loads in one host. The "VSCode runs an old prompt and pwsh.exe runs a different one" bug is almost always this.
Default Parameter Values The Biggest Single Win
Covered in the splatting post, worth repeating because it belongs in your profile:
$PSDefaultParameterValues = @{
'Out-File:Encoding' = 'utf8'
'Set-Content:Encoding' = 'utf8'
'Export-Csv:NoTypeInformation' = $true
'Export-Csv:UseQuotes' = 'AsNeeded'
'Get-ChildItem:Force' = $true
'Invoke-RestMethod:TimeoutSec' = 30
'Invoke-WebRequest:TimeoutSec' = 30
'*:ErrorAction' = 'Stop'
}
The '*:ErrorAction' = 'Stop' line is contentious it changes behavior of every cmdlet. Whether you want it depends on whether you write more scripts (yes) or more interactive one-liners (maybe not). Try both for a week.
Useful Module Imports
Three modules earn their slot in every profile:
Import-Module PSReadLine
Import-Module Terminal-Icons -ErrorAction SilentlyContinue
Import-Module CompletionPredictor -ErrorAction SilentlyContinue
PSReadLineships with Powershell 7 but the version in your profile is fixed in stone for the session, so an explicit import lets you pin a newer one withInstall-Module PSReadLine -AllowPrerelease.Terminal-Iconsadds file-type icons toGet-ChildItemoutput. Pure cosmetics, butls-style output becomes scannable.CompletionPredictorturns Tab completion into a predictive list (matching cmdlet names, paths, and history).
PSReadLine The Real Productivity Layer
The defaults are conservative. Three lines change that:
Set-PSReadLineOption -PredictionSource HistoryAndPlugin
Set-PSReadLineOption -PredictionViewStyle ListView
Set-PSReadLineOption -EditMode Windows # or Emacs / Vi
HistoryAndPlugin source means predictions come from your shell history and any installed IPredictor (like CompletionPredictor above). ListView shows them as a dropdown instead of inline ghost text.
A few keybindings I use every day:
Set-PSReadLineKeyHandler -Key Ctrl+d -Function DeleteCharOrExit
Set-PSReadLineKeyHandler -Key Ctrl+w -Function BackwardDeleteWord
Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete
Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward
Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward
HistorySearchBackward is the one most people don't know about: type a few characters, hit Up, and you cycle through history entries that start with what you typed.
Per-Session Transcripts
$logDir = "$HOME/.pslogs"
$null = New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue
$logName = "{0:yyyy-MM-dd_HHmmss}_$PID.log" -f (Get-Date)
$null = Start-Transcript -Path (Join-Path $logDir $logName) -IncludeInvocationHeader
Every session writes a transcript. Future-you, looking at "what did I run that broke this last week", will be very grateful.
This is operator-controlled transcription useful for personal recovery, not for security auditing. For org-wide audit logging, use the GPO-driven setup in the logging post.
A Sane Prompt With or Without oh-my-posh
The simplest custom prompt that's actually useful:
function prompt
{
$time = (Get-Date).ToString('HH:mm:ss')
$branch = ''
if (Test-Path .git)
{
$branch = (git rev-parse --abbrev-ref HEAD 2>$null)
if ($branch) { $branch = " ($branch)" }
}
$exit = if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { " ![$LASTEXITCODE]" } else { '' }
"[$time] $($PWD.Path)$branch$exit`nPS> "
}
Time, path, git branch, last exit code. No external dependencies. For anything fancier, install oh-my-posh:
winget install JanDeDobbeleer.OhMyPosh --source winget # Windows
brew install jandedobbeleer/oh-my-posh/oh-my-posh # macOS
curl -s https://ohmyposh.dev/install.sh | bash -s # Linux
oh-my-posh init pwsh --config "$HOME/.poshthemes/jandedobbeleer.omp.json" | Invoke-Expression
oh-my-posh is great. It's also slow if you load a heavy theme every keystroke pays the prompt-render cost. Pick a small theme, or write your own.
Alias Hygiene
The temptation is to alias everything. The reality is that aliases break in scripts (because they may not exist in the runtime where the script lands) and confuse anyone reading your code. Two rules:
- Aliases are personal, not portable. Never use them inside scripts you'll commit.
- Limit yourself to genuinely high-frequency shortcuts.
A small set that survives that filter:
Set-Alias -Name g -Value git
Set-Alias -Name k -Value kubectl
Set-Alias -Name tf -Value terraform
Set-Alias -Name d -Value docker
function which { Get-Command @args | Select-Object -ExpandProperty Source }
function .. { Set-Location .. }
function ... { Set-Location ../.. }
which is the one I'd genuinely fight to keep.
Per-Project Auto-Loading
Drop a .envrc.ps1 (or whatever convention you like) in each project root and have the prompt source it on directory change:
$global:__lastDir = $PWD.Path
function prompt
{
if ($PWD.Path -ne $global:__lastDir)
{
$global:__lastDir = $PWD.Path
$envFile = Join-Path $PWD '.envrc.ps1'
if (Test-Path $envFile) { . $envFile }
}
# ... rest of prompt ...
}
Now entering a project directory loads its specific environment (subscriptions, KeyVault references, module paths) without you remembering to run anything.
Loading Speed
Profile load time is the cost you pay every time you open a new shell. Measure it:
Measure-Command { . $PROFILE.CurrentUserAllHosts }
Anything over ~500 ms is felt. The usual culprits:
- Importing modules at the top.
Import-Module SomeBigModulecan take seconds. Defer with a function wrapper that imports on first call. - Heavy prompt themes. Switch to a leaner one.
- Network calls. Don't do
Connect-AzAccountin your profile log in on demand.
A profile that loads in under 250 ms is doable even with PSReadLine + oh-my-posh + a custom prompt. Anything over a second is worth a refactor.
A Skeleton Profile
# ---- defaults ----
$PSDefaultParameterValues = @{
'Out-File:Encoding' = 'utf8'
'Export-Csv:NoTypeInformation' = $true
'Get-ChildItem:Force' = $true
'Invoke-RestMethod:TimeoutSec' = 30
}
# ---- modules ----
Import-Module PSReadLine
Import-Module Terminal-Icons -ErrorAction SilentlyContinue
# ---- PSReadLine ----
Set-PSReadLineOption -PredictionSource HistoryAndPlugin
Set-PSReadLineOption -PredictionViewStyle ListView
Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete
Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward
Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward
# ---- prompt ----
oh-my-posh init pwsh --config "$HOME/.poshthemes/jandedobbeleer.omp.json" | Invoke-Expression
# ---- aliases ----
Set-Alias -Name g -Value git
Set-Alias -Name k -Value kubectl
function which { Get-Command @args | Select-Object -ExpandProperty Source }
# ---- transcript ----
$logDir = "$HOME/.pslogs"
$null = New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue
$null = Start-Transcript -Path (Join-Path $logDir ("{0:yyyy-MM-dd_HHmmss}_$PID.log" -f (Get-Date))) -IncludeInvocationHeader
About 25 lines, loads in ~300 ms on a reasonable machine, hits 90% of the value of any profile I've ever seen.
What to Do Next
A profile isn't where you customize everything; it's where you set the boring defaults that would otherwise repeat in every script you write. Default parameter values, PSReadLine's history-search bindings, a transcript so you can recover what you ran, a small prompt that shows the things you actually look at. Skip the alias-hoarding phase entirely.
Three concrete moves this week:
- Time your current profile.
Measure-Command { . $PROFILE }. Anything over a second is a tax you're paying every time you open a shell. Trim until it's under 500 ms; profile-loading lazily for anything that isn't critical to startup. - Add the transcript line (
Start-Transcript -Path "$env:USERPROFILE\PSTranscripts\$(Get-Date -Format 'yyyy-MM-dd-HHmmss').log" -IncludeInvocationHeader). The next time you wonder "what did I just run that fixed it", the answer is acataway. - Commit your profile to a dotfiles repo. Even a private one. The first time you set up a new machine,
Invoke-RestMethod $url | Out-File $PROFILEbrings your shell back in seconds. The 30-second-to-feel-at-home goal becomes literal.
Pairs naturally with the splatting post (most of the items in the profile worth saving are $PSDefaultParameterValues entries) and the logging post (Start-Transcript is the personal-shell version of the system-wide transcription you turn on at the GPO level).


