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
  • PSReadLine ships 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 with Install-Module PSReadLine -AllowPrerelease.
  • Terminal-Icons adds file-type icons to Get-ChildItem output. Pure cosmetics, but ls-style output becomes scannable.
  • CompletionPredictor turns 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:

  1. Aliases are personal, not portable. Never use them inside scripts you'll commit.
  2. 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 SomeBigModule can 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-AzAccount in 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:

  1. 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.
  2. 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 a cat away.
  3. 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 $PROFILE brings 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).