Bash Arrays and String Manipulation: A Practical Guide
On this page
Bash is not a general-purpose programming language, but it has more built in than most people use. Arrays let you hold lists of file names or settings without fragile word splitting, and parameter expansion can cut, trim and replace text without starting sed, cut or basename. Both make scripts faster and easier to read.
Everything here works in Bash 4.3 and later, which covers any current Linux distribution. macOS still ships Bash 3.2 as /bin/bash, which has no associative arrays and no mapfile; install a newer Bash with Homebrew if you need them there.
Indexed arrays
Create an array with parentheses. Elements are separated by spaces, so quote any element that contains one:
$ fruits=(apple banana "passion fruit")
$ echo "${fruits[0]}"
apple
$ echo "${fruits[-1]}"
passion fruit
$ echo "${#fruits[@]}"
3
Indexes start at 0, negative indexes count from the end, and ${#array[@]} gives the number of elements. The braces are required: $fruits[0] prints the first element followed by a literal [0].
Append with +=, take a slice with ${array[@]:start:count} and remove an element with unset:
$ fruits+=(cherry)
$ echo "${fruits[@]:1:2}"
banana passion fruit
$ unset 'fruits[1]'
$ echo "${!fruits[@]}"
0 2 3
${!array[@]} lists the indexes. After an unset the array has a gap, because Bash arrays are sparse. To renumber it, copy it onto itself: fruits=("${fruits[@]}").
Looping over an array
Always loop over "${array[@]}" with the double quotes. Each element then stays one word, even if it contains spaces:
$ for f in "${fruits[@]}"; do echo "- $f"; done
- apple
- banana
- passion fruit
- cherry
Leave the quotes off and "passion fruit" becomes two loop iterations. To loop with the index as well, iterate over the keys:
$ for i in "${!fruits[@]}"; do echo "$i: ${fruits[$i]}"; done
0: apple
1: banana
2: passion fruit
3: cherry
There is a related difference between [@] and [*]. Quoted, "${a[@]}" expands to separate words, while "${a[*]}" joins everything into one string using the first character of IFS. That makes [*] handy for joining:
$ parts=(red green blue)
$ joined=$(IFS=,; echo "${parts[*]}")
$ echo "$joined"
red,green,blue
Loops in general are covered in the bash loops guide.
Filling arrays from files and commands
To read a file into an array with one line per element, use mapfile (also called readarray). The -t option strips the trailing newline from each line:
$ mapfile -t lines < list.txt
$ echo "${#lines[@]} lines, third is: ${lines[2]}"
3 lines, third is: three four
For command output, combine it with process substitution:
$ mapfile -t sorted < <(printf '%s\n' pear apple fig | sort)
$ echo "${sorted[@]}"
apple fig pear
You will often see sorted=($(command)) instead. It works for simple words, but it splits on any whitespace and expands wildcards such as *, so names with spaces break. Prefer mapfile.
To split one string on a delimiter, use read -a with a custom IFS:
$ IFS=, read -r -a parts <<< "red,green,blue"
$ echo "${parts[1]}"
green
Associative arrays
Associative arrays use strings as keys, like a dictionary or hash map. You must declare them with declare -A first; without it, Bash treats the keys as arithmetic and everything lands in element 0.
$ declare -A port
$ port[http]=80
$ port[https]=443
$ port[ssh]=22
$ echo "${port[https]}"
443
$ for k in "${!port[@]}"; do echo "$k=${port[$k]}"; done | sort
http=80
https=443
ssh=22
Keys come back in no particular order, which is why the example pipes through sort. To test whether a key exists, use -v:
$ [[ -v port[ftp] ]] && echo yes || echo "no ftp"
no ftp
String length and substrings
The same ${...} syntax works on ordinary variables. ${#var} is the length, and ${var:offset:length} extracts part of it:
$ s="Hello, World"
$ echo "${#s}"
12
$ echo "${s:0:5}"
Hello
$ echo "${s:7}"
World
$ echo "${s: -5}"
World
The space before -5 matters. Without it, ${s:-5} is the "default value" expansion described below, not a substring.
Removing prefixes and suffixes
Four operators strip a pattern from the start or end of a value. One symbol removes the shortest match; doubling it removes the longest:
| Expansion | Removes | Result for backup.2026-10-25.tar.gz |
|---|---|---|
${file%.gz} | Shortest match from the end | backup.2026-10-25.tar |
${file%%.*} | Longest match from the end | backup |
${file#*.} | Shortest match from the start | 2026-10-25.tar.gz |
${file##*.} | Longest match from the start | gz |
A good way to remember: # is to the left of $ on a US keyboard and trims the left; % is to the right and trims the right. The classic uses are getting a file name or directory from a path without calling external commands:
$ path="/var/log/nginx/access.log"
$ echo "${path##*/}"
access.log
$ echo "${path%/*}"
/var/log/nginx
Those give the same output as basename and dirname, which is noticeably faster inside a loop over thousands of files. Renaming extensions works the same way: mv -- "$f" "${f%.jpeg}.jpg".
Search and replace
${var/pattern/replacement} replaces the first match and ${var//pattern/replacement} replaces all of them:
$ t="one two one two"
$ echo "${t/one/1}"
1 two one two
$ echo "${t//one/1}"
1 two 1 two
$ str="a-b-c"; echo "${str//-/ }"
a b c
Applied to an array, the replacement runs on every element. Anchoring with # adds a prefix to each one:
$ a=(x y z); echo "${a[@]/#/pre-}"
pre-x pre-y pre-z
Changing case
$ name="alice"
$ echo "${name^}" "${name^^}"
Alice ALICE
$ N="ALICE"; echo "${N,,}"
alice
One caret uppercases the first character, two uppercase everything; commas do the reverse. This is useful for case-insensitive comparisons, such as checking a yes/no answer with [[ ${ans,,} == y* ]].
Default values
$ echo "${unset_var:-default}"
default
$ echo "${unset_var2:=assigned}"; echo "$unset_var2"
assigned
assigned
:- substitutes a value when the variable is unset or empty, without changing it. := also assigns it. In scripts, ${1:-.} is a tidy way to give a positional argument a default.
Pattern and regex matching
Inside [[ ]], == matches a glob pattern and =~ matches an extended regular expression. Capture groups land in BASH_REMATCH:
$ file="backup.2026-10-25.tar.gz"
$ if [[ $file == *.tar.gz ]]; then echo "gzip tarball"; fi
gzip tarball
$ if [[ "2026-10-25" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then
> echo "year ${BASH_REMATCH[1]} month ${BASH_REMATCH[2]}"
> fi
year 2026 month 10
Do not quote the pattern on the right-hand side. Quoting turns it into a literal string match. If the regex is long, keep it in a variable and write [[ $s =~ $re ]].
Quick reference
| Goal | Syntax |
|---|---|
| All elements, safely | "${a[@]}" |
| Number of elements | ${#a[@]} |
| Indexes or keys | ${!a[@]} |
| Append | a+=(item) |
| File into array | mapfile -t a < file |
| String length | ${#s} |
| Substring | ${s:offset:length} |
| File name from path | ${p##*/} |
| Drop extension | ${f%.*} |
| Replace all | ${s//old/new} |
| Upper or lower case | ${s^^} / ${s,,} |