MA-CLI1 · Module 1.9
Finding files
Topics: Globs · find · Wildcards
Overview
Module 1.9 — Finding files
A project folder can hold dozens of files spread across several levels, and "I know it's in there somewhere" is not a plan. This module gives you two ways to name files by pattern instead of one at a time: shell globs, which the shell expands for you, and find, which walks a whole tree and reports what it meets.
Sam's tidewatch project has files for several gauges, an archive, and a scripts folder. Time to find things on purpose.
By the end you should be able to:
- use
*,?, and[ ]in globs, and know when to quote them; - run
findwith a starting path,-name,-type for-type d, and a light-mtimetest; - explain
findas "walk the tree, test each entry, print the ones that pass"; - say how
finddiffers fromls -R, and why you should list before you ever think of-delete.
Scroll Core ideas → worked examples → Quiz. Plan on roughly 60 minutes, with a terminal open in ~/cli-practice/tidewatch/.
Concepts
Core ideas — naming files by pattern, and searching a tree
So far Sam has named every file explicitly: notes/field_log.txt, data/raw/gauge_a.csv. That works when you know exactly what you want. But real questions sound different: "all the CSV files," "every script," "anything not touched since last season." To ask those, you need to describe files by a pattern or by a property rather than by a full name.
The shell gives you two complementary tools for this. Globs are short wildcard patterns that the shell expands into file names before the command runs. find is a separate command that walks through a folder tree, looks at each entry, and prints the ones that pass your tests. They overlap a little, but they answer different kinds of questions, and knowing which to reach for is most of the skill.
Globs: wildcards the shell expands
A glob is a pattern containing special characters. When you type one on a command line, the shell (not the command) replaces it with the list of matching file names, and then runs the command with that list. The command itself never sees the star; it sees the names.
* — any number of characters
ls data/raw/*.csv
The * stands for "anything, including nothing." So *.csv matches every name ending in .csv. If data/raw/ holds gauge_a.csv, gauge_b.csv, and gauge_c_old.csv, the shell turns your command into:
ls data/raw/gauge_a.csv data/raw/gauge_b.csv data/raw/gauge_c_old.csv
and then runs it. You can see this expansion for yourself with echo data/raw/*.csv, which just prints the matched names. That is a wonderful safety habit: echo a glob first to preview what it will touch.
? — exactly one character
ls data/raw/gauge_?.csv
Each ? matches precisely one character. So gauge_?.csv matches gauge_a.csv and gauge_b.csv but not gauge_c_old.csv, because c_old is several characters where the pattern allows one.
[ ] — one character from a set
ls data/raw/gauge_[ab].csv
The brackets match exactly one character, chosen from the ones inside. [ab] means "a or b." You can also give a range, such as [a-c] for a, b, or c, or [0-9] for any digit.
What globs do not do
- A glob matches names in one folder level at a time.
*.csvdoes not look inside subfolders. (data/*/*.csvreaches one level down, because you spelled out the middle folder.) - By convention,
*does not match names that begin with a dot, so hidden files are skipped unless you write the dot yourself. - Globs match names, not contents. They cannot tell you which files mention a gauge, only which names look a certain way (for contents, use
grepfrom the last module).
When a glob matches nothing
Different shells behave differently when a pattern matches no files. In bash, the pattern is usually passed along unchanged, so ls zzz*.csv reports that a file literally named zzz*.csv does not exist. In zsh (the default on many modern Macs), the shell stops first and says "no matches found." Either way, the lesson is the same: if you see the star come back in an error message, nothing matched. Check your spelling and your folder with pwd and ls.
Quoting globs
Quotes switch the shell's expansion off. Inside single or double quotes, * is just a star character handed to the command untouched.
echo *.csv # the shell expands the glob
echo '*.csv' # prints the literal text *.csv
Usually you want the shell to expand globs, so you leave them unquoted. But there is one important case where you must quote: when the pattern is meant for a command that does its own matching. The prime example is find -name, which you will meet in a moment. If you write find . -name *.csv without quotes and your current folder happens to contain CSV files, the shell expands the star first and find receives a jumble of names instead of the pattern, which typically produces a confusing error. Writing find . -name '*.csv' hands find the pattern itself.
Quote also when a name contains a space, such as 'Field Notes.txt'; otherwise the shell treats it as two separate words.
A simple rule of thumb: let the shell expand globs when it should choose the files; quote them when a command should interpret the pattern.
find: walk a tree and test each entry
find <where to start> <tests> <action>
find begins at the path you give it, visits every file and folder underneath, at every depth, and applies your tests to each one. Entries that pass are handed to the action. If you give no action, the default is to print the path.
The simplest possible use is to start somewhere and give no tests at all:
find .
This prints the starting folder and everything inside it, one path per line. Each path begins with the starting point you typed, so find . produces paths like ./notes/field_log.txt, while find notes produces paths like notes/field_log.txt. The paths show you where each thing lives.
Test by name — -name
find . -name '*.csv'
Read it as: "start here, and print every entry whose name matches *.csv." The pattern uses the same glob characters you just learned, but now find interprets them (that is why they are quoted). The match is against the file name only, not the folders above it, and it is case-sensitive; -iname ignores case.
Test by kind — -type f and -type d
-type fselects regular files.-type dselects directories.
find . -type d
find . -type f -name '*.txt'
The first lists only folders, which gives a clean picture of the tree's shape. The second says "files only, ending in .txt." Tests written one after another are combined with AND, so an entry must pass all of them.
Test by age — -mtime (a light touch)
-mtime looks at when a file was last modified, counted in whole days:
-mtime +30— modified more than 30 days ago (older).-mtime -2— modified less than 2 days ago (recent).-mtime 0— modified within the last day.
find . -name '*.csv' -mtime +30
This asks for CSV files untouched for over a month: a handy way to spot stale data. The rules about rounding days can feel fussy, so treat -mtime as a rough filter and double-check with ls -l when the exact day matters.
The mental model: walk, test, print
Picture a very patient helper who walks through your folders. At every entry, the helper asks the questions you wrote, in order: "Is it a file? Does its name end in .csv? Is it older than thirty days?" If every answer is yes, the helper prints the path (-print) and moves on. If any answer is no, the helper just continues walking.
In that picture, -print is the action. It is the default, so you rarely type it, but writing it makes the model explicit:
find data -name 'gauge_?.csv' -print
Thinking this way also helps you predict results. If you get nothing, ask which question failed for every entry (wrong name pattern? wrong type? starting in the wrong place?). If you get too much, add a test.
Two practical notes. First, the order of results is not guaranteed; it follows how the folders are stored. If you need a stable order, add | sort. Second, find produces plain text, one path per line, which means it combines naturally with the pipes from the last module, for example find . -name '*.csv' | wc -l counts how many CSV files exist anywhere in the tree.
If the starting path does not exist, find writes an error to stderr and finds nothing, which is exactly the behaviour you studied in the previous two modules.
Contrast with ls -R
In module 1.6 you used ls -R (and ls -lR) to see a whole tree. Both ls -R and find can cover the full depth, but they are built for different jobs.
| | ls -R | find |
|---|---------|--------|
| Purpose | display a tree, folder by folder | search a tree for entries that pass tests |
| Output shape | groups with folder headings | one full path per line |
| Filter by name pattern | awkward | built in (-name) |
| Filter by type or age | not really | built in (-type, -mtime) |
| Easy to pipe into wc, grep, sort | clumsy because of headings | natural, one path per line |
If you want to read the layout, ls -R is friendly. If you want to ask a question about the tree, find is the right tool. Because every line of find output is a self-contained path, it is also much easier to hand to the next command.
Safety: list first, act later
find can do more than print. It has actions such as -delete, which removes every entry that passes the tests. That power deserves respect, and here is why.
- Deletion is permanent. As with
rm, there is no trash can. - Tests that look right can be wrong. A pattern slightly too broad, or a start folder one level too high, and you may remove things you never meant to.
- Order of arguments can change the meaning. With
-delete, putting it before your tests can cause it to apply to everything.
So build the habit of two passes. First, run the find with plain printing and read the list carefully, perhaps counting it with | wc -l. Only if every line is something you are happy to lose, consider an action. For a beginner, the best default is simply to never use -delete and to remove files in a separate, deliberate rm command after reviewing a list. The listing itself is already the useful result.
Common pitfalls
- Forgetting the quotes in
find -name. The shell may expand the star beforefindsees it. - Expecting
*.csvto search subfolders. A glob covers one folder level;findcovers the whole tree. - Expecting hidden files to match
*. Names starting with a dot are skipped. - Searching from the wrong folder. Check with
pwd; startfindas close to your target as you can. - Trusting result order. Add
| sortif order matters. - Reaching for
-deleteearly. List first, and then decide. - Quoting too much. If you quote a glob meant for the shell, it will not expand.
Self-study checklist
- Explain the difference between
*,?, and[ab]with one example each. - Use
echoto preview a glob before applying it to a command. - Say why
find . -name '*.csv'needs quotes. - Write a
findcommand for "all regular files ending in.txtundernotes." - Explain
-mtime +30in your own words. - Describe
findas "walk the tree, test each entry, print the ones that pass." - Name two differences between
ls -Randfind. - Describe the two-pass habit that keeps
findsafe.
Examples
Worked examples — locating things in the tidewatch tree
The tidewatch project now has data for several gauges, an archive, and a handful of scripts. Sam needs to find files by pattern and by age. Each scene gives a situation, the commands, an interpretation, and a takeaway. The order in which find lists things can differ on your machine, so the examples add | sort where order matters.
First, a small setup so you can follow along:
cd ~/cli-practice
mkdir -p tidewatch/notes tidewatch/data/raw tidewatch/data/processed tidewatch/archive tidewatch/scripts tidewatch/logs
cd tidewatch
touch notes/field_log.txt notes/private_draft.txt 'notes/Field Notes.txt'
touch data/raw/gauge_a.csv data/raw/gauge_b.csv data/raw/gauge_c_old.csv data/processed/gauge_a_clean.csv
touch archive/gauges_2023.csv scripts/sync_readings.sh scripts/check_gauges.sh logs/events.log logs/warnings.txt
touch -t 202301010000 archive/gauges_2023.csv data/raw/gauge_c_old.csv
The last line gives two files an old modification date (January 2023), so the age tests have something to find.
Scene 1 — Preview a glob before you use it
Scene. Sam wants to list the raw gauge files without typing each name, and wants to be sure what the pattern covers.
echo data/raw/*.csv
echo data/raw/gauge_?.csv
echo data/raw/gauge_[ab].csv
data/raw/gauge_a.csv data/raw/gauge_b.csv data/raw/gauge_c_old.csv
data/raw/gauge_a.csv data/raw/gauge_b.csv
data/raw/gauge_a.csv data/raw/gauge_b.csv
Interpretation. echo simply prints whatever the shell hands it, so it shows the expansion. The * caught all three files. The ? and [ab] patterns each caught exactly one character after gauge_, so gauge_c_old.csv, with its longer tail, fell outside. Now Sam can safely run ls -l data/raw/gauge_?.csv, knowing the shell will turn it into those two names.
Takeaway. echo plus a glob is a free preview. Use it before any command that changes files.
Scene 2 — Quotes and empty matches
Scene. Sam compares a quoted and unquoted star, and then tries a pattern that matches nothing.
echo '*.csv'
ls zzz*.csv
*.csv
ls: zzz*.csv: No such file or directory
Interpretation. In the first command, quotes switched expansion off, so the star was printed as a plain character. In the second, no file name begins with zzz. In bash the pattern is passed through unchanged, which is why the error repeats the star. In zsh you would instead see "no matches found". Either way, a star appearing in an error message is a clue that nothing matched.
Takeaway. Unquoted globs are for the shell to expand; quoted globs are for commands that interpret patterns themselves. An error that echoes your pattern back means "no match."
Scene 3 — Walking the whole tree with find
Scene. Sam wants the shape of the project and then every CSV anywhere in it.
find . -type d | sort
find . -name '*.csv' | sort
find . -type f -name '*.txt' | sort
.
./archive
./data
./data/processed
./data/raw
./logs
./notes
./scripts
./archive/gauges_2023.csv
./data/processed/gauge_a_clean.csv
./data/raw/gauge_a.csv
./data/raw/gauge_b.csv
./data/raw/gauge_c_old.csv
./logs/warnings.txt
./notes/Field Notes.txt
./notes/field_log.txt
./notes/private_draft.txt
Interpretation. The first command prints only directories, which gives a clean outline of the tree. The second walks every level and prints each entry whose name ends in .csv; a plain ls *.csv from this folder would have found none, because a glob covers only one level. The third combines two tests (a regular file and a name ending in .txt), so folders are excluded even if one were named something.txt. Every path begins with . because that is where Sam started.
Takeaway. find searches the whole depth, and tests stack with AND. Read it as: walk, test, print.
Scene 4 — Stale data, counted and listed
Scene. Sam suspects some data files are very old and wants to know how many CSVs there are overall, and which of them have not been touched in a month.
find . -name '*.csv' | wc -l
find . -name '*.csv' -mtime +30 | sort
5
./archive/gauges_2023.csv
./data/raw/gauge_c_old.csv
Interpretation. The first pipeline counts paths, one per line, so it reports five CSV files. The second adds the age test, and only the two files given the 2023 date pass. Notice how naturally find joins the pipes from the previous module: its output is one path per line, which is exactly what wc -l and sort expect. If Sam wants to be sure about the dates, ls -l archive data/raw shows them.
Sam also looks for the file whose name contains a space:
find notes -name 'Field*'
notes/Field Notes.txt
Takeaway. -mtime +30 is a rough "older than a month" filter. Quote any pattern or name that contains spaces or wildcards.
Scene 5 — The unquoted star, and listing before acting
Scene. Sam moves into data/raw and makes two decisions, one careless and one careful.
cd data/raw
find . -name *.csv
find: gauge_b.csv: unknown primary or operator
Interpretation. The exact message varies between systems. Because this folder contains CSV files, the shell expanded *.csv into three names before find ran, so find received -name gauge_a.csv gauge_b.csv gauge_c_old.csv and could not make sense of the extra words. Quoting fixes it:
find . -name '*.csv'
./gauge_a.csv
./gauge_b.csv
./gauge_c_old.csv
Now the careful decision. Sam wants to clear out the old file, and remembers there is a -delete action. Instead of reaching for it, Sam goes back up a level and lists what such a command would affect:
cd ../..
find . -name '*.csv' -mtime +30 -print | sort
./archive/gauges_2023.csv
./data/raw/gauge_c_old.csv
Reading the list, Sam notices that archive/gauges_2023.csv is a deliberate keepsake. A blanket delete would have removed it. So Sam removes only the one file intended, with a separate and explicit command:
rm data/raw/gauge_c_old.csv
Takeaway. Quote patterns meant for find. List before you act, read the list, and delete with a command that names only what you mean to lose.
Practice check — finding files
Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.
1.`data/raw/` contains `gauge_a.csv`, `gauge_b.csv`, and `gauge_c_old.csv`. Which pattern matches only the first two?
2.What does `ls data/raw/gauge_[ab].csv` match?
3.Why should you write `find . -name '*.csv'` with quotes around the pattern?
4.Which command lists only regular files under `notes/` whose names end in `.txt`?
5.What does `find . -name '*.csv' -mtime +30` report?
6.Which statement best describes how `find` differs from `ls -R`?
7.You want to remove old CSV files with `find`. What is the sensible first step?