Zsh Glob Qualifiers: Precise File Selection Without find
Zsh glob qualifiers filter file matches by type, size, and age directly in the pattern — no find pipeline needed. Learn the syntax, a worked example, and the no-match trap.
01 Jun 2026, 16:55 UTC

If you write shell scripts that select files by size, age, or type, you probably reach for find with a chain of predicates, or worse, parse ls output. Zsh can do most of that selection inline, in the glob itself, using recursive patterns and glob qualifiers — parenthesized filters applied after expansion. The result is shorter, safer code that handles spaces in filenames correctly and never forks an external tool.
The mechanism: expansion, then filtering
When zsh sees a pattern like **/*.log(.Lm+1m-30), it works in two phases. First, the pathname part **/*.log expands: **/ matches directories at any depth, so the pattern reaches every .log file in the tree. Second, the qualifier group in parentheses filters and optionally sorts the resulting list. Each qualifier is a short code:
.— regular files only (/means directories,@symlinks)Lm+1— size larger than 1 MB (Lk+50would be 50 KB;+means “more than”,-means “less than”)m-30— modified within the last 30 days (m+30would be older than 30 days)N— if nothing matches, expand to an empty list instead of raising an errorom— order matches by modification time; combine with[1,5]to take a slice
Qualifiers compose left to right, and order matters. *(om[1,5]) sorts everything by mtime first, then keeps the five oldest. Writing *([1,5]om) would take five arbitrary files and then sort only those five — usually not what you want.
A worked example: log cleanup candidate list
Say you want to inspect large, recently modified log files anywhere under a project directory before deciding what to archive. In a zsh interactive shell or script (zsh 5.x, any recent version):
# Run in zsh; no special options needed for this pattern
print -l /var/log/myapp/**/*.log(.Lm+1m-30N)
This prints one path per line: regular files (.) ending in .log, larger than 1 MB (Lm+1), modified in the last 30 days (m-30), at any depth under the given directory. The N qualifier means an empty result prints nothing instead of aborting with an error. The equivalent find command — find /var/log/myapp -name '*.log' -type f -size +1M -mtime -30 — works too, but the zsh version stays in the shell, so you can sort, slice, and loop without a pipeline.
For scripting, capture the matches in an array so filenames with spaces survive intact:
#!/usr/bin/env zsh
# No root needed; reads only directory metadata
files=( /var/log/myapp/**/*.log(.Lm+1m-30N) )
(( $#files )) || { print 'no candidates'; exit 0 }
for f in $files; do
print -- "$f: $(stat -c %s "$f") bytes"
done
Because the glob expands directly into array elements, each filename is one element regardless of spaces or newlines — the classic for f in $(find ...) word-splitting bug cannot happen here. To verify the selection is what you expect, create a scratch tree with files of known sizes and timestamps using truncate -s and touch -d, then confirm the pattern selects exactly that subset.
The no-match trap when porting from bash
The single most common zsh scripting breakage is no-match behavior. In bash, an unmatched glob passes through literally (echo *.xyz prints *.xyz). In zsh, an unmatched glob aborts the whole command with no matches found — and in a script, that can terminate execution mid-function. Three fixes, in order of preference:
- Add the
(N)qualifier per pattern:files=( *.tmp(N) ). Local, explicit, and safe. setopt NULL_GLOBat the top of a script to make all unmatched globs expand to nothing.- Test before expanding, if you need to distinguish “no files” from other conditions.
You can confirm the difference in an empty directory: echo *.nomatch errors, while echo *.nomatch(N) prints a blank line.
Extended glob operators and their cost
Separate from qualifiers, zsh offers extended glob operators: ^*.tmp (everything except .tmp files), *.txt~*test* (text files excluding those with “test” in the name), and foo# (zero or more repetitions). These require setopt EXTENDED_GLOB, which is off by default and off in sh emulation mode. Check with setopt | grep -i extendedglob.
Be careful enabling it globally: EXTENDED_GLOB changes the meaning of #, ^, and ~ in patterns, so existing scripts or habits that use those characters literally (for example, a filename like draft#2.txt) can suddenly behave differently. Prefer enabling it inside a function with setopt localoptions extendedglob so the setting reverts when the function returns.
Limits and common mistakes
- Expansion happens before the command runs. A recursive
**/*over a huge tree builds the full list in memory first; on a tree with hundreds of thousands of files this is slow and can hitARG_MAXwhen passed to an external command. For very large trees,find ... -print0 | xargs -0remains the right tool. **/does not follow symlinked directories. Use***/if you need that, knowing it can loop on cyclic links.- Qualifier order is significant. Sorting qualifiers like
ommust precede subscript slices like[1,5]if you want the slice taken from the sorted list. - Qualifiers apply to the final path component.
*(/)filters the matched names, not intermediate directories in the pattern. - Syntax is stable but version-dependent at the edges. Everything shown here is long-standing zsh 5.x behavior; confirm with
zsh --versionand a quickprint -l **/*(.)test in a scratch directory before relying on it in production scripts.
The practical takeaway: for day-to-day file selection in zsh scripts, put the filtering in the glob with qualifiers and always add N when an empty result is acceptable. You get find-like precision, array-safe filenames, and one less external process — as long as you respect the no-match default and the cost of deep recursive scans.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.