Bash Arrays and String Manipulation: A Practical Guide

Scripting · 8 min read · Updated 25 Oct 2026

On this page
  1. Indexed arrays
  2. Looping over an array
  3. Filling arrays from files and commands
  4. Associative arrays
  5. String length and substrings
  6. Removing prefixes and suffixes
  7. Search and replace
  8. Changing case
  9. Default values
  10. Pattern and regex matching
  11. Quick reference

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:

ExpansionRemovesResult for backup.2026-10-25.tar.gz
${file%.gz}Shortest match from the endbackup.2026-10-25.tar
${file%%.*}Longest match from the endbackup
${file#*.}Shortest match from the start2026-10-25.tar.gz
${file##*.}Longest match from the startgz

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 ]].

Everything on this page is described in the Shell Parameter Expansion section of the Bash manual. For more ready-to-paste snippets, see the cheat sheet.

Quick reference

GoalSyntax
All elements, safely"${a[@]}"
Number of elements${#a[@]}
Indexes or keys${!a[@]}
Appenda+=(item)
File into arraymapfile -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,,}