Linux Basic — Case Conversion & Splitting

3 minute read

Case Conversion & Splitting

Two of the most common string chores: change case, and split a string on a delimiter. Bash 4+ handles both in pure parameter expansion plus the read builtin — no fork, no external tools.


Upper / Lower / Toggle

Form Effect
${var^^} every char to UPPER
${var,,} every char to lower
${var^} first char to UPPER
${var,} first char to lower
${var^^[abc]} uppercase only matches of pattern
${var,,[ABC]} lowercase only matches
name="alice"
echo "${name^^}"          # ALICE
echo "${name^}"           # Alice

shout="HELLO WORLD"
echo "${shout,,}"         # hello world
echo "${shout,}"          # hELLO WORLD

Targeted case change:

s="hello world"
echo "${s^^[w]}"          # hello World     (only w → W)
echo "${s,,[H]}"          # hello world     (lowercase H if present)

Title Case (Not Built-In — Easy Compose)

There’s no ${var~} title-case operator, but the prefix-only ^ makes it a one-liner per word:

title() {
    local out=""
    for word in $1; do
        out+="${word^} "
    done
    echo "${out% }"
}

title "the quick brown fox"
# The Quick Brown Fox

Case-Insensitive Comparison

The cleanest pattern is “normalise both sides”:

a="Hello"
b="hello"

[[ "${a,,}" == "${b,,}" ]] && echo equal     # equal

Or use Bash’s shopt -s nocasematch for the rest of the block:

shopt -s nocasematch
[[ "Hello" == "hello" ]] && echo equal
shopt -u nocasematch

The ,,/^^ approach is usually safer — no global state to remember.


Splitting a String on a Delimiter — IFS=… read -ra

line="alice,42,admin"
IFS=, read -ra parts <<< "$line"

echo "${parts[0]}"        # alice
echo "${parts[1]}"        # 42
echo "${parts[2]}"        # admin
echo "${#parts[@]}"       # 3

Anatomy:

  • IFS=, — set the input field separator for this command only (no save/restore needed)
  • read -ra parts — read fields into array parts
  • <<< "$line" — feed the variable as stdin (here-string)

Multiple delimiters at once? Put them all in IFS:

line="a:b/c d"
IFS=':/ ' read -ra parts <<< "$line"
printf '[%s]\n' "${parts[@]}"
# [a]
# [b]
# [c]
# [d]

Bash treats IFS as a set of separator chars, not a multi-char string. So IFS='->' means “split on - OR >”, not on the literal ->.

For multi-char delimiters, use a different technique:

line="apple<->banana<->cherry"
arr=()
while [[ "$line" == *"<->"* ]]; do
    arr+=("${line%%<->*}")        # everything before the first delimiter
    line="${line#*<->}"           # everything after it
done
arr+=("$line")
printf '[%s]\n' "${arr[@]}"
# [apple]
# [banana]
# [cherry]

Joining an Array With a Delimiter

arr=(alice bob carol)

# Using IFS for one expansion
(IFS=,; echo "${arr[*]}")
# alice,bob,carol

"${arr[*]}" joins elements using the first char of IFS. Wrapping the assignment in () runs it in a subshell so IFS isn’t polluted.

A reusable function:

join_by() {
    local sep=$1
    shift
    local IFS=$sep
    echo "$*"
}

join_by , alice bob carol
# alice,bob,carol

join_by ' | ' first second third
# first | second | third       (multi-char OK because $* uses sep AS-IS, not as a char set)

Wait — that last one really does work! When joining via "$*", IFS is used as a literal separator string, not as a char set. Splitting and joining have asymmetric IFS semantics. Useful to know.


IFS Scope — Always Local

Setting IFS permanently is a great way to break unrelated code. Two safe idioms:

# 1. Per-command (the `IFS=… cmd` form)
IFS=, read -ra parts <<< "$csv"

# 2. Subshell isolation
(IFS=,; echo "${arr[*]}")

# 3. Save / restore
old_ifs=$IFS
IFS=,
# ... use $IFS ...
IFS=$old_ifs

The first form (per-command) is the cleanest — IFS exists only for that one read.


Reading Stdin into an Array (One Line)

Use mapfile for full files (covered in Reading Files). For a single delimited line that’s already a string:

csv="apple,banana,cherry"
IFS=, read -ra fruits <<< "$csv"
echo "${fruits[1]}"
# banana

For null-delimited records (the safest for filenames):

mapfile -d '' -t files < <(find . -type f -print0)
echo "${#files[@]}"

-d '' uses a NUL byte as the delimiter — impossible for any filename to contain.


A Real-World Example — Parse key=value Config

config=(
    "host=example.com"
    "port=8080"
    "debug=true"
)

declare -A cfg
for entry in "${config[@]}"; do
    IFS='=' read -r key value <<< "$entry"
    cfg["$key"]="$value"
done

echo "${cfg[host]}:${cfg[port]}"
# example.com:8080

Native parsing, no cut, no awk.


Common Mistakes

❌ Wrong ✅ Right Issue
IFS=, ; read -a parts <<< "$line" IFS=, read -ra parts <<< "$line" first form leaves IFS polluted for the rest of the script
IFS='->' for multi-char split use ${line%%->*} loop IFS is a char-set, not a string
echo "${arr[*]}" for join with , (IFS=', '; echo "${arr[*]}") and remember it’s joined by IFS as-is via $* order of operations matters
${var^^A} to upper-case only a the pattern is matched against the target chars, so use ${var^^[aA]} for both case matching is literal
tr a-z A-Z in tight loop "${var^^}" one fork per call

Next → Practice Exercises

Updated: