Module 9 — Parameter Expansion and String Manipulation
Updated 3 September 2026
🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
Part A — Defaults and guards: the :- family
A1. Four operators for "what if it's empty?"
Everything in this module happens inside the ${ } braces you met in Module 2 — the braces are not just a name boundary; they are a tiny workshop where the value can be transformed on the way out. The first family handles bash's oldest hazard, the silent empty expansion (Module 2 A1), with four one-character policies:
| Spelling | If var is unset or empty… | Production use |
|---|---|---|
| ${var:-fallback} | expand to fallback; var itself unchanged | read-time defaults: "${TIMEOUT:-30}" |
| ${var:=fallback} | expand to fallback and assign it to var | set-once defaults early in a script |
| ${var:?message} | print message to stderr and abort (non-zero) | required variables: : "${API_KEY:?API_KEY is required}" |
| ${var:+word} | the reverse: expand to word only if var has a value | conditional flags: ${VERBOSE:+--verbose} |
The :? guard usually rides on the do-nothing command : (a colon — it ignores its arguments and succeeds), giving the one-line required-variable check above: if API_KEY is missing the script stops with your message; if present, the line does nothing at all. Dropping the colon inside the braces (${var-fallback}) changes "unset or empty" to strictly "unset" — a distinction Module 2's unset-vs-empty section planted, now with a use.
Where the analogy stops working. A human clerk distinguishes "field left blank" from "field filled with whitespace" from "form has no such field." Bash's colon-forms lump unset and empty together — a variable set to "" triggers the same policy as one that never existed — and a value of a single space counts as filled. When those distinctions matter (they occasionally do in config handling), the colonless forms and manual -z checks are the precision tools.
🧪 Exercise 9.1 — four policies, felt
The last line aborts on purpose.
unset region
echo "read-default: [${region:-eu-west-1}] — and region is still: [$region]"
echo "set-default: [${region:=eu-west-1}] — and region is now: [$region]"
count=0
echo "zero is a value: [${count:-5}]"
flag=1
echo "flag set: [${flag:+--verbose}] flag unset: [${nope:+--verbose}]"
: "${MUST_EXIST:?is required}"
echo "you will not see this"✅ Expected result — click to reveal (contains a deliberate abort)
read-default: [eu-west-1] — and region is still: []
set-default: [eu-west-1] — and region is now: [eu-west-1]
zero is a value: [0]
flag set: [--verbose] flag unset: []
bash: MUST_EXIST: is requiredWhat to read out of it, line by line: :- supplied the value but left the jar empty (the probe brackets prove it); := supplied and filled the jar — the difference between the two is entirely in that side effect. Line 3 kills a common misreading: 0 is not "empty" — the default did not fire; :- is about missing values, not falsy ones. Line 4 shows :+'s party trick, building a flag out of a variable's mere presence. And the finale: the :? guard printed your message on stderr and — in a script — stops execution dead: the final echo never runs (from a file, the message also gains a line N: prefix). Typed at an interactive prompt, your shell itself survives the failure and the last echo will print — the abort kills scripts, not your terminal. One line, and a whole class of "ran half a deploy with a blank API key" incidents becomes impossible.
Interview questions — Part A
🎯 "How do I set a default value if the user just presses Enter?" — asked verbatim in LinuxTeck's FAQ (Module 7 answered the read half; here is the expansion half in full)
The direct answer: "${var:-default}" wherever the value is used, or : "${var:=default}" once near the top to settle it permanently. For read prompts: read -r ans; ans="${ans:-staging}".
Going deeper: lay out the full family as policies — :- read-time, := assign-time, :? required, :+ presence-flag — and the colon rule (with colon: unset or empty; without: strictly unset). The canonical production opener is a block of : "${VAR:?...}" and : "${VAR:=...}" lines that is the script's configuration documentation, self-enforcing.
The details that separate candidates: the ${VERBOSE:+--verbose} idiom for building optional command flags without an if-block; knowing := cannot assign to positional parameters (${1:=x} errors — they're read-only that way); and the subtle honesty that 0 and "0" and a lone space are all values, so the colon family never fires on them — "default" means absent, not falsy.
Part B — Trimming: # and %, the path surgeons
B1. Strip from the front, strip from the back
Four operators remove a matching piece from a value's edge, and they reuse the glob language you already know (*, ? — Module 2 B3, Module 5's case patterns):
${var#pattern} — strip the shortest match from the front. ${var##pattern} — the longest from the front. ${var%pattern} — shortest from the back. ${var%%pattern} — longest from the back. The memory hook everyone eventually adopts: on a US keyboard, # sits left of % on the number row the way the front of a string sits left of its back — # trims fronts, % trims backs; doubling the character means "be greedy."
Two of these four are so common they have names in other tools: ${path##*/} (drop everything up to the last slash) is basename; ${path%/*} (drop the last slash and after) is dirname. The expansions do in-process what those commands do in a child process — in a loop over ten thousand paths, that difference is measurable minutes.
Where the analogy stops working. A slicer cuts what you see. These operators cut what the pattern matches, and glob patterns are greedy in non-obvious ways: with archive.tar.gz, %.* takes only .gz (shortest), %%.* takes .tar.gz (longest) — both reasonable, different answers to "remove the extension." Multi-dot names are where extension-stripping one-liners quietly disagree with their authors; decide which behavior you mean, then pick the operator, not the other way round.
🧪 Exercise 9.2 — surgery on paths and versions
file="backups/db-prod.sql.gz"
echo "shortest from back: ${file%.gz}"
echo "longest from back: ${file%%.*}"
echo "shortest from front: ${file#*/}"
echo "longest from front: ${file##*/}"
path="/opt/app/releases/v2.1.0"
echo "basename-style: ${path##*/}"
echo "dirname-style: ${path%/*}"
ver="release-2.14.7"
echo "version number: ${ver#release-}"✅ Expected result — click to reveal
shortest from back: backups/db-prod.sql
longest from back: backups/db-prod
shortest from front: db-prod.sql.gz
longest from front: db-prod.sql.gz
basename-style: v2.1.0
dirname-style: /opt/app/releases
version number: 2.14.7What to read out of it: lines 1–2 are the multi-dot lesson live — same intent ("remove the extension"), two different results; know which you meant. Lines 3–4 happen to match because this path has one slash — with /a/b/c.txt, #*/ yields b/c.txt while ##*/ yields c.txt; the difference reappears the moment paths deepen. The last three lines are the daily bread: pure-bash basename, dirname, and prefix-stripping — no child processes, no quoting adventures, no sed.
Interview questions — Part B
🎯 "How do you get a filename without its extension in bash?" — a whole tutorial genre exists on this exact task (Java2Blog, LinuxHint, Sentry all cover it), though question lists rarely print it verbatim — the substance is the trim operators
The direct answer: "${file%.*}" — strip the shortest match of .anything from the back. For the extension itself: "${file##*.}". For the name without directory and without extension: two steps — name="${file##*/}"; name="${name%.*}".
Going deeper: the multi-dot decision — %.* on app.tar.gz leaves app.tar (usually what you want: one extension off), %%.* leaves app (everything after the first dot gone, which mangles version-dotted names like v2.1.0-notes.txt). And the no-dot edge: ${file%.*} on Makefile returns Makefile unchanged — the pattern must match to cut, which is usually the right failure mode.
The details that separate candidates: explaining # / % by mechanism (front/back, shortest/longest, glob patterns) rather than reciting one memorized incantation; the basename/dirname equivalences and their per-call process cost in loops; and one honest boundary — hidden files like .bashrc fool naive extension logic (${f##*.} yields bashrc), so hostile input deserves a guard.
Part C — Replace, measure, slice
C1. ${var/…}, ${#var}, ${var:off:len}, and case flips
The rest of the workshop, tour-style. Replacement: ${var/pattern/replacement} swaps the first match; double the slash — ${var//pattern/replacement} — for all matches; leave the replacement empty to delete (${csv//,/} strips every comma). The pattern is again a glob, not a regex. Length: ${#var} counts characters (recognize the shape: # before the name is length; after the brace with a pattern it's front-trim — position is everything). Substring: ${var:offset:length} slices by position, counting from 0 — ${host:0:6} is the first six characters. Case: ${var^^} uppercases, ${var,,} lowercases (bash 4+; the everyday normalizer for user-typed values before a case).
🧪 Exercise 9.3 — the rest of the toolbox
host="web-01.prod.example.com"
echo "first dot only: ${host/./-}"
echo "every dot: ${host//./-}"
echo "length: ${#host}"
echo "first six: ${host:0:6}"
csv="a,b,c"
echo "commas deleted: ${csv//,/}"
answer="YeS"
echo "normalized: ${answer,,}"✅ Expected result — click to reveal
first dot only: web-01-prod.example.com
every dot: web-01-prod-example-com
length: 23
first six: web-01
commas deleted: abc
normalized: yesWhat to read out of it: single vs double slash is first-vs-all — the metrics-name conversion on line 2 (dots to dashes, wholesale) is a real daily task in monitoring pipelines. The length and slice lines read a value by position, which is honest for fixed-format data (the first six characters of a host naming convention) and brittle for anything humans type — prefer pattern-based trims when structure, not position, is what you know. The last line is the production idiom before every case "$answer" in yes) …: normalize once, match simply.
Interview questions — Part C
🎯 "How to get part of string variable with echo command only?" — asked verbatim at Edureka; PlacementPreparation asks "How do you check the length of a string in shell scripting?"
The direct answer: substring expansion — echo "${var:x:y}": from offset x (zero-based), take y characters. Length: "${#var}". Both are pure expansions — the "echo command only" in the question is a hint that no cut/awk is needed.
Going deeper: negative offsets count from the end but need a space — ${var: -3} is the last three characters (without the space, :- is Part A's default operator — a genuinely nasty collision worth naming aloud in an interview). Omitting length takes everything to the end: ${var:5}.
The details that separate candidates: the :--collision detail is the separator on this exact question — it proves you know both operators rather than one; and pairing position-slicing with its honest limits (byte/character subtleties with multibyte text, brittleness on human input) shows judgment, not just syntax.
🎯 "How do you perform string manipulation in Bash?" — asked verbatim at Zero To Mastery
The direct answer, as a toolbox tour: defaults ${v:-d}; trims ${v#p} ${v##p} ${v%p} ${v%%p} (front/back, shortest/longest, glob patterns); replace ${v/p/r} and ${v//p/r}; length ${#v}; slice ${v:off:len}; case ${v^^} ${v,,} — all in-process, no child commands.
Going deeper: organize by task, the way their answer does — extension handling (trims), sanitizing names for metrics/DNS (global replace), normalizing input (case flips), validation (length + Module 5's =~) — and state the boundary honestly: patterns here are globs; the moment you need real regex captures or multi-line edits, that is sed/awk territory (Module 11), and knowing where the line is is part of the answer.
The details that separate candidates: the performance story (expansion vs $(sed …) in loops — no fork, orders of magnitude in bulk); quoting discipline surviving inside the braces ("${v//p/r}" still gets quoted as a whole); and mentioning that replacement patterns can anchor — ${v/#p/r} start, ${v/%p/r} end — a corner even strong candidates rarely know.
Part D — Composing the workshop
D1. A real task, assembled: bulk-renaming safely
Every operator in this module earns its keep the day you must rename a directory of files. The task: turn report draft v2.txt-style names into clean report_draft_v2.txt — spaces to underscores. Watch four modules compose:
cd ~/bash-course
mkdir -p renames && cd renames
touch "report draft v2.txt" "meeting notes.txt" clean.txt
for f in *.txt; do # glob loop: split-safe (Module 6)
new="${f// /_}" # this module: every space → underscore
[ "$f" = "$new" ] && continue # already clean? skip (Modules 5+6)
echo "would rename: [$f] -> [$new]" # probe first, act later (Module 2)
doneThe echo-first shape is deliberate and professional: destructive loops get a dry-run pass — you read the plan, then swap echo "would rename: …" for mv -- "$f" "$new" (the -- says "no options follow", armoring mv against names that begin with -; a Module 7 idea protecting a Module 9 rename).
🧪 Exercise 9.4 — the dry run
cd ~/bash-course/renames
for f in *.txt; do
new="${f// /_}"
[ "$f" = "$new" ] && continue
echo "would rename: [$f] -> [$new]"
done✅ Expected result — click to reveal
would rename: [meeting notes.txt] -> [meeting_notes.txt]
would rename: [report draft v2.txt] -> [report_draft_v2.txt]What to read out of it: two renames planned, one file rightly skipped (clean.txt — the continue guard), names shown in probe brackets so any surprise is visible before anything moves. Glob order is alphabetical, hence meeting before report. When the plan reads right, promote the echo to mv -- "$f" "$new" and run once more. This tiny script is the module in miniature: expansion did the string work, and everything around it — glob list, guard, quoting, dry-run — is the earlier track keeping the operation safe.
D2. Which string tool? — the decision
Diagram source
flowchart TD
A["I need to transform<br>a string in bash"] --> B{"What kind<br>of job?"}
B -->|"missing-value<br>policy"| C["${var:-} family<br>:- := :? :+"]
B -->|"cut from an edge<br>(path, extension, prefix)"| D["trims: # ## % %%<br>glob patterns"]
B -->|"swap or delete<br>occurrences"| E["${var/p/r} first<br>${var//p/r} all"]
B -->|"measure / slice<br>by position"| F["${#var} and<br>${var:off:len}"]
B -->|"normalize case"| G["${var,,} ${var^^}"]
B -->|"regex captures,<br>multi-line, files"| H["sed / awk<br>(Module 11)"]
D --> I{"inside a<br>hot loop?"}
I -->|"yes"| J["expansion — no fork"]
I -->|"one-off, hostile paths"| K["basename / dirname<br>fine too"]Interview questions — Part D
🎯 "When do you use parameter expansion versus sed or awk?" — the judgment question behind every string-manipulation screen; posed in follow-up form rather than published lists
The direct answer: expansion for one value in a variable — defaults, edge-trims, glob replace, slices, case — in-process and fork-free. sed/awk for streams and files: many lines, regex with captures and classes, field-based work.
Going deeper: the two boundaries that decide it — pattern power (expansion speaks glob only; the moment you need [0-9]+ captures or backreferences, that's regex country) and data location (a variable vs a stream; $(sed …ing a variable…) round-trips through a child process for work bash could do in place). The hot-loop rule from Part B's fleet callout: expansions in loops, external tools on streams.
The details that separate candidates: naming the collision cases where both work and one is clearly better (strip an extension: expansion; edit a config file in place: sed) rather than treating the tools as rivals; and the team-code angle — a chain of three cryptic expansions is sometimes worth trading for one readable awk even at fork cost, because maintenance is also a cost. Judgment, stated as trade-offs, is the interview answer.
Part E — Production
E1. 🏭 Production practices
Scripts open with a self-enforcing config block. A run of : "${VAR:?message}" and : "${VAR:=default}" lines documents every tunable and secret the script accepts — readable as documentation, fatal when violated.
Path surgery is expansion first. ${f##*/}, ${f%/*}, ${f%.*} are the house spellings inside loops; basename/dirname appear for one-offs and hostile-path edge cases, with the choice deliberate.
Extension logic states its multi-dot policy. %.* vs %%.* on *.tar.gz-shaped names is decided and commented, not discovered in production.
Destructive string-driven loops ship with a dry-run. The echo-the-plan pass is kept in the script behind a flag (${DRY_RUN:+echo} is the one-line trick: prefix the action with a variable that expands to echo only when DRY_RUN is set).
User input is normalized before matching. "${answer,,}" before the case; hostnames and metric names sanitized with global replace (${name//./-}) at the boundary where they enter the system.
The glob/regex boundary is respected. Expansions do glob-shaped work; the first regex capture or multi-line edit goes to sed/awk — no heroic expansion chains that the next engineer cannot read.
E2. Production-practice table
| Symptom | What is really happening | What to run | The fix |
|---|---|---|---|
| Deploy ran with a blank API key / empty target and did half a job | No required-variable guard — empty expansion sailed through (Module 2's silence) | printf '[%s]\n' "$API_KEY" → [] | Top-of-script : "${API_KEY:?API_KEY is required}" per required variable |
| A default "didn't work" — variable still empty later in the script | :- was used (read-time only, no assignment) where := was meant | Probe the variable after the line that "set" it | : "${VAR:=default}" when the value should persist |
| Default fired even though the variable was set… to 0 — or refused to fire on a lone space | Colon-family semantics: unset-or-empty, and whitespace is a value | v=0; echo "${v:-5}" (prints 0) and v=" "; echo "[${v:-x}]" (prints the space) | Colon forms fire on absent, not falsy; trim/validate first when that distinction matters |
| "Remove the extension" mangled v2.1.0-notes.txt into v2 | %%.* (longest-from-back) applied where %.* (shortest) was meant | Test both operators on a multi-dot sample name | Decide the multi-dot policy; comment it at the use site |
| Substring produced a default value instead of the last N characters | ${var:-3} — the missing space made it the default operator, not a negative offset | Compare ${var:-3} and ${var: -3} on a sample | ${var: -3} — space before the minus, always; comment it, it will look like a typo |
| Loop over thousands of paths is mysteriously slow | $(basename …)/$(sed …) forking a process per iteration | time the loop; count $( ) per iteration | Replace with ${f##*/}-style expansions; keep externals out of hot loops |
E3. 🎓 Capstone — four tickets from the queue
🎓 Ticket 1 — "Rollout script takes IMAGE_TAG from the environment. Someone ran it on a box where that variable didn't exist; it deployed the image myapp: — colon, nothing — and the registry treated it as latest. Production got last night's build."
Diagnosis. The unguarded empty expansion composing a valid-looking wrong value: myapp:$IMAGE_TAG with the variable absent became myapp: — not an error, a different artifact. Worse than a crash: the failure wore a working costume all the way to production.
Work the steps: reproduce with the variable unset and probe the composed string: printf '[%s]\n' "myapp:$IMAGE_TAG" → [myapp:].
Fix and prevention: the one-liner this module exists for: : "${IMAGE_TAG:?IMAGE_TAG is required (e.g. 2026-09-03-a1b2c3)}" at the top — the message even teaches the format. Optionally stack a shape guard ([[ "$IMAGE_TAG" =~ ^[0-9a-zA-Z._-]+$ ]], Module 5). Fleet rule: any variable that becomes part of an artifact name, path, or URL gets :? — absence must be louder than wrongness.
🎓 Ticket 2 — "A cleanup script archives logs as ${name%%.*}.tar.gz. Since the app team started shipping logs named like api.2026-09-03.log, the archives keep overwriting each other — everything becomes api.tar.gz."
Diagnosis. B1's multi-dot disagreement, weaponized by a naming change: %%.* eats from the first dot, so api.2026-09-03.log → api — every dated file collapses to the same archive name, and >-style overwrite semantics did the rest. The operator was defensible when names had one dot; the data changed, the greedy trim did not.
Work the steps: run both trims on the new name shape: ${name%.*} → api.2026-09-03 (distinct per day) vs ${name%%.*} → api (collision). One echo each settles it.
Fix and prevention: shortest-match %.* — and state the policy in a comment ("strip ONE extension; dated stems must survive"). Add a collision guard before archiving: [ -e "$out" ] && { echo "refusing to overwrite $out" >&2; exit 1; }. The reusable lesson: greedy trims encode an assumption about dot-count; assumptions about other teams' filenames need guards, not faith.
🎓 Ticket 3 — "Monitoring rejects half our metric names. We build them as cpu.${HOSTNAME}.load — and hosts are named like web-01.prod.example.com, so the metric arrives as cpu.web-01.prod.example.com.load — five dots where the schema allows three fields."
Diagnosis. Unsanitized data crossing a boundary with its own grammar: the hostname's dots are structure to the metrics system, but content to you. Nothing is broken in bash — the composition simply forwarded hostile characters into a namespace that assigns them meaning (the same disease as Module 2's *-in-data, new costume).
Work the steps: probe the composed name; count dots. Then apply the fix candidate and re-count: h="${HOSTNAME//./-}" → web-01-prod-example-com.
Fix and prevention: sanitize at the boundary, once, in a named helper — metric_safe() { local s="${1//./-}"; printf '%s' "${s// /_}"; } (Module 8's function discipline meeting this module's replaces) — and route every externally-sourced fragment through it. Boundary sanitizers beat scattered fixes: the next hostile character (a space, an underscore policy) changes one function, not forty call sites.
🎓 Ticket 4 — "Code review flagged our new log rotator: 'this loop calls basename and two seds per file; on the ingest host that's 300k files.' The author asks — does it actually matter?"
Diagnosis. The hot-loop fork tax (B's fleet callout): three child processes per file × 300k files ≈ a million forks per run, each costing far more than the string work itself. On the laptop sample of 50 files, invisible; on the ingest host, the difference between a minute and the better part of an hour of pure overhead.
Work the steps: measure, don't argue: build a 10k-file test dir, time the loop as written, then the expansion version — name="${f##*/}"; stem="${name%.*}"; clean="${stem//[: ]/_}" — and compare. The ratio settles the review comment with data.
Fix and prevention: expansions inside the loop; if an external tool is genuinely needed (a real regex), restructure so it runs once over the stream (find … | sed …, Module 11's shape) instead of once per file. Codify the review heuristic: $( ) inside a hot loop is a performance smell — justify it or hoist it.
E4. Documentation reference
| Topic | Authoritative reference | What you'll find there |
|---|---|---|
| Every operator in this module | bash(1) — man7.org, "Parameter Expansion" section | The complete catalogue: defaults, trims, replace, length, slices, case, anchors |
| The command-flavored twins | basename(1) — man7.org · dirname(1) — man7.org | The external equivalents of ${f##*/} and ${f%/*}, with their edge-case handling |
| The patterns the trims speak | Bash manual — Filename Expansion (Pattern Matching) | The glob grammar reused by #/% trims and ${var/…} replacements |
E5. Self-assessment
Answer from memory, out loud or on paper. Every answer is in this module.
- ${var:-x} vs ${var:=x} — what does each expand to, and which one changes the variable?
- Write the one-line required-variable guard, explain the role of the leading :, and say what happens on violation.
- When does ${count:-5} not fire, even though count "looks empty" to a human? Give two examples.
- What does ${var:+word} do, and what is its flag-building idiom?
- The four trim operators: which character means which end, what does doubling mean, and what language do the patterns speak?
- Spell basename and dirname as pure expansions.
- On app.tar.gz, what do ${f%.*} and ${f%%.*} each produce — and why must a script choose knowingly?
- ${v/p/r} vs ${v//p/r}; and how do you delete every occurrence of a character?
- Two meanings of # inside braces — how does position distinguish length from trim?
- Why does ${var:-3} not take the last three characters, and what is the correct spelling?
- Why are expansions dramatically faster than $(basename …) in loops? What is the review heuristic?
- Name three jobs that are over the line into sed/awk territory, and the two boundaries (pattern power, data location) that put them there.
E6. Sources
Edureka — Top 60 Shell Scripting Interview Questions and Answers — "How to get part of string variable with echo command only?" (page updated Dec 9, 2024)
Zero To Mastery — Bash Interview Prep: 31 Essential Questions and Answers — "How do you perform string manipulation in Bash?" (published June 18, 2026)
PlacementPreparation — Top 50 Shell Scripting Interview Questions for Freshers — "How do you check the length of a string in shell scripting?" (published Sept 23, 2024; last updated Feb 27, 2025)
LinuxTeck — Bash read Command FAQ — "How do I set a default value if the user just presses Enter?" (published May 5, 2026)
A note on the corpus: the defaults family and substring/length operators appear in published lists; the trim operators (#/%) — arguably this module's most-used tools — circulate as tutorial material (Java2Blog, LinuxHint, Sentry all cover extension-stripping) rather than as fixed-wording interview questions, and are labeled accordingly. No questions were invented. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2.