SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Bash history expansion: !! and !$

!! repeats the last command and !$ gives you its last argument. Learn the six bash history expansions worth knowing, plus :p to print one before it runs.

What bash history expansion does

Bash history expansion rebuilds an earlier command line from your shell history before bash runs it. !! repeats the previous command. !$ inserts the last argument of the previous command. The substitution is textual and it happens first, before bash splits the line into words, so what comes back is the exact characters you typed the first time.

Six forms cover almost everything you will do on a server:

  • !! repeats the whole previous line, and sudo !! runs it again as root.
  • !$ is the last argument of the previous line.
  • !* is every argument of the previous line.
  • !n runs history entry number n, and !-n counts backwards from where you are now.
  • !string runs the most recent command that started with string.
  • ^old^new reruns the previous line with the first old replaced by new.

Everything below is typed at an interactive prompt on your own server. History expansion is switched off in scripts, which the last section explains.

One long path, typed once

Here is the case that pays for itself. You are preparing a release directory, and the path is long enough that typing it a second time invites a typo.

sudo mkdir -p /srv/www/app/releases/2026-08-07
sudo chown -R deploy:deploy !$
ls -ld !$
sudo -u deploy nano !$/config.env

Before running each line, bash prints the line the expansion produced:

sudo chown -R deploy:deploy /srv/www/app/releases/2026-08-07

That echo is your check. Read it before you read the command's output, because it is the only chance you get to see what bash decided !$ meant.

The chain holds because bash stores the expanded line in history, not the !$ you typed. So line 3 takes its last argument from the expanded line 2, and line 4 takes it from line 3. Line 4 also shows that text may follow a designator: !$/config.env becomes the path with /config.env on the end, because the word designator stops at the /.

One more from the same session. After editing the file you want to list the directory holding it:

ls -l !$:h

:h is the head modifier. It strips the last component off a path, the same job dirname does. :t keeps only the last component, :r removes the extension, and :e keeps only the extension.

Repeat the last command with !! and sudo !!

You forget sudo, and the service manager says so:

systemctl restart nginx
Failed to restart nginx.service: Interactive authentication required.
sudo !!

Bash replaces !! with the text of the previous line, so the shell runs sudo systemctl restart nginx.

!! is exactly the previous line, whatever that line was. This is where the classic accident happens. You believe the last command was the one that failed, but since then you ran a cd, or a history, or the failed command was two lines back. sudo !! then runs the wrong command with root privileges. Print it first when you are not certain:

sudo !!:p

:p prints the expanded line and does not run it. The printed line is added to your history, so if it reads correctly, a plain !! on the next line runs it.

Reuse the last argument with !$

!$ is the last word of the previous line. It is the form you will use most, because the last word is usually the thing you are working on: a path or a service name.

sudo systemctl status nginx
sudo systemctl reload !$

Two things surprise people here.

First, !$ is the last word, not the last argument you had in mind. If the previous line ended in a redirection, the last word is the redirect target:

sudo nginx -T > /tmp/nginx-dump.conf
less !$

That one works out well. But after journalctl -u nginx > /tmp/log.txt, !$ is /tmp/log.txt and not nginx. Read the echoed line.

Second, the expansion is textual, so a variable comes back unexpanded. After ls $HOME/backups, !$ gives you the characters $HOME/backups, which bash then expands again as a normal parameter. Hold on to that ordering: history expansion runs before parameter expansion and before command substitution with $( ), so it never sees values, only text.

The neighbouring forms are worth knowing. !^ is the first argument, !:2 is the second, and !:2-4 is a range. !!:$ is the long way of writing !$.

If you would rather see the text before you commit to it, press Alt-. (or Esc and then .). Readline inserts the last argument of the previous command straight into your prompt, where you can edit it. Press it again to step back to the last argument of the command before that. Nothing runs until you press Enter.

Pass every argument on with !*

!* is every word of the previous line except the first one.

stat /srv/www/app/shared/config.env /srv/www/app/shared/secrets.env
sudo chmod 600 !*

The phrase "except the first one" is doing real work. !* drops word 0 and nothing else, so options ride along with the paths. After ls -l file1 file2, !* is -l file1 file2, so sudo chmod 600 !* fails, because chmod receives -l as an argument. Number the words from zero and take a slice instead: !!:2* means word 2 through to the end.

The same trap comes with sudo. After sudo chown deploy:deploy /srv/www/app, word 0 is sudo and word 1 is chown, so !* hands the next command chown deploy:deploy /srv/www/app. That is rarely what you wanted.

Pick a command by position with !n and !-n

