We ended the previous lesson with a statement that looked like a curiosity and is in fact the key to this lesson: when you type rm *.csv, it is not rm that understands the asterisk. It is Bash that expands it before running anything, and rm already receives the complete list of names. Understanding that division of responsibilities explains behaviors that otherwise look magical or arbitrary, and it prevents accidents that have cost entire systems. Here you will learn to select files by pattern with precision on /var/log/veloz and the archive in /srv/veloz/data.
Contents
- What globbing is and who does it
- The basic wildcards:
*,?and[...] - POSIX character classes
- Brace expansion:
{} - Globbing versus regular expressions
- Tuning the behavior with
shopt - What happens when a pattern matches nothing
- Classic dangers
- Hands-on cases on the Veloz Envíos data
- What globbing is and who does it
Globbing (or path expansion) is the mechanism by which the shell replaces a pattern with the sorted list of existing file names that match it. It happens during the expansion phase you studied in 01-04, just before the command is looked up and executed.
The definitive demonstration is echo, which does nothing but print the arguments it receives:
echo knows nothing about wildcards. It received three already-expanded arguments, because Bash replaced the pattern before invoking it. Exactly the same happens with ls, cp or rm: they all receive the final list.
Three consequences you must internalize:
- The pattern only matches files that exist. If you write
*.login a directory with no.logfiles, there is nothing to substitute (section 7). - The expansion happens in the current directory, unless the pattern includes a path:
/var/log/veloz/*.logworks from anywhere. - Quotes disable globbing.
echo "*.csv"prints a literal*.csv, because a quoted pattern is not expanded. That is whygrep "*.csv" filedoes not do what you expect andfind . -name "*.csv"does: infindwe want the pattern to reach the program unexpanded.
- The basic wildcards:
*, ? and [...]
*, ? and [...]| Pattern | Matches | Example |
|---|---|---|
* |
Any sequence of characters, including the empty one | shipments* → shipments.csv, shipments-2026-08-01.csv |
? |
Exactly one character, whatever it is | shipments-2026-08-0?.csv → days 01 to 09 |
[abc] |
One character from the listed ones | log[123].txt |
[a-z] |
One character from the range | [a-m]* → names starting from a to m |
[!0-9] |
One character that is not in the set | [!s]* → names that do not start with s |
ls /var/log/veloz/*.log # all the logs
ls shipments-2026-08-0?.csv # the first nine days of the month
ls shipments-2026-0[78]-*.csv # July and August only
ls shipments-2026-08-[12][0-9].csv # days 10 to 29Two important nuances:
*does not cross slashes./var/log/*lists what is in/var/log, but it does not descend into its subdirectories. That is whatglobstaris for (section 6).*does not match hidden files (those starting with.).ls *does not show.bashrc. It is a deliberate protection: without it,rm *in your$HOMEwould delete all your configuration. It can be changed withdotglob, but that is rarely a good idea.
In [!0-9], the ! sign negates the set. Bash also accepts ^, but ! is the standard, portable form.
- POSIX character classes
Writing [a-zA-Z] works in English, but it breaks with accents and depends on the locale settings. POSIX classes are the robust way:
| Class | Matches |
|---|---|
[[:digit:]] |
Digits 0-9 |
[[:alpha:]] |
Letters, including the accented ones of the active language |
[[:alnum:]] |
Letters and digits |
[[:upper:]] / [[:lower:]] |
Uppercase / lowercase |
[[:space:]] |
Spaces, tabs and newlines |
[[:punct:]] |
Punctuation marks |
ls shipments-[[:digit:]][[:digit:]][[:digit:]][[:digit:]]-*.csv # 4-digit year
ls report-[[:upper:]]* # starts with a capitalNotice the double bracket: the class is [:digit:] and it goes inside a set [...], hence [[:digit:]]. You can combine them: [[:digit:]_-] matches a digit, an underscore or a dash.
- Brace expansion:
{}
{}Brace expansion looks like globbing, but it is a different and more powerful thing: it generates strings of text without consulting the disk and it happens before globbing, in the first expansion phase.
echo {1..5} # → 1 2 3 4 5
echo {jan,feb,mar} # → jan feb mar
echo shipments-2026-0{7,8} # → shipments-2026-07 shipments-2026-08None of those names need to exist: brace expansion manufactures text, it does not select files. That is why it is good for creating things, whereas globbing is only good for selecting what is already there:
mkdir -p ~/veloz-ops/{bin,lib,etc,logs} # creates 4 directories
mkdir -p /srv/veloz/data/archive/2026/{01..12} # creates the 12 months, zero-paddedThe range accepts a step: {1..20..5} gives 1 6 11 16, and it also works with letters: {a..e}.
The most useful idiom of all is file{,.bak}, which expands to file file.bak:
Bash turns that into cp -a ~/veloz-ops/etc/veloz-ops.conf ~/veloz-ops/etc/veloz-ops.conf.bak. It is the shorthand for the quick backup from lesson 02-01, and you will see it constantly in documentation and in the work of experienced people. It works because the first alternative is empty (nothing between { and ,).
One warning: brace expansion does not happen if there are spaces inside or if there is only one element. echo {a} prints a literal {a}.
- Globbing versus regular expressions
*.csv as a globbing pattern and .*\.csv as a regular expression do the same thing, but written differently and with incompatible rules. Confusing them is a classic mistake, because the same symbols mean different things.
| Aspect | Globbing (wildcards) | Regular expressions |
|---|---|---|
| Who interprets it | The shell, before running | The program (grep, sed, awk) |
| What it acts on | Existing file names | Text from any source |
* |
Any sequence of characters | "Zero or more times the previous thing" |
? |
Exactly one character | "Zero or one time the previous thing" (in ERE) |
. |
A literal dot | Any character |
| Any single character | ? |
. |
| Any sequence | * |
.* |
| Matching | The whole name must match | It is enough for part of it to match |
ls *.log # globbing: files ending in .log
grep '^2026-08-03.*ERROR' /var/log/veloz/app.log # regex: that day's lines with ERRORIn the second case, the single quotes are essential: they stop the shell from trying to expand * as a wildcard and guarantee that the expression reaches grep intact. Regular expressions in full are studied in 05-04; for now it is enough that you tell the two territories apart.
- Tuning the behavior with
shopt
shoptshopt (shell options) turns on (-s) and off (-u) Bash options that change how globbing works.
| Option | What it does |
|---|---|
nullglob |
If the pattern matches nothing, it expands to nothing instead of staying literal |
failglob |
If the pattern matches nothing, the command is not run and an error is shown |
dotglob |
Includes hidden files in the expansions |
nocaseglob |
Ignores upper and lowercase when matching |
globstar |
Enables ** to walk subdirectories recursively |
extglob |
Enables extended patterns like !(*.bak) or `+(a |
shopt -s globstar
ls /var/log/veloz/**/*.log # every .log at any depth
shopt # check the state of all the optionsglobstar deserves attention: ** walks the whole tree, so **/*.csv finds the CSVs in every subdirectory of the archive with no need for find. Be careful with it on huge trees, because it can take a long time.
nocaseglob solves a real case: if some file arrived as Shipments-2026-08-01.CSV, with the option active *.csv will find it all the same.
These options are persistent within the session: you turn them on in ~/.bashrc for interactive use, or at the start of a script so that its behavior is explicit.
- What happens when a pattern matches nothing
This is Bash's most counterintuitive default behavior:
There is no .xml file, and instead of returning an empty list, Bash leaves the pattern as it is and passes it along as a literal argument. It is a historical legacy and the cause of baffling errors: ls *.xml answers ls: cannot access '*.xml': No such file or directory, mentioning a file literally called *.xml that obviously does not exist. Worse still is what happens in a loop:
The loop runs once with the unexpanded pattern as if it were a file name, instead of not running at all. If there were an rm "$f" inside, or a read of the file, the result would be an incomprehensible error. Loops are studied in 04-01, but the problem is a globbing one and so is its solution:
With nullglob, a pattern with no matches expands to zero arguments and the loop does not iterate. It is the option you should turn on in any script that walks files by pattern. The alternative failglob is even stricter: it aborts the command with an explicit error, useful when the absence of files signals a real problem.
- Classic dangers
The extra space in rm *. This is the most famous accident in Unix:
rm *.bak # deletes the backups
rm * .bak # ← deletes the WHOLE directory, and then complains that ".bak" does not existA single space turns "delete the .bak files" into "delete everything". rm processes the arguments in order, so by the time it shows the error about .bak it has already deleted the contents of the directory. The habit that avoids it: replace rm with ls and review the list first.
Files starting with a dash. If the directory contains a file called -rf, the expansion of rm * places it among the arguments and rm interprets it as an option. The protection, already seen in 02-01, is rm -- * or rm ./*.
rm -rf $DIR/* with an empty variable. If DIR is not defined, the shell expands the line to rm -rf /*. It is the documented cause of several catastrophic deletions in production, and it is prevented with the quoting and default-value techniques of Module 3.
Wildcards in broad absolute paths. rm /var/log/veloz/* looks bounded, but if you mistakenly type /var/log/* the scope changes completely. The higher up the directory is, the slower you should type.
- Hands-on cases on the Veloz Envíos data
# 1. See all the application logs, including the rotated ones
ls -lh /var/log/veloz/*.log*
# 2. Count the archived CSVs for August 2026
ls /srv/veloz/data/archive/2026/08/shipments-2026-08-??.csv | wc -l
# 3. Copy the first fortnight's data to an analysis directory
mkdir -p ~/veloz-ops/tmp/fortnight
cp -a /srv/veloz/data/archive/2026/08/shipments-2026-08-{01..15}.csv \
~/veloz-ops/tmp/fortnight/
# 4. Look for ERROR in every log in the tree (requires globstar)
shopt -s globstar
grep -l ERROR /var/log/veloz/**/*.log
# 5. Backup of the whole toolkit configuration
cp -a ~/veloz-ops/etc/veloz-ops.conf{,.$(date +%F).bak}Case 3 mixes the two mechanisms and is a good summary of the lesson: {01..15} is brace expansion, which generates fifteen text names without looking at the disk; those names are then resolved against real files. If any of the fifteen days did not exist, cp would give an error for that specific file —unlike a * pattern, which would simply not have included it—. That behavior is an advantage: it tells you that a day is missing from the archive, which is exactly what you would want to know.
Common Mistakes and Tips
- Believing the command interprets the wildcard. The shell does. Always check it with
echoin front before a destructive operation. - Quoting a pattern you wanted expanded, or the other way round.
rm "*.log"looks for a file literally called*.log;find . -name *.logwithout quotes fails because the shell expands it beforefindever sees it. - Expecting
*to include hidden files. It does not, and that is a good thing. If you really need them,shopt -s dotglob. - Confusing globbing
*with regular-expression*. In a file pattern it is "anything"; in a regex it is "zero or more repetitions of the previous element". - Writing loops over patterns without
nullglob. They will iterate once over the literal pattern when there are no matches. - Using
**withoutglobstar. Without the option turned on,**behaves exactly like*and does not walk subdirectories; the failure is silent. - One space too many before the wildcard. Double-check any line that combines
rmand*.
Exercises
Exercise 1 — Selecting with precision. In /srv/veloz/data/archive/2026/08 there are files shipments-2026-08-01.csv through shipments-2026-08-31.csv, plus a shipments-2026-08-03.csv.bak and a notes.txt. Write the pattern that selects: (a) every CSV of the month, without the .bak; (b) only days 20 to 29; (c) every file in the directory that does not start with s; (d) the days ending in 1 or 5.
Exercise 2 — Braces versus wildcards. Explain the difference in result between these two commands in a directory containing only shipments-2026-08-01.csv and shipments-2026-08-02.csv, and say which one you would use to detect a day missing from the archive:
Exercise 3 — Preparing a safe script. You are going to write a script that archives every .log file in ~/veloz-ops/logs. Say which shopt options you would turn on and why, and show how you would check without risk which files would be affected before running anything.
Solutions
Solution to Exercise 1
ls shipments-2026-08-??.csv # (a) exactly two digits and ending in .csv
ls shipments-2026-08-2[0-9].csv # (b) days 20 to 29
ls [!s]* # (c) do not start with s → notes.txt
ls shipments-2026-08-?[15].csv # (d) second digit 1 or 5: 01,05,11,15,21,25,31In (a), the trick is the ??: by requiring exactly two characters before .csv, it automatically excludes shipments-2026-08-03.csv.bak, because that name has more text afterwards. A shipments*.csv pattern would have failed in the opposite direction, and shipments* would have included the .bak.
Solution to Exercise 2
The first one simply lists shipments-2026-08-01.csv shipments-2026-08-02.csv. The second lists those two and additionally prints three errors: ls: cannot access 'shipments-2026-08-03.csv': No such file or directory, and the same for the 04th and the 05th.
Globbing consults the disk and returns only what exists: by definition it can never tell you about an absence. Brace expansion generates the five names blindly and lets ls complain about the three that are not there. So, to detect missing days in the archive you use the second form: the errors are precisely the result you are after. It is a good example of how "producing an error" can be the desired behavior.
Solution to Exercise 3
shopt -s nullglob # if there are no .log files, the loop does not iterate instead of doing it once
shopt -s failglob # ALTERNATIVE: abort with an error if there are no matches
shopt -u dotglob # make sure hidden files are not dragged in
# Risk-free check, before touching anything
cd ~/veloz-ops/logs
echo *.log # see the exact list the command would receive
ls -lh *.log # also see the size and date of each onenullglob is the right choice for a routine archiving script: if one day there are no logs to archive, the expected thing is for the script to do nothing, not to fail. failglob would be preferable in a script where the absence of files signals an anomaly —for example, if veloz-api must always produce logs and there are none—, because then silence would be the worst possible outcome. Both solve the same problem with opposite criteria, and choosing well depends on whether "there are no files" is normal or alarming in your case.
The check with echo is the key habit: it shows exactly the list of arguments the real command will receive, with the same pattern, in the same directory and with the same shell options active.
Conclusion
You now know that wildcards are expanded by the shell and not by the command, and you have verified with echo that by the time rm or cp run they already receive the complete list of names. You handle *, ?, the [...] sets with negation and ranges, and the POSIX classes that are robust against accents. You tell brace expansion —which manufactures text without looking at the disk, and is therefore good for creating and for detecting absences— apart from globbing, which only selects what exists. You know how wildcards and regular expressions differ, you tune the behavior with shopt (nullglob, failglob, dotglob, nocaseglob, globstar) and you know the default behavior for a pattern with no matches, which is the silent cause of so many broken loops.
With this you close the module's file-manipulation tools. Searches by criteria that a name pattern cannot express —date, size, permissions, depth— are find territory, in 05-01.
One last piece remains, and it is purely about productivity. In lesson 02-06 you will learn to move around the terminal at a professional's speed: reusing commands from history, searching backwards with Ctrl-R, editing the line without arrow keys and automating your day-to-day with aliases. It is the lesson that closes the module and leaves you ready to make the jump from the terminal to the script.
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
