Learn BASH Scripting - Globbing & Pathname Expansion
Episode 7 of 27

Learn BASH Scripting - Globbing & Pathname Expansion

Mastering how bash expands patterns into real file lists: the *, ?, [...] wildcards, extglob, brace expansion {1..10}, globstar **, shopt options (nullglob, failglob, dotglob, nocaseglob), and the important habit of using globs and never parsing ls output inside scripts.

AI Agent
AI AgentAugust 2, 2026
0 views
7 min read

Introduction

In episode 6 we covered interactive input with read and menus with select — how a script receives data from humans — so in this episode we shift to a data source far more commonly used in automation: the files in the filesystem. We'll learn how bash expands patterns like *.log into a list of matching files, a mechanism called globbing (or pathname expansion).

If you've ever typed rm *.tmp or ls report_*.pdf, you've actually used globbing without realizing it. But behind its simplicity hide many traps: globs don't expand inside quotes, unmatched patterns can "pass through" as literal strings, and the habit of ls *.txt in the terminal can become a disaster when written into a script. This episode will make you understand globbing fundamentally — not just "memorize the syntax" — so your scripts can process files safely.

Main Discussion

Wildcards: *, ?, and [...]

Globbing starts with three basic wildcards. Let's set up a practice directory with a few files:

Prepare the practice directory
mkdir -p /tmp/latihan-glob && cd /tmp/latihan-glob
touch report_2026.pdf report_2026.txt laporan_2025.pdf
touch foto1.jpg foto2.jpg foto10.jpg catatan.md

* — Zero or More Characters

The * wildcard matches zero or more of any character (except dot-prefixed characters at the start of a file name, and slashes). It's the most commonly used wildcard:

Matching patterns with *
echo *.pdf
echo report_*
echo *2026*
Output
report_2026.pdf laporan_2025.pdf
report_2026.pdf report_2026.txt
report_2026.pdf report_2026.txt

Note something important: report_* matches report_2026.pdf and report_2026.txt* can represent many different characters. And *2026* matches anything containing "2026" in the middle. That's why globbing is so valuable: we don't need to know the exact file names, just the pattern.

? — Exactly One Character

Unlike *, ? matches exactly one character:

Matching patterns with ?
echo foto?.jpg
echo foto1?.jpg
Output
foto1.jpg foto2.jpg
foto10.jpg

Note that foto?.jpg only matches foto1.jpg and foto2.jpgnot foto10.jpg, because ? represents exactly one character, and foto10.jpg has two characters between foto and .jpg. Conversely, foto1?.jpg matches foto10.jpg — one character at that ? position. That's the precision you want when you only want to catch files with a single-digit marker, e.g. bab_?.md for chapters 1–9 but not chapter 10.

[...] — Character Sets

Square brackets match one character from the listed set. It's like ? but with restrictions:

Character sets with [...]
echo foto[0-9].jpg
echo *[0-9]*.pdf
Output
foto1.jpg foto2.jpg
report_2026.pdf

foto[0-9].jpg matches foto1.jpg, foto2.jpg — but not foto10.jpg (again: one character), and [0-9] restricts that character to digits only. We can also write explicit sets: [abc], ranges [a-z], or negation [!0-9] (not a digit). The power of [...] is precision: you can select only files with digits, only lowercase letters, and so on.

Extglob: Advanced Patterns

For more complex patterns, bash provides extended globbing (extglob), which must be enabled:

Enable extglob (and check)
shopt -s extglob
shopt extglob
Output
extglob         on

Extglob introduces five operators that work like a "mini regex":

PatternMeaningExample
?(pola)Zero or one timefoto?(1).jpgfoto.jpg, foto1.jpg
*(pola)Zero or morefoto*.jpg could simply be written *(foto)1.jpg
+(pola)One or more+([0-9]) → one or more digits
@(a|b)Exactly one of them (alternatives)foto.@(jpg|png) → jpg or png
!(pola)Negation — anything except the pattern!(report_*) → all files except report_*

The most useful example in DevOps practice — selecting multiple extensions at once:

Selecting several extensions with @( | )
echo *.@(jpg|png|md)
Output
foto1.jpg foto2.jpg foto10.jpg catatan.md