history prints the list with a number in front of every entry.

history 5
  512  sudo nginx -t
  513  sudo systemctl reload nginx
  514  ss -tulpn
  515  sudo tail -f /var/log/nginx/error.log
  516  history 5

!513 runs entry 513 again. !-2 runs the entry two lines back, counted from the line you are typing now, so !-1 and !! mean the same thing.

Check the numbers immediately before you use them. !-2 points somewhere new every time you run anything, including an ls you fired without thinking. Absolute numbers are stable inside one session, but they are not the same numbers in a second session on the same box, and they are not the same after your next login reads the history file back in. A number you memorised yesterday points at a different command today.

Rerun a command by prefix with !string

!string runs the most recent command that starts with string.

!ss

That reruns ss -tulpn from the list above, the listening socket check described in which ports are open on a Linux server. !?string? matches anywhere in the line rather than at the start, which helps when you remember an argument but not the command name.

Keep the prefix long. !s will match ss, sudo, systemctl or shutdown, whichever ran most recently, and you will not know which until it runs. !string:p prints the match without running it. If nothing matches, bash prints bash: !ss: event not found and runs nothing, which is the safe outcome.

Fix one typo with ^old^new

sudo systemctl status ngnix
Unit ngnix.service could not be found.
^ngnix^nginx

The shell reruns the previous line with the first ngnix replaced by nginx. Only the first match is changed. To change every match, use the long form !!:gs/ngnix/nginx/, where s means substitute and g means do it across the whole line.

Two habits stop the accident where an expansion runs something you did not intend.

The first is :p, which you have already seen. Append it to an expansion and bash prints the result instead of running it: !!:p, or !systemctl:p. The printed line goes into your history, so !! afterwards runs the thing you just read.

The second is stronger, because it applies to every expansion without you remembering anything:

shopt -s histverify

Put that line in ~/.bashrc. With histverify set, an expansion does not run when you press Enter. Bash writes the expanded line back into your prompt so you can read it and edit it, and you press Enter a second time to run it. It costs one keystroke and it removes the whole class of accident. It needs readline, so it works at an interactive prompt and nowhere else.

Ctrl-R is the other way to work, and it never expands anything. Press Ctrl-R and type part of a command. The prompt changes to a reverse-i-search prompt and shows the most recent match as you type. Press Ctrl-R again to step back to older matches. Enter runs the line it is showing. Ctrl-G cancels the search and gives your original prompt back. The left arrow key ends the search and leaves the matched line in your prompt for editing.

Use Ctrl-R when you want to see the command first. Use !! and !$ when the command is one line old and you can still read it on screen.

Why does echo "done!" say event not found

echo "deploy done!"
bash: !": event not found

History expansion runs before quoting is resolved, and double quotes do not protect the ! character. Only single quotes and a backslash do. So echo 'deploy done!' prints what you expect. A backslash also stops the expansion, but inside double quotes bash leaves the backslash in the output, so single quotes are the clean fix.

This bites hardest with passwords, because a strong password often contains !. A command like mysql -u app -p"S3cret!pass" either fails with an event-not-found error, or, if some history entry happens to match, silently sends a different string. Use single quotes, or better, let the tool prompt you so the secret never reaches the command line at all. That habit belongs with the rest of your account hygiene: see the first ten minutes on a new VPS for how keys and passwords should be handled on a fresh box.

If you are about to paste a block of text full of ! characters, switch expansion off for the session with set +H, and back on with set -H.

HISTSIZE, HISTFILESIZE and where your history is kept

Two variables, and people mix them up because the names are close.

  • HISTSIZE is how many commands the running shell keeps in memory.
  • HISTFILESIZE is how many lines are kept in the file on disk, ~/.bash_history unless HISTFILE says otherwise.

The file is written when the shell exits, and it is truncated to HISTFILESIZE lines at that moment. Ubuntu's default ~/.bashrc sets 1000 and 2000. Check what yours actually holds:

grep HIST ~/.bashrc
echo "$HISTSIZE $HISTFILESIZE $HISTFILE"

Raise both if your !string searches keep missing commands you know you ran. Values of 10000 and 20000 are common and cost almost nothing, because the file is plain text. A negative value means no limit at all.

Timestamps help when you come back to a server after a week away:

export HISTTIMEFORMAT='%F %T '

history then prints a date and time in front of each entry, because bash starts writing a comment line holding the epoch seconds before each command in the file.

The common complaint is that history goes missing when you use more than one terminal. Each shell keeps its own list in memory and writes it out at exit, so without histappend the last shell to close overwrites what the others saved. Two settings fix it:

