Bash history expansion, and why it stops working in a script

History expansion like !! and !$ is an interactive shell feature. In a script it is off, so !$ is passed through as a literal filename. Here is the failure, the flag that explains it, and what to write instead.

The short answer, before anything else: history expansion is a feature of interactive shells. A script runs non-interactively, where the feature is off by default, so !$ is never expanded. It is passed along as a literal two-character filename. Run echo $- and look for an H. If there is no H, expansion is off and every !! in that shell is just text.

Everything below was captured from a real shell session, not retyped from memory. When a line shows bash repeating your command back, that is bash printing the expansion before it runs it.

The prompt tricks worth knowing

!! repeats the previous command. Bash prints what it expanded to first, so you always see what is about to run.

root@ops-01:/srv/app# cat config/app.conf
listen 8080
workers 4
root@ops-01:/srv/app# !!
cat config/app.conf
listen 8080
workers 4

!$ is the last argument of the previous command. This is the one that earns its keep: you list a file, then want to do something else to the same path, and you never retype it.

root@ops-01:/srv/app# ls -l logs/app.log
-rw-r--r-- 1 root root 14 Aug  4 10:12 logs/app.log
root@ops-01:/srv/app# wc -l !$
wc -l logs/app.log
2 logs/app.log

!^ is the first argument instead, which is what you want after a cp or an mv, where the interesting path is the source rather than the destination.

root@ops-01:/srv/app# cp config/app.conf /tmp/backup.conf
root@ops-01:/srv/app# head -1 !^
head -1 config/app.conf
listen 8080

^old^new^ reruns the previous command with the first match replaced. It is the fastest fix for a one-character typo, and it shows you the corrected command before running it.

root@ops-01:/srv/app# grep listen config/app.conf
listen 8080
root@ops-01:/srv/app# ^listen^workers^
grep workers config/app.conf
workers 4

!word reruns the most recent command that started with that word. Useful when the command you want is twenty lines back and you remember only how it began.

root@ops-01:/srv/app# !grep
grep workers config/app.conf
workers 4

history shows the numbered list all of this is drawing from.

root@ops-01:/srv/app# history 5
    7  head -1 config/app.conf
    8  grep listen config/app.conf
    9  grep workers config/app.conf
   10  grep workers config/app.conf
   11  history 5

Notice line 10. When ^listen^workers^ ran, bash recorded the expanded command, not what was typed. History stores what actually executed.

Where it breaks

Here is a small script. It uses !$ exactly the way it worked at the prompt a moment ago.

root@ops-01:/srv/app# cat checksize.sh
#!/bin/bash
ls -l logs/app.log
wc -l !$

Running it does not do what the prompt did.

root@ops-01:/srv/app# bash checksize.sh
-rw-r--r-- 1 root root 14 Aug  4 10:12 logs/app.log
wc: '!
#39;: No such file or directory

Read that error carefully, because it is the reason this wastes people's time. Bash did not complain about the syntax. It did not warn that history expansion was unavailable. The first line worked fine. Bash simply passed !$ through untouched, and wc went looking for a file named !$, which does not exist.

The failure is silent about its actual cause, and it appears halfway through a script that was working a second ago at the prompt.

Why

Bash keeps its current option flags in $-. Interactively, there is an H in there, and H means history expansion is enabled.

root@ops-01:/srv/app# echo $-
himBHs

Run the same thing non-interactively and the H is gone.

root@ops-01:/srv/app# bash -c 'echo $-'
hBc

That is the whole story. i for interactive and H for histexpand are both present in the first, and both absent in the second. A script gets the second shell.

You can watch the feature switch off in your own session. set +H disables history expansion, and from that moment !$ is ordinary text.

root@ops-01:/srv/app# set +H
root@ops-01:/srv/app# echo !$
!$

It takes two flags, not one

set -H on its own does not fix the script. Expansion still fails with the same error, because H is only half of what the feature needs. A shell also has to be recording a history list for !$ to point into, and that is a separate option which is likewise off non-interactively.

root@ops-01:/srv/app# bash -c 'set -H; set -o | grep histexpand'
histexpand     	on
root@ops-01:/srv/app# bash -c 'set -H; set -o | grep -w history'
history        	off

Turn on both and history expansion genuinely works inside a script.

root@ops-01:/srv/app# cat withhistory.sh
#!/bin/bash
set -H
set -o history
ls -l logs/app.log
wc -l !$
root@ops-01:/srv/app# bash withhistory.sh
-rw-r--r-- 1 root root 14 Aug  4 10:12 logs/app.log
wc -l logs/app.log
2 logs/app.log

There is the expansion echo, in a script, doing exactly what it does at the prompt.

What to do in a script anyway

Do not use it. The point above is that you *can*, not that you should.

!$ at a prompt refers to something you typed a second ago and can still see on screen. In a script it refers to whatever line ran before it, which changes the moment somebody inserts a line above it. Nothing about the reference is visible at the point of use, and no reader expects history expansion in a file.

Name the thing once and use the name:

log=logs/app.log
ls -l "$log"
wc -l "$log"

That is about as short, it cannot be broken by reordering, and it works in every shell whatever the flags say.

Quick reference

SequenceExpands toIn a script by default
!!the entire previous commandNo
!$last argument of the previous commandNo
!^first argument of the previous commandNo
!ncommand number n from historyNo
!wordmost recent command starting with wordNo
^old^new^previous command, first match replacedNo
echo $-current shell flags, H means expansion is onYes
set +H / set -Hturn the histexpand option off / onYes
set -o historyturn on the history list itself, needed tooYes

The six expansions read No because both options are off in a script unless you turn them on, which the section above shows how to do and argues against.

Verified on GNU bash 5.2.15 on Debian 12. The behaviour is long standing, but the flag names are bash specific and do not carry over to dash, zsh, or fish.

Common questions

Why does !$ work when I type it but not in my script? History expansion is enabled only in interactive shells. Scripts run non-interactively, so !$ is passed through as a literal string. Check with echo $-: no H means no expansion.

Why did my script say No such file or directory instead of a syntax error? Because there is no syntax error. Bash treated !$ as an ordinary word and handed it to the command as an argument, so the command reported that it could not find a file by that name.

Can I enable history expansion in a script with set -H? Not with set -H alone. That turns on the histexpand option but leaves the history list itself off, so there is still nothing for !$ to point into and you get the same error. Adding set -o history as well does make it work. It is still a poor idea in a file, because the reference is invisible at the point of use and breaks as soon as a line is inserted above it. Use a variable.

Why does history show a different command than the one I typed? Because bash records what ran, not what you typed. After ^old^new^, the history entry is the substituted command.

More from the Field Manual, or work through the training tracks.