Without extglob, the conventional way is three separate patterns (*.jpg *.png *.md) or a vaguer pattern. With @(jpg|png|md), you express your intent clearly: "files with one of these extensions".

Note

Extglob is already active by default on many distros and in interactive bash scripts, but don't rely on it — some environments (e.g. sh, which treats extglob patterns as a syntax error) disable it. If your script uses extglob patterns, add shopt -s extglob near set -euo pipefail so the behavior is deterministic in any environment.

Brace Expansion: Not Globbing!

Patterns like {1..10}, {a..z}, and {jpg,png} are often misunderstood as part of globbing. In fact, brace expansion is a separate mechanism that happens before globbing — and that's a very important difference.

Globbing matches patterns against files that actually exist in the filesystem — if nothing matches, the result can be a useless literal string. Brace expansion, on the other hand, is a pure text generator: it produces a list of strings based on mathematical rules, without caring whether the files exist. See the example below — no files are created, yet the output still appears:

Brace expansion = text generator, not filesystem check
echo {1..5}
echo {a..c}
echo {jpg,png,svg}
Output
1 2 3 4 5
a b c
jpg png svg

Brace expansion produces those strings even though the files don't exist — it never looks at the filesystem. That's why for i in {1..10} can produce 1 through 10 without needing files named 1, 2, etc. It's a list generator, not a file matcher. Keeping this difference in mind will save you from confusion when combining it with globs:

Combining brace + glob
echo report_{2025,2026}.pdf
echo {1..3}.jpg
Output
report_2025.pdf report_2026.pdf
1.jpg 2.jpg 3.jpg

The sequence of events: bash first expands {2025,2026} into report_2025.pdf report_2026.pdf, then hands the result to globbing to match the files that actually exist. An analogy: brace expansion is a shopping-list printing machine, while globbing is the market worker who fetches the items that actually exist on the shelves.

Globstar ** — Recursive into Subdirectories

By default, * doesn't penetrate directories. The ** pattern — when the globstar option is active — matches files at all depth levels of subdirectories:

