Skip to content

History expansion

Level: 101 · you have typed sudo !! and want to know what else ! can do

One line: In an interactive bash or zsh, !!, !$, !-3:2 and ^old^new are rewritten from the history before the line runs, and the shell prints the rewritten line first. A ! inside double quotes is rewritten too, which is where event not found comes from, and bash 3.2 and bash 5.2 disagree about "wow!". A script has no history expansion, and fish has none anywhere: to fish, !! is a command it cannot find.

Measured

The session is an interactive bash with its stderr joined to its stdout, because stderr is where bash shows its work. Each typed line is echoed by the line editor, bash then prints the expanded line, and the command's own output comes last. PS1 and PS2 are empty so that no prompt gets in the way, and grep -v drops the job-control warning at the start and the exit at the end.

Verified output of histbang_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ PS1= PS2= HISTFILE=$PWD/hist bash --norc -i <<'EOF' 2>&1 | grep -v -e 'job control' -e 'process group' -e '^exit$'
echo alpha beta gamma
echo !$
!!
echo !-3:2 !-3:$
^beta^delta
!echo:p
history -p '!!' '!-2:1'
EOF
echo alpha beta gamma
alpha beta gamma
echo !$
echo gamma
gamma
!!
echo gamma
gamma
echo !-3:2 !-3:$
echo beta gamma
beta gamma
^beta^delta
echo delta gamma
delta gamma
!echo:p
echo delta gamma
history -p '!!' '!-2:1'
echo delta gamma
delta

$ bash -c 'echo "in a script: wow!ok !!"'
in a script: wow!ok !!

$ bash -c 'set -o history; history -s "echo last words"; history -p "!!" "!$"'
echo last words
words
  • !$ is the last word of the previous command. bash printed echo gamma before running it.
  • !! is the whole previous command, which is now echo gamma. The history holds the expanded line, not the !$ that was typed.
  • !-3:2 and !-3:$ count back three commands and take word 2 and the last word. Words are counted from 0, the command name, so word 2 of echo alpha beta gamma is beta.
  • ^beta^delta repeats the previous command with its first beta replaced.
  • :p prints and does not run. !echo:p found the latest command starting with echo and printed it, and no delta gamma output follows.
  • history -p expands its arguments and prints them, without running anything.
  • A script has none of this. bash -c printed wow!ok !! untouched. History expansion is on only in an interactive shell. A script that wants it can still call history -p, once it has a history to expand from: set -o history turns one on, and history -s adds a line to it.

On a Mac

The key above is shared: bash 3.2 and bash 5.2 expanded every word the same way. Where they part is a ! inside double quotes, and this key is split on purpose:

Verified output of histbang_quotes_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.

$ PS1= PS2= HISTFILE=$PWD/hist bash --norc -i <<'EOF' 2>&1 | grep -v -e 'job control' -e 'process group' -e '^exit$'
echo "wow!ok"
echo "wow!"
echo 'wow!ok'
echo wow\!ok
set +H
echo "wow!ok"
EOF
echo "wow!ok"
bash: !ok: event not found
echo "wow!"
wow!
echo 'wow!ok'
wow!ok
echo wow\!ok
wow!ok
set +H
echo "wow!ok"
wow!ok

Verified output of histbang_quotes_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.

$ PS1= PS2= HISTFILE=$PWD/hist bash --norc -i <<'EOF' 2>&1 | grep -v -e 'job control' -e 'process group' -e '^exit$'
echo "wow!ok"
echo "wow!"
echo 'wow!ok'
echo wow\!ok
set +H
echo "wow!ok"
EOF
echo "wow!ok"
bash: !ok": event not found
echo "wow!"
bash: !": event not found
echo 'wow!ok'
wow!ok
echo wow\!ok
wow!ok
set +H
echo "wow!ok"
wow!ok
  • Both expand ! inside double quotes. echo "wow!ok" failed on both, because !ok asks for the latest command starting with ok and there is none. A line whose expansion fails is not run at all: no wow!ok follows the error.
  • bash 3.2 reads the closing quote as part of the event: !ok": event not found. It even fails on echo "wow!", with !": event not found. Bash 5.2 ends the word at the closing quote, reports !ok, and leaves a ! just before " alone, so "wow!" prints wow!. A string ending in ! that works in a Linux terminal fails in /bin/bash on a Mac.
  • Single quotes and a backslash protect ! in both, and set +H turns expansion off for the rest of the session.