shopt -s histappend
export PROMPT_COMMAND='history -a'

histappend makes the shell add its list to the end of the file instead of replacing the file. history -a appends new lines after every prompt, so a session that is killed rather than closed cleanly still leaves its commands behind. Ubuntu's default ~/.bashrc sets histappend already. This matters more the more machines you run, because history is stored per user per machine, so when you are managing several servers from one workstation the !$ you want may simply be on another host.

Keeping secrets out of your bash history

HISTCONTROL decides which lines are saved at all.

  • ignorespace drops any line that begins with a space.
  • ignoredups drops a line that is identical to the one before it.
  • ignoreboth does both of the above.
  • erasedups removes every older copy of the line being saved.
export HISTCONTROL=ignoreboth

With ignorespace active, one leading space keeps a command out of the in-memory list, so it never reaches the file either. Check the value before you rely on it with echo "$HISTCONTROL". Ubuntu's default user .bashrc sets ignoreboth, but a root shell or a minimal image may leave it unset, and then the leading space does nothing and the line is stored like any other.

Be honest with yourself about what this protects. The line stays out of ~/.bash_history. It is still visible in ps output while the process runs, so any other user on the box can read it there. sudo records the command it ran in the system log. The history file is plain text, so keep it at mode 600 and remember that anyone who can read your home directory can read your last few thousand commands.

If something sensitive is already in the list, remove the entry and rewrite the file:

history
history -d 517
history -w

history -d removes that entry from memory, and history -w writes the current list over the file. history -c clears the whole list. HISTIGNORE is the related knob for noise rather than secrets: HISTIGNORE='ls:pwd:history:clear' keeps those lines out of the list so your searches return useful hits.

Why history expansion does nothing in a shell script

History expansion belongs to interactive shells. A script runs in a non-interactive shell, where the history list is not enabled and expansion is off, so !! and !$ stay in the line as ordinary text. sudo !! inside a script asks sudo to run a command literally named !!, and it fails.

Check any shell you are sitting in:

echo $-

The output is the set of current option flags, something like himBHs. i means the shell is interactive and H means history expansion is enabled. Run the same line inside a script and neither letter is there.

That is the line between the two halves of your shell work. At the prompt, !$ and Ctrl-R save keystrokes on commands you can still see. In a file, you name things instead: put the path in a variable, or capture output with command substitution. A script that depended on your personal history would do something different for the next person who ran it, which is the opposite of what a script is for.

For that reason, every example on this page is meant to be typed at a live prompt. None of it behaves the same way pasted into a .sh file.

FAQ

What does !! do in bash?

!! expands to the full text of the previous command line, so sudo !! runs your last command again as root. The expansion is textual and happens before bash parses the line, and bash prints the finished line just before running it. If you are not certain what the previous line was, type sudo !!:p first. :p prints the expansion without running it, and it adds the printed line to your history, so a following !! runs it.

How do I reuse the last argument of the previous command?

Use !$. After sudo mkdir -p /srv/www/app/releases, the line ls -ld !$ becomes ls -ld /srv/www/app/releases. It takes the last word of the line, so a redirection target at the end counts as the last word. You can also append text to it: !$/config.env adds to the path, because the word designator stops at the /. The interactive alternative is Alt-., which inserts the same text into your prompt so you can read it before pressing Enter.

Why does bash say "event not found" when my text contains an exclamation mark?

Double quotes do not protect ! from history expansion, so echo "done!" makes bash look for a history event and print bash: !": event not found. Single quotes do protect it, so write echo 'done!'. A backslash stops the expansion too, but inside double quotes bash leaves the backslash in the output. To paste a long block containing !, turn expansion off for the session with set +H.

Why do !! and !$ not work in my shell script?

History expansion is enabled only in interactive shells. A script runs non-interactively, so the shell never builds a history list, and !! is left in the line as plain text. Run echo $- to see which you are in: an interactive shell prints flags including i and H, and a script prints neither. In scripts, use a variable or command substitution instead.

How do I keep a password out of my bash history?

Set HISTCONTROL=ignorespace or HISTCONTROL=ignoreboth in ~/.bashrc, then start the command with a single space and it is never saved. Confirm the value with echo "$HISTCONTROL" first, because if it is unset the leading space does nothing. This only keeps the line out of ~/.bash_history. The command is still visible in ps while it runs, and sudo logs what it executed. If a secret is already saved, find its number with history, then run history -d <number> followed by history -w to rewrite the file.

#bash#shell#history#productivity#cli