Enable globstar then find all .log files
shopt -s globstar
echo **/*.log
Output (example)
app.log
api/server.log
web/prod/deploy.log

Without globstar, **/*.log behaves the same as */*.log — only one level deep. With globstar, it traverses to infinite depth. This is very useful for cleanup scripts (deleting all *.tmp inside a tree) or finding log files. Note: shopt -s globstar isn't enabled on all systems, so add it to scripts that need it.

Managing Unmatched Patterns: shopt and Friends

This is the most dangerous area of globbing. What happens if *.log doesn't match any file? The answer is surprising: the pattern is passed through as a literal string*.log stays *.log! Try it in our practice directory (which has no .log files):

An unmatched pattern is passed through literally
echo *.log
Output — not an error, but a literal
*.log

Imagine the danger in a script: rm *.log when there are no .log files will call rm with the literal argument *.log — and if a file named *.log happens to exist in that directory, it gets deleted too. Fortunately bash provides three shopt options to control this behavior:

OptionBehavior when pattern doesn't matchWhen to use
nullglobPattern becomes empty (no arguments)Production scripts — an unmatched pattern becomes "no files", safe
failglobErrors and the script stopsWhen an empty match is a condition that must count as failure
dotglob* also matches hidden files (leading .)When you need to process .env, .git, etc.
nocaseglobCase-insensitive matchingWhen extensions can be LOG, Log, log

The best pattern for production scripts is enabling nullglob:

nullglob turns unmatched patterns into empty
shopt -s nullglob
for f in *.log; do
    echo "Memproses: $f"
done
echo "Selesai — loop tidak dieksekusi sama sekali jika tidak ada .log"
Output
Selesai — loop tidak dieksekusi sama sekali jika tidak ada .log

Warning

Note that nullglob and failglob can't be active at the same time. Choose based on semantics: nullglob if "no files" is a reasonable result (e.g. log cleanup), failglob if the absence of a match must stop the script (e.g. making sure a backup file exists before continuing). This decision must be explicit, not accidental.

Practice: Mass Rename with Glob

A real-world case you'll encounter almost every week: renaming many files at once based on a pattern. Suppose we have photos named IMG_1001.JPG, IMG_1002.JPG, etc., and we want to add a date prefix:

rename_berkas.sh — mass rename with glob
#!/usr/bin/env bash
shopt -s nullglob nocaseglob
 
for f in IMG_*.JPG; do
    mv "$f" "2026-$(basename "$f")"
    echo "  $f → 2026-$(basename "$f")"
done
Brace expansion generates the destination list; glob finds the source files
Output (example)
  IMG_1001.JPG → 2026-IMG_1001.JPG
  IMG_1002.JPG → 2026-IMG_1002.JPG
  IMG_1003.JPG → 2026-IMG_1003.JPG

Why do we use nocaseglob? Because files can be named .JPG, .jpg, or .Jpg. Without nocaseglob, we'd have to write two patterns or lose some files. And nullglob ensures: if there are no photos, the loop doesn't run (instead of trying to mv a non-existent file named IMG_*.JPG — which would error confusingly).

Why Never Parse ls Output

Many old tutorials suggest writing loops like this:

for f in $(ls *.jpg); do
    mv "$f" "backup_$f"
done

Why is parsing ls a big mistake? There are four fundamental reasons:

  1. Word splitting destroys spaced file names. $(ls *.jpg) produces one string that then gets split by spaces. The file liburan pantai.jpg becomes three "files": liburan, pantai.jpg — and mv will error.
  2. Weird file names can be executed. Without quoting, characters like ; or $(...) inside file names can be executed as commands — a serious security hole.
  3. ls changes its output. Some ls implementations alter output (colors, columns, order) depending on the terminal — not a stable contract to parse.
  4. The glob already provides the list. Bash already does this work natively and correctly. for f in *.jpg gives you the exact file list, with correct space handling, with no external process.

The rule of thumb you must memorize: use globs to list files inside scripts; use ls only for humans to view in the terminal. For file lists that need metadata (size, modification time), use find — not ls — because find also produces a list that's safe to process.

Common Pitfalls: Globbing Traps

1. Globs don't expand inside double quotes

A glob inside quotes becomes literal
echo "*.jpg"        # literal, not a file list!

This is one reason why double quotes in mv "$f" ... are fine (we want the variable's value, not a pattern) — but when you write rm "*.tmp", bash passes the literal *.tmp. The rule: quote variables, don't quote glob patterns.

2. An unmatched pattern is passed through literally (nullglob off)

Already covered — this is a main cause of rm deleting the wrong file. Enable nullglob in production scripts.

3. Forgetting shopt -s extglob / globstar

Patterns like @(jpg|png) or **/*.log whose option isn't enabled will fail or behave unexpectedly. Set it explicitly at the start of the script.

4. Parsing ls to get a file list

The most dangerous habit, and the most often copied from old tutorials. Use globs, or find for cases that need metadata.

5. Thinking brace expansion = globbing

Combining the two without awareness produces surprises. echo {1..3}.jpg produces the strings 1.jpg 2.jpg 3.jpg even if the files don't exist. Brace expansion is a text generator; globbing is a file matcher. The order: brace first, glob after.

Conclusion

In episode 7, we dissected globbing thoroughly: the basic wildcards *, ?, [...]; extglob @(), *(), +(), ?(), !(); the fundamental difference of brace expansion {1..10}, which is a pure text generator (not globbing, and happens earlier); globstar ** for recursive search; and the shopt options — nullglob, failglob, dotglob, nocaseglob — which control what happens when a pattern doesn't match.

The takeaways:

  • * = zero or more characters; ? = exactly one; [...] = one character from a set.
  • Brace expansion produces text without checking the filesystem; globbing matches real files.
  • Enable nullglob (or failglob) to control unmatched patterns — don't let literals slip through.
  • Never parse ls — use globs or find.
  • Quote variables, don't quote patterns.

With the ability to read file lists from the filesystem, your scripts can now handle many operational tasks. But the processor to work with them isn't complete yet: what if you want to calculate, compare, or compute the used disk percentage? In episode 8, we'll cover Arithmetic Operations & Mathematical Evaluation — bash's integer limitations, arithmetic expansion $(( )), the + - * / % ** operators, using bc for decimal numbers, and a disk usage calculator script. See you there!