In zsh and fish

zsh has the same !-words, on by default in an interactive shell (the BANG_HIST option). The first typed line, setopt verbose, makes zsh echo each later line as it reads it, and zsh prints the expanded line after it:

Verified output of histbang_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ PS1= PS2= zsh -f -i +o promptsp +o promptcr <<'EOF' 2>&1
setopt verbose
echo alpha beta gamma
echo !$
!!
echo !-3:2 !-3:$
^beta^delta
!echo:p
echo "wow!ok"
echo 'wow!ok'
setopt nobanghist
echo "wow!ok"
EOF
echo alpha beta gamma
alpha beta gamma
echo !$
echo gamma
gamma
!!
echo gamma
gamma
echo !-3:2 !-3:$
echo beta gamma
beta gamma
^beta^delta
echo delta gamma
delta gamma
!echo:p
echo delta gamma
echo "wow!ok"
zsh: event not found: ok
echo 'wow!ok'
wow!ok
setopt nobanghist
echo "wow!ok"
wow!ok

$ zsh -f -c 'echo "in a script: wow!ok !!"'
in a script: wow!ok !!

Every designator expanded as it did in bash. The error has zsh's wording, zsh: event not found: ok, and single quotes protect ! as in bash. setopt nobanghist is zsh's set +H. A zsh script, like a bash one, leaves ! alone.

fish has no history expansion. A line fish cannot parse fails before it runs, so each of these runs in a child fish:

Verified output of histbang_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ !!
fish: Unknown command: !!
fish: 
!!
^^
(exit status 127)

$ echo !$
fish: Expected a variable name after this $.
echo !$
      ^
(exit status 127)

$ echo "wow!ok" !! !-2
wow!ok !! !-2

$ python3 $at_a_terminal 'function last_history_item; echo $history[1]; end' "abbr --add !! --position anywhere --function last_history_item" "echo ran >> log" "!!"

$ cat log
ran
ran

$ string replace -r "when: \d+" "when: <epoch>" < ~/.local/share/fish/fish_history
- cmd: function last_history_item; echo $history[1]; end
  when: <epoch>
- cmd: abbr --add !! --position anywhere --function last_history_item
  when: <epoch>
- cmd: echo ran >> log
  when: <epoch>
  • !! is a command name fish looked for and did not find, with exit status 127.
  • !$ is a syntax error, because $ has to be followed by a variable name.
  • As an argument, ! means nothing: echo "wow!ok" !! !-2 printed all three words as typed.
  • The replacement fish's documentation suggests is an abbreviation: abbr --add !! --position anywhere --function last_history_item, where last_history_item prints $history[1], the most recent command. An abbreviation expands only in the line editor, so this was typed at a terminal by fish_at_a_terminal.py. log holds ran twice: the typed !! became echo ran >> log and ran again. The history file holds that command once and no !! at all. The abbreviation expanded before the line was saved, and a command repeated straight after itself is saved once, as What gets remembered showed.

This fish key is shared too, so on a Mac, fish 4.3.2 behaves exactly this way.

bash zsh fish
!!, !$, ^old^new at the prompt expanded, and the expansion printed expanded, and the expansion printed not special; an abbreviation can stand in for !!
in a script not expanded not expanded not special
! inside double quotes expanded; 3.2 and 5.2 differ at the closing quote expanded not special
turn it off set +H setopt nobanghist nothing to turn off
show without running !…:p, history -p !…:p

If you are coming from another library

  • Rust. Fuzzy finding ↗ sets up fzf's Ctrl-R, the other way to reach an old command: search for it instead of counting back to it. In this library, fzf has its own chapter.

See also