Shell aliases are a powerful way to shorten frequently used commands, but they can become surprisingly complex when they involve multiple commands, substitutions, or other aliases. Understanding how zsh expands these can save you a lot of debugging headaches.

Let’s see zsh alias expansion in action. Imagine you have a Git repository and you want a quick way to stage all changes and then commit them with a message.

First, let’s define a simple alias:

alias gs='git status'

Now, if you type gs, zsh will replace it with git status before executing it. Simple enough.

But what about chaining commands or using shell features? Let’s try a more complex one:

alias gcm='git commit -m'

Typing gcm "My awesome commit" works. Zsh expands gcm to git commit -m, and then appends "My awesome commit".

Now, let’s get trickier. We want an alias that stages everything and then commits with a provided message. A common first attempt might look like this:

alias gasc='git add -A && git commit -m'

If you try gasc "My staged commit", you’ll likely get an error like git commit -m: no such file or directory or error: There were unmerged files. This is because zsh, by default, expands aliases once and then parses the resulting command line. The && git commit -m part is being treated as arguments to git add -A, not as a separate command.

To make this work, we need to tell zsh to re-evaluate the expanded alias. This is where the alias -g (global alias) or using noglob with a regular alias comes in. However, the most common and robust way to handle this kind of multi-command aliasing, especially when arguments are involved, is to use a shell function. Functions are parsed more intelligently and allow for complex logic.

Here’s how you’d achieve the same goal with a function, which is the idiomatic zsh way for anything beyond simple command substitution:

gasc() {
  git add -A && git commit -m "$1"
}

Now, gasc "My staged commit" will execute git add -A first, and if that succeeds, it will execute git commit -m "My staged commit". The $1 captures the first argument you pass to the function.

The core problem with the alias gasc='git add -A && git commit -m' is that alias expansion happens before the shell parses the command line for logical operators like &&. So, git add -A && git commit -m "My staged commit" is interpreted by zsh as a single command: git add -A with two arguments: && and git commit -m "My staged commit". git add -A doesn’t know what to do with && as an argument.

When you use a function, zsh parses the function definition and then executes it. Inside the function, git add -A and git commit -m "$1" are treated as distinct commands connected by the && operator, which is evaluated correctly.

Let’s look at another common alias pitfall: an alias that calls another alias.

alias ll='ls -l'
alias lll='ll -a'

If you type lll, zsh expands lll to ll -a. Then, it does not re-expand ll. So, you end up running ll -a, which zsh then expands to ls -l -a. This works as expected in this case.

However, if you have a more complex scenario where an alias is defined within another alias’s expansion, and you want the inner alias to expand, you might need setopt ALIAS_TO_ALIAS.

alias foo='echo "hello"'
alias bar='foo world'

Typing bar would result in foo world. If setopt ALIAS_TO_ALIAS is off (the default), foo would not be expanded within bar’s expansion. You’d literally see foo world printed. If setopt ALIAS_TO_ALIAS is on, then foo would be expanded to echo "hello", resulting in echo "hello" world being executed.

The key takeaway is that zsh performs alias expansion before parsing the command line. For commands involving logical operators (&&, ||), pipes (|), or command substitutions ($(...), `...`), or when you want to chain alias expansions, functions are generally more robust and predictable.

The next error you’ll hit after mastering alias expansion is likely related to zsh’s globbing (wildcard expansion) interacting unexpectedly with your aliases or functions.

Want structured learning?

Take the full Zsh course →