opscape

Shell builtins · bash builtin

shift

Drop the first N positional parameters ($1, $2, ...) and renumber the rest down.

shift [N]

8 of 8 examples run in Debian 13.7 on 2026-10-31 · manual: bash(1), SHELL BUILTIN COMMANDS

shift discards $1 and moves every other positional parameter down by one: $2 becomes $1, $3 becomes $2, and $# decreases. shift N drops the first N at once. It works on the script's arguments at the top level and on the function's own arguments inside a function.

It is the engine of hand-written argument parsing: a while (( $# > 0 )) loop looks at $1, handles it (taking a value from $2 and shifting twice when an option has an argument), and shifts. Unlike getopts, that style handles long options such as --output=FILE and options mixed with operands. It is also how you peel off a subcommand (cmd=$1; shift) and pass the rest through with "$@".

When there are fewer than N parameters, shift N does nothing and returns 1. That is easy to miss, and in a loop it can mean spinning forever, so check $# first.

Examples

Drop the first argument

set -- alpha beta gamma
echo "before: \$1=$1, $# args"
shift
echo "after:  \$1=$1, $# args, all: $*"
output
before: $1=alpha, 3 args
after:  $1=beta, 2 args, all: beta gamma

set -- stands in for a script's arguments here. After shift, beta is $1 and the count drops from 3 to 2.

Split off a subcommand and pass the rest through

tool.sh
#!/usr/bin/env bash
cmd=${1:?usage: tool.sh COMMAND [ARGS...]}
shift
case $cmd in
  count) wc -l "$@" ;;
  show)  head -n 2 "$@" ;;
  *)     echo "unknown command: $cmd" >&2; exit 2 ;;
esac
bash tool.sh count todo.txt notes.txt
bash tool.sh show "file with spaces.txt"
output
  5 todo.txt
  6 notes.txt
 11 total
This file has spaces in its name.

The first argument picks the action, shift removes it, and "$@" forwards the remaining arguments exactly as given, spaces included. This is how git-style command-line tools are structured.

Parse options by hand, including long options

deploy.sh
#!/usr/bin/env bash
env=staging dry_run=0
while (( $# > 0 )); do
  case $1 in
    -e|--env)  env=$2; shift 2 ;;
    --env=*)   env=${1#*=}; shift ;;
    -n|--dry-run) dry_run=1; shift ;;
    --)        shift; break ;;
    -*)        echo "unknown option: $1" >&2; exit 2 ;;
    *)         break ;;
  esac
done
echo "env=$env dry_run=$dry_run targets=($*)"
bash deploy.sh --env=prod -n web01 web02
bash deploy.sh -e dev web01
bash deploy.sh --dry-run -- -odd-host
output
env=prod dry_run=1 targets=(web01 web02)
env=dev dry_run=0 targets=(web01)
env=staging dry_run=1 targets=(-odd-host)

Each branch shifts away what it consumed: two words for -e dev, one for --env=prod or a flag. The loop stops at the first non-option (or after --), leaving the targets in $@.

Drop several arguments at once

set -- 2026 03 14 backup.tar.gz notes.txt
date="$1-$2-$3"
shift 3
echo "date=$date files: $*"
output
date=2026-03-14 files: backup.tar.gz notes.txt

shift 3 removes the three date parts in one step, leaving only the file names.

Shifting past the end fails and does nothing

set -- one two
shift 3
echo "status $?, still have: $*"
output
status 1, still have: one two

With only two parameters, shift 3 returns 1 and leaves them all in place. It does not shift as many as it can. Under set -e this failure would end the script.

An option missing its value

deploy.sh
#!/usr/bin/env bash
env=staging
while (( $# > 0 )); do
  case $1 in
    -e) [[ $# -ge 2 ]] || { echo "-e needs a value" >&2; exit 2; }
        env=$2; shift 2 ;;
    *)  break ;;
  esac
done
echo "env=$env"
bash deploy.sh -e prod
bash deploy.sh -e
outputexit 2
env=prod
-e needs a value

Without the $# -ge 2 check, -e as the last argument would set env to an empty string, and shift 2 would fail and leave -e in $1, looping forever. Always check that the value is there before consuming it.

Walk through the arguments one by one

set -- web01 "db 01" cache01
while (( $# )); do
  echo "processing [$1], $(( $# - 1 )) left"
  shift
done
output
processing [web01], 2 left
processing [db 01], 1 left
processing [cache01], 0 left

A while (( $# )) loop with shift visits every argument in order. A for arg in "$@" loop is simpler when you do not need to look ahead at $2.

shift inside a function affects only its own arguments

set -- script-arg1 script-arg2
log() { local level=$1; shift; echo "[$level] $*"; }
log WARN disk almost full
echo "script still has: $*"
output
[WARN] disk almost full
script still has: script-arg1 script-arg2

In a function, the positional parameters are the function's arguments. shift there leaves the script's own $@ untouched.

Common pitfalls

  • shift 2 with only one argument left fails silently (status 1) and shifts nothing, which turns a parsing loop into an infinite loop. Check $# before shifting by more than one.

  • After shift, the old $1 is gone. Save it first if you still need it (cmd=$1; shift).

  • Using $* or unquoted $@ to pass the rest along breaks arguments with spaces. Use "$@".

  • shift cannot drop arguments from the end or the middle; use an array and slicing ("${@:1:$#-1}") for that.

GNU vs BSD and macOS

The examples above ran the GNU/Linux version. These are the differences that matter on macOS and the BSDs (written from their documentation, not run here).

  • shift and shift N are POSIX. In dash, shifting past the end is a fatal error that ends the script instead of returning 1.

Related