| |

Bash Scripting 09 ๐Ÿง String Manipulation โ€” Substring extraction, replacement, and length

โฑ๏ธ Estimated read: 11 min | |

Bash treats every variable as a string. Numbers, filenames, configuration values, and command output are all stored as sequences of characters. The operations that inspect and transform those sequencesโ€”measuring length, extracting a portion, replacing a patternโ€”are performed through parameter expansion, the same ${...} syntax that handles variable substitution. This is not a separate library or an external tool. It is built into the shell, and it executes without spawning a process.

The previous chapters covered arithmetic and conditional logic. String manipulation is the complement to those tools: arithmetic computes with numbers, and string operations manipulate the text that scripts read from files, arguments, and command output. The syntax is dense, but it follows a consistent pattern. The variable name comes first, followed by an operator that determines the operation, and optionally a pattern or offset that determines the specific result.

Key point: Bash string operations use parameter expansion, not external commands. ${#var} returns length, ${var:offset:length} extracts a substring, and ${var/pattern/replacement} replaces text. The substring offset is zero-based for ordinary variables but one-based for positional parameters, and negative offsets require a space after the colon to distinguish them from the default-value operator. Pattern removal with # and % is often more useful for filenames than substring extraction because it matches the structure of the string rather than a fixed position.


Why parameter expansion replaces external tools

The process-spawning problem.

A script that calls sed, awk, or cut for every string operation pays a process-creation cost each time. On a loop that processes thousands of filenames, that cost dominates the runtime. Parameter expansion performs the same operations inside the shell process, with no fork and no exec. The syntax is less readable than a sed command, but the performance difference is measurable in any script that operates on more than a handful of strings.

The quoting problem.

External tools require the string to be passed as an argument or piped through stdin, which introduces quoting and escaping issues. Parameter expansion operates directly on the variable’s value, without any intermediate serialization. The string does not need to be quoted a second time, and special characters in the value do not need to be escaped for the shell to parse them correctly. The expansion produces the result directly, and the result can be assigned to another variable or used in a command.

The positional-parameter problem.

Bash provides ${var:offset:length} for substrings and ${var#pattern} / ${var%pattern} for pattern removal. The pattern-removal operators are often more appropriate for filenames and paths because they match the structure of the string. ${path##*/} extracts the basename by removing everything up to the last slash, regardless of how many directories the path contains. ${file%.*} removes the extension by matching the last dot and everything after it. These operations express intent more clearly than counting characters to a fixed offset.


a. String length and substring extraction

The ${#var} expansion returns the number of characters in the variable’s value. This is the simplest operation and the foundation for validation and loop bounds.

#!/usr/bin/env bash
# Length of a string
name="deploy-production-v2"
echo "${#name}"              # 21

# Validation: reject empty values
if [ "${#name}" -eq 0 ]; then
    echo "ERROR: name cannot be empty" >&2
    exit 1
fi

The length operator works on any variable, including positional parameters (${#1}) and arrays (${#array[@]} for the number of elements). It returns a number, so it can be used directly in arithmetic comparisons.

Substring extraction uses ${var:offset:length}. The offset is the starting position, and the length is the number of characters to extract. If the length is omitted, the extraction continues to the end of the string .

# Substring extraction
full="us-east-1-web-prod-01"

echo "${full:0:9}"           # us-east-1     (position 0, 9 chars)
echo "${full:10}"            # web-prod-01   (position 10 to end)
echo "${full: -8}"           # prod-01       (last 8 chars; note the space)
echo "${full: -8:4}"         # prod          (last 8 chars, take 4)

The space before the negative offset is mandatory. Without it, ${full:-8} is interpreted as the default-value operator, which returns the value of full if it is set, or 8 if it is not. The space disambiguates the negative offset from the default-value syntax .

For positional parameters, the indexing is one-based by default. ${@:2} starts from the second argument, not the third. This is a deliberate inconsistency that reflects the convention that $1 is the first argument .


b. Pattern replacement

The replacement syntax is ${var/pattern/replacement} for the first match and ${var//pattern/replacement} for all matches. The pattern is a glob, not a regular expression, and the replacement is a literal string .

#!/usr/bin/env bash
# Replace first occurrence
version="app-v1.2.3-beta"
echo "${version/beta/stable}"      # app-v1.2.3-stable

# Replace all occurrences
echo "${version//-/_}"             # app_v1.2.3_beta

# Anchor to start with #
path="/old/path/to/file"
echo "${path/#\/old/\/new}"        # /new/path/to/file

# Anchor to end with %
filename="deploy.sh.bak"
echo "${filename/%.bak/}"          # deploy.sh

The # anchor matches only at the beginning of the string, and the % anchor matches only at the end . This is distinct from the # and % removal operators, which delete the matching portion rather than replacing it. The replacement syntax with an anchor is useful when the same pattern could appear elsewhere in the string and should not be modified in those positions.

If the replacement string is empty, the pattern matches are deleted. ${filename/%.bak/} removes the .bak suffix by replacing it with nothing.


c. Prefix and suffix removal

The removal operators are the most commonly used string operations in scripts that handle filenames and paths. ${var#pattern} removes the shortest matching prefix, and ${var##pattern} removes the longest. ${var%pattern} removes the shortest matching suffix, and ${var%%pattern} removes the longest .

#!/usr/bin/env bash
# Prefix removal
filepath="/home/user/documents/file.txt"

echo "${filepath#*/}"        # home/user/documents/file.txt  (shortest prefix)
echo "${filepath##*/}"       # file.txt                      (longest prefix; basename)

# Suffix removal
filename="document.old.txt"

echo "${filename%.*}"        # document.old                  (shortest suffix; last extension)
echo "${filename%%.*}"       # document                      (longest suffix; all extensions)

The ${filepath##*/} idiom is the standard way to extract a basename without calling basename. The ## removes the longest prefix matching */, which is everything up to and including the last slash. The ${filename%.*} idiom removes the last extension by matching the shortest suffix that starts with a dot .

The same operators work with variables containing patterns. ${var#$prefix} removes the value of $prefix from the beginning of $var, which is useful when the prefix is stored in a separate variable.


Complete Example Session

#!/usr/bin/env bash
# ============================================
# PART 1: LENGTH VALIDATION
# ============================================
input="config.yaml"
if [ "${#input}" -eq 0 ]; then
    echo "Empty input" >&2
    exit 1
fi
echo "Length: ${#input}"      # 11

# ============================================
# PART 2: SUBSTRING FOR PREFIX EXTRACTION
# ============================================
timestamp="20250101-143022"
date_part="${timestamp:0:8}"  # 20250101
time_part="${timestamp:9:6}"  # 143022
echo "Date: $date_part, Time: $time_part"

# ============================================
# PART 3: NEGATIVE OFFSET FOR SUFFIX
# ============================================
logfile="application-error.log"
extension="${logfile: -3}"    # log (last 3 chars)
echo "Extension: $extension"

# ============================================
# PART 4: REPLACE FORMATTING
# ============================================
raw="2025_01_01"
formatted="${raw//_/-}"       # 2025-01-01
echo "Formatted: $formatted"

# ============================================
# PART 5: ANCHORED REPLACEMENT
# ============================================
url="http://example.com/api"
secure="${url/#http:/https:}" # https://example.com/api
echo "Secure: $secure"

# ============================================
# PART 6: BASENAME EXTRACTION
# ============================================
fullpath="/var/log/nginx/access.log"
basename="${fullpath##*/}"    # access.log
echo "Basename: $basename"

# ============================================
# PART 7: DIRECTORY EXTRACTION
# ============================================
dirpath="${fullpath%/*}"      # /var/log/nginx
echo "Directory: $dirpath"

# ============================================
# PART 8: EXTENSION REMOVAL
# ============================================
filename="archive.tar.gz"
no_ext="${filename%%.*}"      # archive
only_last="${filename%.*}"    # archive.tar
echo "No extension: $no_ext, Last removed: $only_last"

# ============================================
# PART 9: COMBINED OPERATIONS
# ============================================
# Remove directory, then remove extension
clean="${fullpath##*/}"       # access.log
stem="${clean%.*}"            # access
echo "Stem: $stem"

# ============================================
# PART 10: CASE CONVERSION (BASH 4+)
# ============================================
name="deploy-prod"
upper="${name^^}"             # DEPLOY-PROD
lower="${upper,,}"            # deploy-prod
echo "Upper: $upper, Lower: $lower"

The ten parts demonstrate the operations in the order a script would use them: length for validation, substring for fixed-position extraction, negative offset for suffix, replacement for formatting, anchored replacement for URL schemes, prefix and suffix removal for paths, and case conversion for normalization. The path operations in Parts 6โ€“9 are the ones that appear most frequently in real scripts.


Quick Reference

String Operations

OperationSyntaxExample
Length${#var}${#name}
Substring${var:offset:length}${path:0:4}
Substring to end${var:offset}${path:10}
Negative offset${var: -n}${path: -8}
Replace first${var/old/new}${v/beta/stable}
Replace all${var//old/new}${v//-/_}
Replace at start${var/#old/new}${url/#http:/https:}
Replace at end${var/%old/new}${f/%.bak/}
Remove prefix (short)${var#pattern}${path#*/}
Remove prefix (long)${var##pattern}${path##*/}
Remove suffix (short)${var%pattern}${f%.*}
Remove suffix (long)${var%%pattern}${f%%.*}
Uppercase${var^^}Bash 4+
Lowercase${var,,}Bash 4+

Path Operations

GoalSyntaxResult for /a/b/c.txt
Basename${path##*/}c.txt
Directory${path%/*}/a/b
Extension${path##*.}txt
Remove extension${path%.*}/a/b/c

Best Practices

โœ… Do This:

# Use parameter expansion instead of external commands
basename="${path##*/}"                                       # โœ…
extension="${file##*.}"                                      # โœ…

# Space before negative offset
last_chars="${var: -3}"                                      # โœ…

# Quote expansions in assignments
result="${var//old/new}"                                     # โœ…

# Use ## for basename, % for directory
base="${path##*/}"; dir="${path%/*}"                         # โœ…

# Validate length before processing
[ "${#input}" -gt 0 ] || exit 1                              # โœ…

โŒ Don’t Do This:

# Forget the space for negative offset
"${var:-3}"    # Default-value operator, not substring        # โŒ

# Use regex syntax in patterns
"${var/[0-9]+/}"    # + is literal, not regex                # โŒ

# Use sed for simple basename extraction
basename=$(echo "$path" | sed 's|.*/||')    # Slow           # โŒ

# Assume ${var:0:4} works on arrays the same way
# Array substring uses different indexing                    # โŒ

# Replace without anchoring when only prefix should change
"${url/http/https}"    # Replaces anywhere, not just prefix # โŒ

Common Pitfalls

PitfallWhy It HappensFix
Negative offset returns defaultNo space before -${var: -3}, not ${var:-3}
Pattern does not matchUsed regex instead of globUse *, ?, []
Basename extraction wrongUsed # instead of ##${path##*/} for last slash
Extension removal leaves dotUsed # instead of %${file%.*} removes last dot
Replacement affects all occurrencesUsed / instead of //Single slash for first only

Real-World Examples

1. Length check

[ "${#name}" -gt 0 ] || echo "Empty"

2. First 8 characters (date)

date="${timestamp:0:8}"

3. Last 4 characters (extension)

ext="${file: -4}"

4. Replace underscores

clean="${raw//_/-}"

5. Change URL scheme

secure="${url/#http:/https:}"

6. Extract basename

base="${path##*/}"

7. Extract directory

dir="${path%/*}"

8. Remove extension

stem="${file%.*}"

9. Remove all extensions

stem="${file%%.*}"

10. Uppercase conversion

upper="${name^^}"

Visual

Substring Indexing

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  SUBSTRING OFFSETS ARE ZERO-BASED                            โ”‚
โ”‚                                                              โ”‚
โ”‚  var="us-east-1-web-prod-01"                                 โ”‚
โ”‚      0123456789...                                           โ”‚
โ”‚                                                              โ”‚
โ”‚  ${var:0:9}                                                  โ”‚
โ”‚  โ”œโ”€โ”€ offset 0 (start)                                        โ”‚
โ”‚  โ”œโ”€โ”€ length 9                                                โ”‚
โ”‚  โ””โ”€โ”€ result: "us-east-1"                                     โ”‚
โ”‚                                                              โ”‚
โ”‚  ${var:10}                                                   โ”‚
โ”‚  โ”œโ”€โ”€ offset 10                                               โ”‚
โ”‚  โ”œโ”€โ”€ no length โ†’ to end                                      โ”‚
โ”‚  โ””โ”€โ”€ result: "web-prod-01"                                   โ”‚
โ”‚                                                              โ”‚
โ”‚  ${var: -8}                                                  โ”‚
โ”‚  โ”œโ”€โ”€ space before -8 (negative offset)                       โ”‚
โ”‚  โ”œโ”€โ”€ offset -8 โ†’ 8 chars from end                            โ”‚
โ”‚  โ””โ”€โ”€ result: "prod-01"                                       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Prefix and Suffix Removal

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  # REMOVES FROM START; % REMOVES FROM END                    โ”‚
โ”‚                                                              โ”‚
โ”‚  path="/home/user/docs/file.txt"                             โ”‚
โ”‚                                                              โ”‚
โ”‚  ${path#*/}                                                  โ”‚
โ”‚  โ”œโ”€โ”€ # = shortest prefix match                               โ”‚
โ”‚  โ”œโ”€โ”€ pattern = */                                            โ”‚
โ”‚  โ””โ”€โ”€ result: "home/user/docs/file.txt"                       โ”‚
โ”‚                                                              โ”‚
โ”‚  ${path##*/}                                                 โ”‚
โ”‚  โ”œโ”€โ”€ ## = longest prefix match                               โ”‚
โ”‚  โ”œโ”€โ”€ pattern = */                                            โ”‚
โ”‚  โ””โ”€โ”€ result: "file.txt"                                      โ”‚
โ”‚                                                              โ”‚
โ”‚  ${path%/*}                                                  โ”‚
โ”‚  โ”œโ”€โ”€ % = shortest suffix match                               โ”‚
โ”‚  โ”œโ”€โ”€ pattern = /*                                            โ”‚
โ”‚  โ””โ”€โ”€ result: "/home/user/docs"                               โ”‚
โ”‚                                                              โ”‚
โ”‚  ${path%%/*}                                                 โ”‚
โ”‚  โ”œโ”€โ”€ %% = longest suffix match                               โ”‚
โ”‚  โ”œโ”€โ”€ pattern = /*                                            โ”‚
โ”‚  โ””โ”€โ”€ result: "" (empty; first / is the match)                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Replacement Anchors

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ANCHORED REPLACEMENT RESTRICTS WHERE THE MATCH OCCURS       โ”‚
โ”‚                                                              โ”‚
โ”‚  url="http://http.example.com/http"                          โ”‚
โ”‚                                                              โ”‚
โ”‚  ${url/http/https}                                           โ”‚
โ”‚  โ””โ”€โ”€ replaces FIRST occurrence anywhere                      โ”‚
โ”‚      result: "https://http.example.com/http"                 โ”‚
โ”‚                                                              โ”‚
โ”‚  ${url//http/https}                                          โ”‚
โ”‚  โ””โ”€โ”€ replaces ALL occurrences                                โ”‚
โ”‚      result: "https://https.example.com/https"               โ”‚
โ”‚                                                              โ”‚
โ”‚  ${url/#http:/https:}                                        โ”‚
โ”‚  โ””โ”€โ”€ # = only at start                                       โ”‚
โ”‚      result: "https://http.example.com/http"                 โ”‚
โ”‚                                                              โ”‚
โ”‚  ${url/%http/https}                                          โ”‚
โ”‚  โ””โ”€โ”€ % = only at end                                         โ”‚
โ”‚      result: "http://http.example.com/https"                 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Length${#var}
Substring${var:offset:length}
Negative offset${var: -n} (space required)
Replace first${var/old/new}
Replace all${var//old/new}
Replace at start${var/#old/new}
Replace at end${var/%old/new}
Remove prefix (short)${var#pattern}
Remove prefix (long)${var##pattern}
Remove suffix (short)${var%pattern}
Remove suffix (long)${var%%pattern}
Basename${path##*/}
Directory${path%/*}
Uppercase${var^^} (Bash 4+)

Key takeaways:

  • Parameter expansion is the built-in string library. Length, substring, replacement, and pattern removal are all performed by ${...} syntax. No external command is spawned, and no quoting or escaping is required beyond the standard shell rules .
  • The negative-offset space is not optional. ${var: -3} extracts the last three characters. ${var:-3} is the default-value operator and returns the value of var if it is set, or 3 if it is not .
  • # removes from the start; % removes from the end. The single-character forms remove the shortest match, and the doubled forms remove the longest. ${path##*/} is the standard basename idiom; ${file%.*} is the standard extension-removal idiom .
  • Replacement patterns are globs, not regex. *, ?, and [] are the supported wildcards. The # and % anchors restrict the match to the beginning or end of the string, and the doubled slashes replace all occurrences instead of only the first .
  • Substring indexing is zero-based for variables. The offset counts characters from the start of the string. For positional parameters, the indexing is one-based by default, which reflects the convention that $1 is the first argument .
  • The pattern operators express intent better than fixed offsets. Extracting a basename with ${path##*/} works regardless of how many directories the path contains. Counting characters to a fixed offset breaks when the path depth changes.

Remember: Bash strings are sequences of characters, and the operations that manipulate them are built into the shell’s expansion syntax. ${#var} measures, ${var:offset:length} extracts, ${var/old/new} replaces, and ${var##pattern} removes. The syntax is dense but consistent: the variable comes first, the operator determines the operation, and the pattern or offset determines the result. The negative-offset space is the one trap that produces a wrong answer instead of an error. The ## and %% forms are the ones that handle filenames and paths without counting characters. Use parameter expansion instead of sed or cut when the operation is a simple string transformation, and the script will be faster and more portable.


Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!