A script extracted the hostname from a URL with cut -d/ -f3 — split on /, take the third field. For http://example.com/path the fields are http:, ``, example.com, path, so field 3 is example.com. Correct. Then the upstream system started emitting https:// URLs. The field positions did not move — https: is still field 1, the empty string is still field 2, example.com is still field 3 — but a developer "fixed" an unrelated parsing change and shifted the field number, and now field 3 returned an empty fragment. The script built recipient addresses from that empty value and sent 2,300 notification emails to null@domain instead of real users before the bounce rate triggered an alert. The lesson is not "be careful with cut." It is that field-counting is fragile against format changes, while bash parameter expansion matches on pattern boundaries — and does it without spawning a subshell per call, which matters when you are doing this 2,300 times in a loop.
Length and substrings
${#var} is the length. ${var:offset:length} slices: zero-based offset, then how many characters. Omit the length (${var:7}) to take everything to the end. This replaces echo "$path" | cut -c8-15 with no pipe and no external process.
Prefix stripping: # and
# removes the shortest matching prefix; ## removes the longest (greedy). The canonical use is pulling the filename out of a full path:
*/ is the pattern "anything ending in a slash." With ## it matches as much as possible, deleting all directory components and leaving the basename — the parameter-expansion equivalent of basename. With # it matches as little as possible, removing only the leading slash.
Suffix stripping: % and %%
% and %% do the same, anchored at the end. The everyday use is dropping a file extension:
Choose % to strip one extension and %% to strip a compound one. This is dirname/basename-style work done inside the shell.
Search and replace: / and //
A single / replaces the first match; // replaces every match:
${var// /_} is the standard way to make a string filesystem-safe by turning spaces into underscores — no sed, no subshell.
Case conversion (bash 4+)
These are built into bash 4.0+. The system bash on macOS is 3.2, where they do not exist — if your script must run there too, fall back to tr '[:upper:]' '[:lower:]'.
Default values: :- := and :?
Three expansions, three intents:
:- substitutes a value for this one use without changing the variable. := also assigns it, so every later reference sees the default (note: := cannot be used directly on positional parameters, hence the : no-op command idiom). :? enforces that the variable was provided and stops the script with your message otherwise — the right choice for required configuration like database credentials in CI.
When NOT to use parameter expansion
Parameter expansion wins for single-variable, fixed-pattern edits because it skips the subshell. It loses on readability the moment you need real regular expressions, multi-line input, or a transformation applied across many lines. Extracting a path component: parameter expansion. Replacing the third comma-separated field in every line of a 50,000-line CSV: awk. Rewriting a string only when it matches a complex regex with capture groups: sed -E. If the parameter-expansion version requires a comment to explain what the pattern does, the sed/awk version is probably the more maintainable choice. Speed favors expansion in tight loops; clarity favors sed/awk for genuinely text-processing tasks.
Complete production script
A filename normalizer that strips the path, downcases, replaces spaces with underscores, and removes a trailing _YYYYMMDD date suffix — using parameter expansion only, zero subshells:
Every transformation — basename, lowercase, space replacement, extension split, date-suffix removal — happens inside bash with no basename, no tr, no sed, and no subshell. In a loop over thousands of files that is the difference between a script that finishes in a second and one that forks thousands of processes. And because each step matches on a pattern boundary rather than a field number, a change in input format fails visibly instead of silently returning the wrong slice the way that cut -d/ -f3 did.