skip to content

In a bash script someone defines `ls() { ls -l "$@"; }` so listings are always long-format, and every call to ls then recurses until the shell dies. Why does that happen, and how do you call the real ls from inside the wrapper?

level: middleimportance: should knowfreq 45%

answer

  1. name lookup has a fixed order
  2. functions outrank anything on disk
  3. one builtin exists to skip that step
  4. a backslash is not the same escape

basics

~20 s

Bash resolves a plain command name to a shell function before it ever searches PATH, so ls inside the body finds the function again and it calls itself forever. Prefix the inner call with command — command ls -l "$@" — which skips function lookup.

solid answer

~50 s

When bash runs a simple command it resolves the name in a fixed order: alias expansion at parse time, then shell functions, then builtins, then a PATH search. A function named `ls` therefore wins over `/bin/ls` everywhere in the script, including inside its own body, so the wrapper calls itself and recurses until the shell exhausts memory. The fix is the `command` builtin: `command ls -l "$@"` suppresses function lookup and runs the builtin or the PATH executable instead. If you are wrapping a builtin rather than an external program, `builtin cd "$@"` is the equivalent. To inspect what a name currently resolves to, use `type -a ls`; `declare -F` lists defined functions and `unset -f ls` removes one. Deliberately shadowing a command is a legitimate technique for stubbing in tests, but in a shared library it is a trap.

code

bash · 12 lines
bash
#!/usr/bin/env bash
# Broken: the body resolves 'ls' to this same function.
# ls() { ls -l "$@"; }

# Correct: 'command' skips shell-function lookup.
ls() { command ls -l "$@"; }

ls /tmp
type -t ls        # -> function
type -a ls        # -> function, then /bin/ls (or wherever it lives)
unset -f ls
type -t ls        # -> file

go deeper

for a junior

Know that a function you define takes over that command name, and that command ls is how you reach the real program from inside a wrapper of the same name.

for a middle

Explain the resolution order — alias, function, builtin, PATH — and why the body is resolved on every call, which is what makes the self-named wrapper recurse.

for a senior

Diagnose it live: run type -a to see what a name resolves to, know that command skips functions while builtin forces a builtin, and treat a library that redefines a common command name as a defect.

for a principal

Own the convention across a shell codebase — namespaced function names in shared libraries, overrides allowed only in the script that needs them and always calling through command, and stubbing by shadowing confined to the test harness.

## How bash resolves a command name Given a simple command like `ls -l /tmp`, bash decides what `ls` means in a strict order: 1. **Reserved words** are recognised while parsing (`if`, `while`, `[[`, `function`…). 2. **Aliases** are expanded, also at parse time, and only when alias expansion is enabled (interactive shells, or `shopt -s expand_aliases`). 3. **Shell functions** are looked up next. 4. **Builtins** (`cd`, `echo`, `read`, `printf`, `command`…) come after that. 5. **PATH search** for an executable file is last, with results cached in the shell's hash table. Everything about wrapper functions follows from step 3 sitting above steps 4 and 5. A function called `ls`, `grep`, `cd` or `curl` takes over that name for the whole shell — in the script, in every function it calls, and in anything it sources. ## Why the wrapper recurses ```bash ls() { ls -l "$@"; } # broken ``` The body is not resolved when the function is defined; it is resolved each time the function runs. So calling `ls /tmp` enters the function, which runs `ls -l /tmp`, which resolves `ls` to the same function, which runs `ls -l /tmp` again. Bash does not detect this. Each level is a new function frame, so the shell keeps allocating until it is killed or the machine starts swapping — there is no tidy "maximum recursion depth" error unless you have set a nesting limit yourself. A useful mental check: any wrapper whose body mentions its own name is either intentional recursion or this bug. ## `command` and `builtin` The `command` builtin exists precisely for this. `command NAME ARGS…` runs `NAME` while ignoring shell functions, falling through to the builtin or the PATH executable: ```bash ls() { command ls -l "$@"; } # correct curl() { command curl --retry 3 "$@"; } ``` If the thing you are wrapping is a builtin, `command` still finds the builtin, which is usually what you want: ```bash cd() { builtin cd "$@" && ls; } # explicit is better here ``` `builtin NAME` forces the builtin specifically, skipping both functions and the PATH. And if you genuinely need the external binary rather than the builtin of the same name — `/bin/echo` instead of the `echo` builtin, say — call it by absolute path or through `env`. Note what `command` does **not** do: it is not a general "run this literally" escape. A leading backslash, `\ls`, suppresses *alias* expansion only; functions are still found. Candidates who reach for `\ls` to defeat a function have confused the two mechanisms. ## Inspecting and removing - `type -a ls` lists every meaning the name currently has, in resolution order — alias, function, builtin, and each PATH hit. - `type -t ls` prints just the kind: `alias`, `keyword`, `function`, `builtin` or `file`. - `declare -F` lists the names of all defined functions; `declare -f ls` prints the body. - `unset -f ls` removes the function, restoring the external command. In a debugging session, `type -a` is the first thing to run when a command in a script behaves nothing like it does at your prompt. ## When shadowing is deliberate Overriding is a real technique, not only a bug: - **Test stubs.** Shell test suites define `curl() { echo "$STUBBED_BODY"; }` to run a script without network access. - **Policy wrappers.** A deployment library defines `kubectl() { command kubectl --context "$CTX" "$@"; }` so every call is pinned to one cluster. - **Safety rails.** `rm() { command rm -I "$@"; }` inside a script that must not delete silently. The discipline is: keep the override local to the script that wants it, always call through `command` inside the body, and never let a general-purpose sourced library redefine a common name. A library that quietly redefines `test`, `echo` or `cd` will break code in files it has never heard of, and the failure surfaces far away from the definition. ## The related failure mode Because a later definition silently replaces an earlier one — no warning, no error — two sourced libraries that both define `log` will leave whichever was sourced last in charge. Prefixing library functions with a short namespace (`mylib::log`, or `mylib_log` for portability) is the usual defence, and `declare -F` makes the collision visible when you go looking for it.

  • Does `command` also bypass a builtin of the same name?
    No. `command` skips shell functions only; it will happily run the builtin, which is usually what a wrapper wants. To force the builtin explicitly use `builtin cd`, and to reach an external binary that shares a builtin's name — `/bin/echo`, for example — call it by absolute path or via `env`.
  • How do you find out what a name currently resolves to in a running script?
    `type -a name` lists every meaning in resolution order — alias, function, builtin and each PATH hit — while `type -t name` prints just the kind. `declare -F` lists all defined function names and `declare -f name` prints one body. These are the first things to run when a command behaves differently inside a script.
  • Why is defining a function named `log` or `test` in a shared shell library risky?
    Because a later definition silently replaces an earlier one with no warning, and the function outranks the builtin or PATH command everywhere it is sourced. Two libraries defining `log` leave the last one sourced in charge, and overriding `test` breaks conditionals in unrelated files. Namespace library functions instead, for example `mylib_log`.

saying these in an interview costs you the question

  • Thinks PATH is searched before shell functions
  • Uses \ls to escape a function instead of an alias
  • Believes bash detects and stops infinite function recursion
  • Says the function body is resolved once at definition time
  • Claims `command` and `builtin` do the same thing

context