Skip to content
Igor Maric / imTheOdd0ne

Aliases and shell functions: building your personal command vocabulary

The average developer types the same handful of commands hundreds of times a day, with the same flags, in the same order. The shell has two small features for shortening that vocabulary without losing any of its expressiveness, and they take an afternoon to learn properly.

TL;DRHomeBlog2023Article

Aliases and shell functions help a developer turn the commands they type most often into short, memorable words that still behave exactly like the originals. An alias rewrites a single command into a longer one before the shell runs it; a function is a tiny program that accepts arguments, branches on conditions, and can change the calling shell. Together they cut the keystrokes spent on navigation, listings, git, and archive handling, and they make repeated work less error-prone. The right adoption path is one alias for the most-typed command, then one function for the next thing that needs an argument. Key takeaway: shape the tools used hundreds of times a day, and the gains compound for years.

Aliases and shell functions: building your personal command vocabulary

There is a point in every working developer's life when you realise you have typed git status perhaps forty thousand times. The flags have not changed. The order has not changed. Your hands know the rhythm so well that you can type it while reading something else on the other monitor. The keystrokes accumulate. You keep paying them. And one afternoon, usually after watching somebody else hit a four-character invocation that does the same thing, it occurs to you that the shell has been quietly offering a way out for several decades.

The average developer types perhaps a hundred distinct shell commands across a working day, but reaches for maybe a dozen of them over and over: a handful of cd invocations, a flurry of ls -la, the same set of git status, git diff, git log triplets every few minutes, the occasional grep, the inevitable tar -xzf. The flags do not change. The order rarely changes. Nothing about it requires intelligence; that is the whole point. Repetitive work is exactly what the shell is good at, and most of us spend years typing through it by hand anyway.

The shell has noticed. Both Bash and Zsh ship with two small mechanisms for shortening that vocabulary without giving up any of its expressiveness: aliases and functions. Aliases handle the simple cases — a short word the shell expands into a longer command before running it. Functions handle everything else — small programs defined inside the shell, callable like commands, capable of taking arguments and doing real logic.

Both have been part of POSIX for decades, both are documented in the same chapter of every shell manual, and both are dramatically underused outside of pre-built dotfiles repositories. I have copied other people's .aliases files four times across as many jobs. Every time, I keep about a third of the lines, delete a third, and replace the rest with personal vocabulary I picked up in the meantime. The rest of this article is the version of that file I would hand to a colleague who has finally got tired of typing cd ../...

What aliases and functions actually are

An alias is a short word that the shell expands into a longer command before running it. A function is a small program defined inside the shell, callable like a command, capable of accepting arguments and doing logic. Aliases substitute text. Functions execute code. That is the whole distinction, and most of the practical advice in this article follows from it.

The smallest useful alias is one line. Place it in ~/.bashrc (Bash) or ~/.zshrc (Zsh):

# File: ~/.bashrc
alias ll='ls -la'

# Reload the file so the new alias is visible:
source ~/.bashrc

# Use it:
ll

# Output:
total 24
drwxr-xr-x  4 dev  staff   128 Jun 19 10:00 .
drwxr-xr-x  6 dev  staff   192 Jun 19 09:50 ..
-rw-r--r--  1 dev  staff   123 Jun 19 10:00 README.md
-rw-r--r--  1 dev  staff   456 Jun 19 09:55 config.toml

Typing ll is identical to typing ls -la. The shell sees ll, looks it up in its alias table, finds the replacement text, and runs the replacement instead. The original command remains available; the alias simply adds a shorter spelling. Nothing magical, nothing irreversible, and importantly, nothing the shell will hide from you if you ask it type ll.

Functions take a small step further. They accept arguments, run conditionals, and execute multiple commands in sequence. The textbook example is a function that creates a directory and changes into it in one move — mkcd:

# File: ~/.functions
mkcd () {
  mkdir -p "$1" && cd "$1"
}

# Reload:
source ~/.functions

# Use it:
mkcd projects/new-thing

# Verify:
pwd

# Output:
/Users/dev/projects/new-thing

