๐ŸŽ New User? Get 20% off your first purchase with code NEWUSER20 ยท โšก Instant download ยท ๐Ÿ”’ Secure checkout Register Now โ†’
Menu

Categories

Bash Parameter Expansion: Substring, Replace, Default Values

Bash Parameter Expansion: Substring, Replace, Default Values

A sysadmin is parsing ten thousand log filenames in a loop and needs to strip the extension, extract a date substring, and provide a default value if a variable is unset, all without calling sed or awk in every iteration. Bash parameter expansion does this work in pure Bash, faster and cleaner. Every time you write ${var##*.} instead of spawning basename, you save a fork and exec; in a tight loop that compounds. Parameter expansion is built into the shell, runs in the current process, and handles the string manipulation tasks you reach for external tools to solve.

This guide organizes parameter expansion by task: substring extraction, search and replace, prefix and suffix removal, default values, and more. Each pattern includes the exact syntax, what it does, and when you would use it instead of an external command. The examples come from config parsing, filename manipulation, and validation scripts you write every week.

Why Parameter Expansion Matters in Production Scripts

Parameter expansion runs in the shell process. External tools like sed, awk, cut and basename require a fork and exec for every invocation. In a loop processing thousands of files, that overhead becomes the bottleneck. A backup script that iterates over 50,000 log files and calls basename once per file spawns 50,000 processes; the same script using ${file##*/} spawns zero.

Parameter expansion is portable. Every POSIX-compliant shell supports the core patterns; Bash adds more. You do not depend on GNU coreutils or specific versions of sed. The syntax is terse but consistent: once you learn that # strips from the left and % strips from the right, the patterns stick.

Compare a loop that extracts filenames:

for path in /var/log/app/*.log; do
  name=$(basename "$path" .log)  # forks basename
  echo "$name"
done

versus:

for path in /var/log/app/*.log; do
  name=${path##*/}      # strip directory
  name=${name%.log}     # strip extension
  echo "$name"
done

The second version is faster, more readable once you know the operators, and works anywhere Bash runs. For scripts that parse filenames, URLs, or config values in bulk, parameter expansion is the right tool.

Substring Extraction by Position

The syntax ${var:offset:length} extracts a substring starting at offset (zero-indexed) for length characters. If you omit length, Bash takes everything from offset to the end. This is useful when log entries or filenames have fixed-width fields and you want a specific piece without calling cut.

Extracting from the Beginning

To grab the first N characters, set offset to 0:

filename="20260315_backup.tar.gz"
date=${filename:0:8}
echo "$date"  # 20260315

Log files often start with a timestamp. Extracting ${filename:0:8} pulls YYYYMMDD in one operation, no pipeline required. You can then use that substring to sort, filter, or build a destination path.

Extracting from an Offset

Start at a position other than zero to skip leading characters:

line="2026-03-15 14:32:10 INFO User login"
time=${line:11:8}
echo "$time"  # 14:32:10

The timestamp starts at index 11 (after YYYY-MM-DD ). ${line:11:8} extracts the next 8 characters. This pattern replaces cut -c12-19 and runs in the shell process. If your logs have fixed column widths, substring extraction by position is faster and simpler than regex or field splitting.

Negative Offsets (Bash 4.2+)

Bash 4.2 introduced negative offsets to count from the end. You must include a space before the minus sign to distinguish it from the default-value syntax:

path="/var/log/app/server.log"
ext=${path: -3}
echo "$ext"  # log

Without the space, ${path:-3} means "use path if set, otherwise use 3". With the space, ${path: -3} extracts the last three characters. This works on any string where you want the tail: the last five digits of an ID, the final segment of a version string, or the extension of a filename when you know its length.

Search and Replace Patterns

Parameter expansion can replace substrings without invoking sed. The basic form is ${var/pattern/replacement}, which replaces the first match of pattern with replacement. The pattern is a glob, not a regex: *, ?, [...] work, but \d or + do not. For more complex transformations see the Bash manual's parameter expansion section, but the glob patterns handle most filename and URL manipulation.

Replace First Match

Use ${var/pattern/replacement} to replace only the first occurrence:

url="http://example.com/http://mirror.example.com"
fixed=${url/http:/https:}
echo "$fixed"  # https://example.com/http://mirror.example.com

Only the first http: became https:. This is useful when you know the pattern appears once or you want to preserve subsequent matches. To delete instead of replace, omit the replacement string:

path="/var/log/app.log"
short=${path/\/var/}
echo "$short"  # /log/app.log

The slash in the pattern must be escaped as \/ because / is the delimiter. Deleting a substring is the same as replacing it with nothing.

Replace All Matches

Double the leading slash to replace every occurrence: ${var//pattern/replacement}. This is the equivalent of sed 's/pattern/replacement/g':

path="/var/log/app/data"
sanitized=${path//\//-}
echo "$sanitized"  # -var-log-app-data

Every slash became a dash. Use this to sanitize filenames for systems that disallow certain characters, or to normalize data before writing it to a config file. Another common pattern strips all non-alphanumeric characters:

input="User@Name#123"
clean=${input//[^a-zA-Z0-9]/}
echo "$clean"  # UserName123

The glob [^a-zA-Z0-9] matches any character outside that set; replacing it with nothing removes punctuation and whitespace in one pass.

Anchored Patterns (Prefix and Suffix)

Anchor the pattern to the start or end of the string with # (prefix) or % (suffix). ${var/#prefix/replacement} replaces prefix only if it appears at the beginning; ${var/%suffix/replacement} only at the end:

url="http://example.com"
secure=${url/#http:/https:}
echo "$secure"  # https://example.com

filename="backup.tar.gz"
renamed=${filename/%.gz/.bz2}
echo "$renamed"  # backup.tar.bz2

Anchored replacement is safer when the pattern might appear elsewhere. Upgrading http: to https: with /# ensures you do not accidentally replace http: in a query parameter. Similarly, /% lets you change a file extension without touching a directory name that happens to contain the same string.

Prefix and Suffix Removal

Prefix and suffix removal strips characters from the left or right edge of a string using glob patterns. # removes from the left (prefix), % from the right (suffix). A single operator removes the shortest match, a double operator removes the longest. This is the standard way to extract basenames, strip extensions, or remove directory paths without calling basename, dirname, or cut.

Shortest Match Removal (# and %)

Use ${var#pattern} to remove the shortest match of pattern from the beginning of var. Use ${var%pattern} to remove the shortest match from the end:

filename="archive.tar.gz"
no_ext=${filename%.*}
echo "$no_ext"  # archive.tar

The pattern .* matches a dot followed by anything. % finds the shortest match from the right, so it stops at the last dot and removes .gz. This is what you want when stripping a single extension. To remove a prefix:

path="/var/log/app.log"
relative=${path#/var/}
echo "$relative"  # log/app.log

# removes /var/ from the start. The shortest match stops as soon as the pattern is satisfied, so if the path were /var/log/var/app.log, only the first /var/ would be removed.

Longest Match Removal (## and %%)

Double the operator to remove the longest match. ## is greedy from the left, %% from the right. The canonical example is ${path##*/}, which strips everything up to and including the last slash, leaving only the filename:

path="/var/log/app/server.log"
basename=${path##*/}
echo "$basename"  # server.log

This replaces basename "$path" and works on any string with slashes. For extensions, %% strips all of them:

filename="archive.tar.gz"
no_ext=${filename%%.*}
echo "$no_ext"  # archive

The pattern .* matches a dot and everything after. %% finds the longest match from the right, so it removes .tar.gz entirely. Use %% when you want the base name without any extension, regardless of how many dots appear. To extract a directory path, use ${path%/*}, which removes the last slash and everything after:

path="/var/log/app/server.log"
dirname=${path%/*}
echo "$dirname"  # /var/log/app

This is dirname without the fork. If the path has no slash, the result is the entire string; add a conditional check if you need to handle that case.

Real-World Filename and Path Manipulation

Log rotation scripts often copy a file and add a timestamp before the extension. Prefix and suffix removal handles this cleanly:

for f in *.log; do
  cp "$f" "${f%.log}.$(date +%s).log"
done

${f%.log} strips .log, the timestamp is appended, then .log is added back. The result is app.log becomes app.1710518400.log. This pattern is faster than calling basename in a loop and easier to read than a sed substitution. For multi-part extensions, choose % or %% based on what you want to keep:

archive="backup.2026-03-15.tar.gz"
echo "${archive%.*}"    # backup.2026-03-15.tar
echo "${archive%%.*}"   # backup

If you need to preserve .tar and only remove .gz, use %. If you want the base name with no extensions at all, use %%. The same logic applies to URLs: ${url##*/} extracts the final path component, ${url%%\?*} strips query parameters.

Default, Alternate and Error Values

Default value patterns let you handle unset or empty variables without an explicit if block. These are essential in scripts that read environment variables or accept optional arguments. The syntax is compact and runs in the shell process, so there is no overhead. Four patterns cover the common cases: use a default, assign a default, error on missing value, or substitute an alternate.

Use Default if Unset

${var:-default} returns default if var is unset or empty. It does not modify var:

LOGDIR=${LOGDIR:-/var/log/myapp}
echo "$LOGDIR"  # /var/log/myapp if LOGDIR was unset or empty

This is the standard way to provide fallback values for configuration variables. The script works without LOGDIR in the environment, but respects it if set. Use :- in assignment statements at the top of a script to establish defaults for paths, ports, timeouts, or any parameter that has a sensible fallback. The variant ${var-default} (no colon) only checks if var is unset, not if it is empty; an empty string is treated as a valid value. Prefer :- unless you specifically want to allow empty strings.

Assign Default if Unset

${var:=default} assigns default to var if var is unset or empty, then returns the value:

: ${TMPDIR:=/tmp}
echo "$TMPDIR"  # /tmp if it was unset; now TMPDIR is set

The leading : is a no-op command that evaluates the expansion without doing anything with the result. After this line, TMPDIR is guaranteed to be set. Use := when you want to normalize variables for the rest of the script: if the user did not provide TMPDIR, the script sets it and every subsequent reference uses that value. This pattern is less common than :- because modifying variables can surprise a caller, but it is useful in scripts that source other scripts or export variables for child processes.

Error if Unset

${var:?error message} prints error message to stderr and exits with status 1 if var is unset or empty. Use this for required parameters:

: ${DATABASE:?DATABASE variable must be set}
: ${API_KEY:?API_KEY variable must be set}
: ${REGION:?REGION variable must be set}

Put these checks at the top of a script that depends on environment variables. If a required variable is missing, the script exits immediately with a clear error message. This is better than letting the script run and fail later with a cryptic error when it tries to connect to an undefined database. The :? pattern makes your script's requirements explicit and easy to audit. For more on validating inputs and handling errors, see Top 10 Bash Scripting Best Practices for Production in 2026.

Alternate Value if Set

${var:+alternate} returns alternate only if var is set and non-empty. If var is unset or empty, the expansion is empty. This is less common but useful for conditional flags:

VERBOSE=1
flags="-q ${VERBOSE:+-v}"
echo "$flags"  # -q -v

If VERBOSE is set, -v is added to flags. If VERBOSE is unset or empty, the expansion produces nothing and flags is just -q. Use this to build command lines where an option appears only if a corresponding variable is set. It avoids an if block for simple toggles.

Length, Case Conversion and Indirection

Three more parameter expansion features handle string length, case conversion, and variable indirection. ${#var} returns the number of characters in var. Case conversion operators change capitalization without calling tr or awk. Indirection lets you dereference a variable whose name is stored in another variable, useful in loops over numbered or prefixed variables.

String Length

${#var} returns the length of var in characters:

password="secret123"
if [ ${#password} -lt 8 ]; then
  echo "Password must be at least 8 characters"
  exit 1
fi

This is faster than piping to wc -m and cleaner than iterating over the string. Use it to validate input lengths, check if a string is empty ([ ${#var} -eq 0 ]), or enforce limits before writing to a database. For arrays, ${#array[@]} returns the number of elements; ${#array[i]} returns the length of the string at index i.

Case Conversion (Bash 4+)

Bash 4 introduced operators to change case without calling tr. ${var^} capitalizes the first character, ${var^^} uppercases the entire string. ${var,} lowercases the first character, ${var,,} lowercases the entire string:

name="john doe"
echo "${name^}"    # John doe
echo "${name^^}"   # JOHN DOE

env="PRODUCTION"
echo "${env,}"     # pRODUCTION
echo "${env,,}"    # production

Use ^^ and ,, to normalize user input for case-insensitive comparisons or to format output. This is faster than a pipeline to tr '[:lower:]' '[:upper:]' and works on any Bash 4+ system. If you need to support Bash 3, fall back to tr or awk.

Variable Indirection

${!var} dereferences var: it uses the value of var as the name of another variable and returns that variable's value. This is useful when you have numbered or prefixed variables and want to iterate over them:

HOST1="web01.example.com"
HOST2="web02.example.com"
HOST3="web03.example.com"

for i in {1..3}; do
  varname="HOST$i"
  echo "Pinging ${!varname}"
  ping -c1 "${!varname}" > /dev/null && echo "  up" || echo "  down"
done

varname holds the string HOST1, HOST2, or HOST3. ${!varname} retrieves the value of that variable. Without indirection, you would need eval, which is harder to quote correctly and less safe. Use indirection to loop over configuration sets, read variables from a naming convention, or build dynamic variable names in scripts that generate other scripts.

Array Expansion Patterns

Arrays in Bash store multiple values in a single variable. Parameter expansion on arrays lets you access all elements, slice ranges, and retrieve indices without iterating manually. This is essential when processing argument lists, file collections, or any data set where you need to split, batch, or inspect elements programmatically.

Expanding Array Elements

${array[@]} expands to all elements as separate words; ${array[*]} expands all elements as a single word separated by the first character of IFS (usually a space). Always quote "${array[@]}" to preserve elements that contain spaces:

files=("app.log" "error log.txt" "access.log")
for f in "${files[@]}"; do
  echo "Processing: $f"
done

Without quotes, error log.txt would split into two arguments. "${array[@]}" ensures each element is treated as a distinct word, even if it contains whitespace. Use ${array[*]} when you want to join elements into a single string, for example to build a comma-separated list: IFS=,; echo "${files[*]}" produces app.log,error log.txt,access.log.

Array Slicing

${array[@]:offset:count} extracts count elements starting at offset (zero-indexed). This is useful for processing arguments in batches or skipping the first N elements:

args=("$@")
main_args=("${args[@]:0:5}")
rest=("${args[@]:5}")
echo "First five: ${main_args[*]}"
echo "Remaining: ${rest[*]}"

If the script receives ten arguments, main_args gets the first five, rest gets the last five. You can also use this on $@ directly: "${@:2:3}" takes arguments 2, 3, and 4. Omit count to take everything from offset to the end. This pattern replaces loops that manually copy subsets of an array.

Keys and Indices

${#array[@]} returns the number of elements in the array. ${!array[@]} expands to the list of indices (for indexed arrays) or keys (for associative arrays):

declare -A config
config[host]="db.example.com"
config[port]="5432"
config[user]="admin"

for key in "${!config[@]}"; do
  echo "$key: ${config[$key]}"
done

This iterates over the keys of an associative array. For indexed arrays, ${!array[@]} returns the indices, which is the same as a numeric sequence if the array is dense, but useful if you have deleted elements and want to see which indices remain. Use ${#array[@]} to check if an array is empty or to allocate resources based on the number of elements.

Common Pitfalls and Edge Cases

Parameter expansion is powerful but has traps. Quoting, the difference between :- and -, and the fact that patterns are globs, not regex, are the most common sources of bugs. Understanding these edge cases makes your scripts robust and saves debugging time when a substitution does not behave as expected.

Quoting and Word Splitting

Always quote parameter expansions that might contain spaces or special characters. Unquoted ${var} undergoes word splitting on IFS and pathname expansion:

filename="my file.txt"
rm ${filename}  # wrong: tries to remove "my" and "file.txt"
rm "${filename}"  # correct

Without quotes, my file.txt splits into two words and rm sees two arguments. Quote "${var}" unless you specifically want splitting. This applies to all expansions: "${var:-default}", "${var##*/}", "${array[@]}". The only exception is when you assign directly to a variable (x=${var}), where splitting does not occur, but quoting is still safer for consistency.

Colon Versus No Colon

:- checks if a variable is unset or empty; - (no colon) only checks if it is unset. An empty string is treated differently:

VAR=""
echo "${VAR:-default}"  # default (VAR is empty)
echo "${VAR-default}"   # (empty string; VAR is set)

If VAR is set to an empty string, :- uses the default but - does not. The same distinction applies to := versus =, :? versus ?, and :+ versus +. In most scripts, you want the colon form to treat empty and unset the same way. Use the no-colon form only when an empty string is a valid value distinct from unset.

Glob Patterns, Not Regex

Patterns in ${var/pattern/replacement}, ${var#pattern}, and ${var%pattern} are globs: * matches zero or more characters, ? matches one character, [abc] is a character class. You cannot use regex metacharacters like \d, \w, +, or |:

file="report_2026.txt"
echo "${file/[0-9]+/YEAR}"  # wrong: + is literal, not "one or more"
echo "${file/[0-9]*/YEAR}"  # correct: * is zero or more

The pattern [0-9]+ looks for a digit followed by a literal plus sign. Use [0-9]* to match a sequence of digits. If you need real regex, call sed or use Bash's [[ $var =~ regex ]] test, but that does not perform substitution. For complex transformations, parameter expansion has limits; recognize when an external tool is the better choice.

Greedy Matching Edge Cases

The % and %% operators strip from the right, but the pattern is matched against the entire string. If you write ${file%.txt} and the filename is file.txt.backup, only .backup is removed because .* matches .backup, not .txt.backup:

file="config.txt.backup"
echo "${file%.txt}"  # config.txt.backup (no match at the end)
echo "${file%.*}"    # config.txt (removes .backup)

To strip .txt anywhere, use ${file//.txt/} (replace all). To strip everything after the first dot, use ${file%%.*}. Choose the operator and pattern based on where the substring appears and how greedy you want the match. Test edge cases: filenames with multiple dots, paths with no slashes, variables that might be empty.

Parameter Expansion Quick Reference

Parameter expansion replaces external tools in loops, normalizes configuration, and makes scripts faster. Use ${var:offset:length} for fixed-width fields, ${var//pattern/replacement} for global substitution, # and % for prefix and suffix removal, :- for defaults. Quote expansions to prevent word splitting. Patterns are globs, not regex. Arrays expand with [@] and slice with [@]:offset:count.

When you reach for sed, awk, cut, or basename in a loop, check if parameter expansion can do the job. The syntax is terse but consistent: once you internalize the operators, reading and writing them is faster than constructing a pipeline. For scripts that process thousands of files, the performance difference is measurable. For scripts that run once, the benefit is portability and fewer dependencies.

Parameter expansion is one part of effective shell scripting. For a broader view of writing maintainable, production-ready Bash, see Bash Mastery 2026, which covers functions, error handling, testing, and the patterns that separate a quick hack from a script you can deploy with confidence.

Frequently Asked Questions

What is the difference between ${var:-default} and ${var:=default}?

${var:-default} returns default if var is unset or empty, but does not modify var. ${var:=default} assigns default to var if it is unset or empty, then returns the value. Use :- for inline defaults; use := when you want to set the variable for the rest of the script. Both check for unset and empty; omit the colon to check only unset.

How do I remove a file extension with parameter expansion?

Use ${filename%.*} to remove the last extension (everything from the last dot to the end). For archive.tar.gz, this gives archive.tar. Use ${filename%%.*} to remove all extensions, leaving archive. The % operator strips the shortest match from the right; %% strips the longest match. Choose based on whether you want to remove one extension or all of them.

Can I use regex in ${var/pattern/replacement}?

No. The pattern in ${var/pattern/replacement} is a glob, not a regex. * matches zero or more characters, ? matches one, [abc] is a character class. Regex metacharacters like \d, +, |, or () do not work. For regex substitution, use sed or match with [[ $var =~ regex ]] and reconstruct the string with ${BASH_REMATCH[@]}.

Why do I need a space in ${var: -5} for negative offsets?

Without the space, ${var:-5} is parsed as the default-value syntax: "use var if set, otherwise use 5". With the space, ${var: -5} is substring extraction starting 5 characters from the end. Bash 4.2+ supports negative offsets, but you must include the space before the minus to distinguish it from :-. This is a quirk of the syntax; always add the space for negative positions.

How do I convert a string to uppercase in Bash?

Use ${var^^} to convert the entire string to uppercase (Bash 4+). ${var^} capitalizes only the first character. For lowercase, use ${var,,} (entire string) or ${var,} (first character). On Bash 3, use tr '[:lower:]' '[:upper:]' instead: upper=$(echo "$var" | tr '[:lower:]' '[:upper:]'). The ^^ and ,, operators are faster because they run in the shell process.

Share this article:
Dorian Thorne
About the Author

Dorian Thorne

Cloud Infrastructure, Cloud Architecture, Infrastructure Automation, Technical Documentation

Dorian Thorne is a cloud infrastructure specialist and technical author focused on the design, deployment, and operation of scalable cloud-based systems.

He has extensive experience working with cloud platforms and modern infrastructure practices, including virtualized environments, cloud networking, identity and acces...

Cloud Computing Cloud Networking Identity and Access Management Infrastructure as Code System Reliability

Stay Updated

Subscribe to our newsletter for the latest tutorials, tips, and exclusive offers.