The placeholder $1 is the first argument passed to the function. An alias cannot do this. The Bash 5.2 reference manual states the rule directly: 'There is no mechanism for using arguments in the replacement text, as in csh. If arguments are needed, a shell function should be used.'1 That single sentence is the deciding line between the two features, and the cause of most of the broken alias attempts I have inherited from other people's dotfiles.

A note on persistence. Aliases and functions defined at the shell prompt vanish on logout. To make them stick, they live in ~/.bashrc (Bash) or ~/.zshrc (Zsh), which the shell reads at startup. Many developers — including me — prefer to split the contents into dedicated files: ~/.aliases for aliases, ~/.functions for functions, sourced from the main rc file:

# File: ~/.bashrc (or ~/.zshrc)
[ -f ~/.aliases ]  && source ~/.aliases
[ -f ~/.functions ] && source ~/.functions

This is the layout used by Mathias Bynens' widely-copied dotfiles repository2, and it scales well: aliases and functions can grow into the hundreds without making the main rc file unreadable.

The aliases worth typing once

A handful of patterns recur in almost every developer's .aliases file. They cover navigation, listings, human-readable defaults, and the highest-volume command vocabulary on the machine — git. Place them all in a single file:

# File: ~/.aliases

# Navigation shortcuts: walk up the directory tree without spelling 'cd ..'.
alias ..='cd ..'
alias ...='cd ../..'
alias ....='cd ../../..'

# Friendlier ls: long form, hidden files, human-readable sizes.
alias ll='ls -la'
alias la='ls -lAh'
alias l='ls -lah'

# Human-readable defaults for size and search tools.
alias df='df -h'
alias du='du -h'
alias grep='grep --color=auto'

# Git: a small starter set of the highest-volume commands.
alias gs='git status'
alias gco='git checkout'
alias gd='git diff'
alias gp='git push'
alias gl='git log --oneline --graph --decorate'

Each group earns its place differently. The navigation shortcuts pay back faster than anything else in the file: typing .. instead of cd .. saves three keystrokes every single time, and most developers move up directories dozens of times a day. Oh My Zsh ships an analogous mechanism by default — its directories.zsh library file uses Zsh's global aliases (alias -g ...='../..', alias -g ....='../../..') together with the auto_cd option to achieve the same effect3, which gives some sense of how universal the pattern has become.

The ls aliases are about flags people forget. ls -la shows hidden files and full metadata; ls -lh adds human-readable sizes (4.0K rather than 4096); ls -A is -a without the noisy . and .. entries. Picking sensible defaults once and binding them to short names removes the cost of remembering. The grep --color=auto alias is the same pattern: colour highlighting on, every time, without typing the flag.

The git block is the highest-leverage section in the file for most developers. Git commands are the single most-typed command vocabulary on a working machine, and Oh My Zsh's git plugin (one of its most-used) ships dozens of git aliases by default4 precisely because the keystroke savings compound across a working day. A starter set of five — status, checkout, diff, push, log — covers the bulk of what a developer types into git directly. I have added perhaps ten more over the years, none of which anybody else needs.

That leaves the safety-net debate, which deserves a paragraph of its own. Some developers add interactive-confirmation aliases for destructive commands:

# File: ~/.aliases (continued)

# Safety net for destructive commands — but read the next paragraph first.
alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'

Two real schools exist on this. Oh My Zsh's common-aliases plugin ships these defaults for users who opt in5. Prezto deliberately does not, providing parallel verbs rmi, cpi, mvi instead6. The Prezto position is the safer one, and I have come around to it after one too many incidents on colleagues' machines that did not have my aliases. The Bash manual is explicit that 'aliases are not expanded when the shell is not interactive, unless the expand_aliases shell option is set …'1, which means that the moment the same command runs inside a script — or on a colleague's machine that does not have the alias — the safety net silently disappears. Worse, developers who rely on the alias often build muscle memory that assumes the prompt always appears, and that muscle memory does not transfer. Parallel verbs (rmi for 'interactive rm') make the choice explicit at the call site without overriding the original command. The file ends up almost identical, with one fewer category of foot-gun.

Where aliases hit their wall

Aliases are pure text substitution. They have no concept of arguments. Trying to make one accept a parameter looks plausible and fails, sometimes in genuinely creative ways:

# File: ~/.bashrc — this does NOT work as intended.
alias mkcd='mkdir -p $1 && cd $1'

# Use it:
mkcd foo

# Effective expansion (what the shell actually runs):
mkdir -p $1 && cd $1 foo

The shell substitutes mkcd with the literal text mkdir -p $1 && cd $1, and then appends foo to whatever comes next on the command line. The $1 markers are not arguments — they are dollar-sign-one in the resulting text, which the shell tries to expand as positional parameters of the surrounding context (typically empty). The result is that mkdir runs with no directory name, and cd is asked to change into foo after a stray $1. None of this does what the alias author wanted, and most people only figure it out the first time the alias quietly succeeds in some half-broken way and they spend a happy half-hour debugging.

The Bash manual states the constraint plainly: 'There is no mechanism for using arguments in the replacement text … If arguments are needed, a shell function should be used.'1 POSIX itself flags aliases as primarily an interactive-user convenience and points readers towards functions for anything more substantial; the rationale section of the POSIX alias definition records that 'the standard developers considered that aliases were of use primarily to interactive users and that they should normally not affect shell scripts called by those users; functions are available to such scripts'7.

So the rule is short. One word, no arguments, no logic — alias. Anything that needs $1, anything that needs an if, anything that should produce a different result depending on what was typed after it — function. The Bash manual is unambiguous about which of the two to favour by default: 'For almost every purpose, shell functions are preferred over aliases.'1 In my own dotfiles, the ratio settled at roughly thirty aliases to fifteen functions, and I have never regretted promoting an alias to a function. The reverse direction is rare and almost always painful.

Functions: aliases that take arguments

The function syntax in Bash and Zsh shares a single canonical form. The keyword function is optional in Bash, and writing functions with parentheses is portable across both shells:

# File: ~/.functions

# Make a directory (and any missing parents) and cd into it.
mkcd () {
  mkdir -p "$1" && cd "$1"
}

# Reload after editing:
source ~/.functions

# Use:
mkcd projects/new-thing

# Verify the shell really moved:
pwd

# Output:
/Users/dev/projects/new-thing

The body sits between the braces. Inside the body, $1, $2, $3 and so on bind to positional arguments. $@ expands to all arguments, each as a separate word. $# is the count. $0 is the function name in Zsh and the name of the parent script in Bash, which has tripped me up more times than I would like to admit. The Bash manual covers the positional-parameter model in full1, and the same positional model applies inside functions and scripts alike.

A more useful function is the canonical archive extractor. Almost everyone who writes shell ends up with one. It dispatches on the file extension, picks the right tool, and spares the developer from having to remember whether tar needed -xjf or -xJf for a particular compression — a fact I have looked up at least once a year for fifteen years and still cannot keep in working memory:

# File: ~/.functions

extract () {
  if [ -z "$1" ]; then
    echo "Usage: extract <archive>"
    return 1
  fi
  case "$1" in
    *.tar.gz|*.tgz)   tar -xzf "$1" ;;
    *.tar.bz2|*.tbz2) tar -xjf "$1" ;;
    *.zip)            unzip "$1"   ;;
    *)
      echo "extract: unsupported archive: $1"
      return 1
      ;;
  esac
}

A demo run from a directory containing a single archive:

# Source after editing:
source ~/.functions

# Look at what is there:
ls

# Output:
backup.tar.gz

# Extract:
extract backup.tar.gz

# Look again:
ls

# Output:
backup.tar.gz   notes.md   src

A function should set its exit status the way a command does. return 0 means success; any non-zero value means failure. return 1 is the conventional generic-failure value, used twice in the example above for the empty-argument and unknown-extension paths. Subsequent && and || operators in the calling shell respect that exit status, so functions compose with the rest of the shell vocabulary cleanly.

One detail catches readers familiar with lexically-scoped languages: Bash uses dynamic scope inside functions. A variable assigned without local is visible to functions called from the current function, which is rarely what the author intended. I have debugged that confusion in other people's dotfiles a handful of times; the symptom is always a variable mysteriously holding a value from somewhere it shouldn't, and the cause is always the missing local. Zsh similarly supports local and adds explicit scope keywords like typeset -l. The Bash manual documents local under shell builtins1. A defensive habit of declaring every assignment with local (one assignment per local line, to keep the exit status meaningful) is worth picking up early and never unlearning.

The day a personal ~/.functions file crosses fifty lines tends to be the day you stop writing one-off shell scripts for repetitive tasks. The vocabulary is on the prompt, ready, and the inertia of opening a new file disappears.

The conceptual point that matters for the next section comes from MIT's Missing Semester course8: 'functions have to be in the same language as the shell, while scripts can be written in any language. ... functions are loaded once when their definition is read. ... functions are executed in the current shell environment whereas scripts execute in their own process.' The third clause is the one that determines whether a piece of code can be a function at all.

When a function should graduate to a script

The decision is almost mechanical once the constraints are written down. A short hierarchy, in order of growing scope:

  • One line, no logic, no arguments → alias. alias gs='git status' belongs nowhere else.
  • Needs arguments, conditionals, or multiple commands → function. mkcd, extract, anything with a case statement.
  • Needs to modify the calling shell → must stay a function. A script that runs cd does not move the parent shell. A script that exports an environment variable does not export it back to the parent. Both effects only persist if the code runs in the parent shell's process, and only functions do that. Anything that flips a directory, sets a variable, or alters the parent shell's state has no choice — it has to be a function.
  • Multi-line, used by other shells, used by other developers, complex enough to want tests → script in ~/bin/ or ~/.local/bin/. Functions that grow past, say, fifty lines are usually easier to maintain in their own files where they can be edited without scrolling past unrelated functions.
  • Project-scoped (build, lint, deploy commands shared with collaborators) → consider make, just, or package.json scripts. Personal vocabulary stays in dotfiles; team vocabulary lives in the repository, where every collaborator gets the same commands.

Nick Janetakis writes about the practical end of this in a 2023 piece on moving large shell-alias-functions into their own script files9, where he describes promoting his fattest functions to standalone scripts in ~/.local/bin/private/. The argument is straightforward: at some size, a function is just a script with extra braces, and putting it in its own file makes it easier to read, easier to edit, and easier to lint. The trigger he uses is roughly the same as the one above — anything that 'feels too big to scroll past' gets pulled out.

The script-versus-function decision is asymmetric in one direction. Anything that can be a script can also be a function, but anything that must change the parent shell can only be a function. When in doubt, start as a function and promote later if the file gets unwieldy. The reverse path exists, but only theoretically; in fifteen years I have never travelled it.

A few more patterns worth knowing

Three patterns sit slightly past the basics and reward the time it takes to learn them.

The first is Zsh's global and suffix aliases, which extend the alias mechanism in two directions. A global alias substitutes anywhere on the command line, not only at the start. A suffix alias binds an extension to a command and runs that command when a file with the extension is typed bare. Both are Zsh-specific (Bash has no equivalent) and both are documented in the Zsh manual10:

# File: ~/.zshrc (Zsh-only)

# Global alias: substituted anywhere on the line, not only at the start.
alias -g G='| grep'

# Use it:
ls /usr/local/bin G git

# Output:
git
git-receive-pack
git-shell
git-upload-archive
git-upload-pack

# Suffix alias: bound to a file extension; typing the file runs the command.
alias -s md=code

# Use it (opens README.md in VS Code):
README.md

Global aliases make pipelines terser at the cost of a small amount of readability; reserve them for two or three frequently-piped tails like | grep, | less, or | wc -l. Suffix aliases are convenient for 'open this file in the right tool' patterns but should be used sparingly — they cause surprising behaviour for anyone reading the shell history later. I have deleted more of my own global aliases than I have kept, and the kept ones are all of the | grep variety.

The second pattern is Zsh's autoload mechanism. Instead of defining functions in a single rc file, autoload reads one function per file on first call, with each file living somewhere on $fpath. The Zsh manual's 'Functions' chapter covers the mechanism in full10. For a personal dotfiles repository this is overkill until the function library grows past a few dozen entries; for plugin frameworks (Oh My Zsh, Prezto), it is the standard way to ship optional functionality.

The third is a small but durable gotcha. The Bash manual notes that 'aliases are expanded when a function definition is read, not when the function is executed'1. The practical consequence is that defining an alias inside a function is almost never what the author wants: the alias lives in the global table, but is only added when the function runs, and is not expanded inside the function body of the same call. Keep alias definitions on their own lines, in ~/.aliases, and let functions stay function-shaped.

A shell that has been taught the developer's vocabulary is a shell that gets out of the way. Aliases and functions are the lowest-friction productivity investment in computing — payback is measurable in days, not quarters, and the same definitions keep working through shell upgrades, distribution changes, and the slow drift of personal dotfiles across years. I have lost many things to job changes and disk failures over the years. The .aliases and .functions files are not among them.

The right adoption path is small. One alias for the most-typed command this week — probably gs for git status, or .. for cd ... One function for the next thing that needs $1 — almost certainly mkcd, possibly extract, occasionally a project-specific helper. After that, growth is incremental: every time the same long command appears in your shell history three or four times in a row, it has earned a name.

The tools you use hundreds of times a day are the ones worth shaping. The shell offers the chisel; the rest is keystrokes saved, mistakes avoided, and an evening of dotfile editing that keeps paying back for years.


Footnotes

  1. Ramey, Chet. (2022). 'Bash Reference Manual, Edition 5.2.' Free Software Foundation. https://www.gnu.org/software/bash/manual/bash.html 2 3 4 5 6 7

  2. Bynens, Mathias. (2023). '.aliases.' mathiasbynens/dotfiles. https://github.com/mathiasbynens/dotfiles/blob/main/.aliases

  3. Russell, Robby and contributors. (2023). 'directories.zsh.' ohmyzsh/ohmyzsh. https://github.com/ohmyzsh/ohmyzsh/blob/master/lib/directories.zsh

  4. Russell, Robby and contributors. (2023). 'git.plugin.zsh.' ohmyzsh/ohmyzsh. https://github.com/ohmyzsh/ohmyzsh/blob/master/plugins/git/git.plugin.zsh

  5. Russell, Robby and contributors. (2023). 'common-aliases.plugin.zsh.' ohmyzsh/ohmyzsh. https://github.com/ohmyzsh/ohmyzsh/blob/master/plugins/common-aliases/common-aliases.plugin.zsh

  6. Ionescu, Sorin and contributors. (2023). 'modules/utility/init.zsh.' sorin-ionescu/prezto. https://github.com/sorin-ionescu/prezto/blob/master/modules/utility/init.zsh

  7. IEEE / The Open Group. (2018). 'alias — define or display aliases (Rationale).' Open Group Base Specifications Issue 7. https://pubs.opengroup.org/onlinepubs/9699919799/utilities/alias.html

  8. Athalye, Anish; Gjengset, Jon; Gonzalez Ortiz, Jose Javier. (2020). 'Shell Tools and Scripting.' MIT Missing Semester. https://missing.csail.mit.edu/2020/shell-tools/

  9. Janetakis, Nick. (2023). 'Moving a Bunch of Shell Alias Functions into Their Own Script Files.' nickjanetakis.com. https://nickjanetakis.com/blog/moving-a-bunch-of-shell-alias-functions-into-their-own-script-files

  10. Stephenson, Peter. (2022). 'The Z Shell Manual, version 5.9.' zsh.sourceforge.io. https://zsh.sourceforge.io/Doc/Release/index.html 2

Related Articles

Latest from the blog

The organisational memory leak: why lessons disappear between teams

Companies do not keep repeating software failures because nobody noticed. They repeat them because the lesson had nowhere durable to live, no owner, and no budget attached. The post-mortem sits in the wiki. The trap stays armed.

19 May 2026 · 23 min read