>
Open Source

zsh’s numeric glob operator beats bash for disk-rotation backups

If your disk is constantly near full and you back files up in batches as new ones land, the shell glob you reach for is usually the wrong shape. Bash’s ? (single character) and [0-9] (character class) get you part of the way, but they collapse as soon as your “every fifth file” batch has to span a range and skip numbers that do not exist. zsh has a glob primitive for exactly this case, and it has been hiding in zshexpn (the zsh expansion manual page) for decades.

The <x-y> glob operator is the cleanest answer for numeric ranges in a single shot. Combine it with set -o extendedglob and the (^*[0-9]) anchor (match a non-digit boundary before the number) and you get a glob that survives variable substitution, character-set expansion, and the small-but-important “files I am still downloading” problem. The recipe generalizes well beyond episode files: any directory that gets new items on a schedule and needs rotation cleanup is a target.

What bash gets wrong on this

The standard bash approach for “match file names 10 through 14” is something like *[0-9]1[0-4]* or character classes that need to know the digit count up front. Four problems show up immediately:

  • Width coupling. [0-9]1[0-4] only matches two-digit numbers 10 through 14. The moment a file lands named File name 100 something, your glob silently keeps matching it because [0-9]1[0-4] is satisfied by the leading digit of 100. You do not want to back up the same file twice, but that is exactly what happens on the first rotation cycle.
  • Brace expansion is not a glob. {10..14} works on literal strings, not filenames. It generates the strings 10, 11, 12, 13, 14 as input to the glob, but only at the position you wrote it. If your filename has the number in the middle, brace expansion does not help. A pattern like Episode_{10..14}.mp4 works; a pattern like Episode_*_{10..14}* does not.
  • Variables in ranges. POSIX glob has no way to take first=10 last=14 and emit a numeric range. You end up writing a for loop, generating string names manually, and rebuilding the glob from pieces. The code gets longer than the work it is doing.
  • No freshness check. bash globs are static string matchers. They have no notion of “this file was modified 1.2 seconds ago, leave it alone.” You either pay for a find -mmin pipeline after the glob or you risk copying a half-written file and corrupting the backup.

zsh’s <x-y> solves the first three because it operates on the numeric value of the matched substring, not on its string representation. The fourth problem gets its own primitive, and it lives in the same expansion syntax.

The primitive that does the work

Two pieces have to be in place before the operator is available:

set -o extendedglob   # turns on the (^*[0-9]) anchor and a few other features

Then:

*File name(^*[0-9])<10-14>\ *

reads left to right as: any prefix, then File name (space escaped), then any non-digit-ending sequence (the (^*[0-9]) qualifier), then a number that falls in the inclusive range 10 through 14, then a space, then any suffix. The non-digit anchor matters because without it <10-14> would happily match inside 210, 1100, or any number containing 10 as a substring. A directory full of 1, 10, 100, 1000 and friends will surprise you on the first run without it.

One thing to know about <x-y>: it cannot take variables directly the way bash brace expansion can. The angle-bracket operator is a glob flag, not a parameter substitution, so <$first-$last> is a syntax error. The workaround is $~range, which forces parameter expansion of a string that contains glob metacharacters:

set -o extendedglob
first=10
last=14
name='File name'
range="<$first-$last>"
mv -vi -- **/*$name(^*[0-9])$~range\ * /media/username/hdd/backups/

$range is the literal string <10-14>. $~range tells zsh to re-expand that string as a glob pattern, not as a fixed string. This is the one zsh-only trick that makes the whole pattern usable in a script. Without $~, you would be trying to move files whose names literally contain the text <10-14>, which is almost never what you want.

The half-finished download problem

The use case in the original question is a near-full disk with files landing constantly. Even with a perfect numeric range, a file that arrived 1.2 seconds ago is probably still being written to. Moving it to the backup drive mid-write corrupts both copies. zsh has a second glob feature for this:

*(.cs+3)

The (.cs+3) is a glob qualifier, applied to the preceding pattern:

  • . means only match regular files, not directories, sockets, or devices.
  • c means check the inode change time (the file write time, not read time).
  • s+3 means the change time is at least 3 seconds in the past.

A file that finished downloading 4 seconds ago qualifies. A file that is still being written does not. Combined with the numeric range, the full pattern becomes:

mv -vi -- **/*File\ name(^*[0-9])<10-14>(.cs+3)\ * /media/username/hdd/backups/

The qualifier lives inside the glob, so it filters before mv ever sees the filename. No find pipeline, no xargs, no race-prone file-locking. If you have ever written a backup script that quietly corrupted a download-in-progress, the simplicity of this pattern is the fix you wanted.

The “every five files” loop

The original problem was rotation: add 5, remove 5, repeat. A for loop with an incrementing range covers it cleanly:

set -o extendedglob
name='File name'
for ((i = 1; ; i += 5)); do
  range="<$i-$((i + 4))>"
  mv -vi -- **/*$name(^*[0-9])$~range(.cs+3)\ * /media/username/hdd/backups/
  read -q '?continue? ' || break
done

The (($i + 4)) arithmetic gives a five-file window starting at $i. The (.cs+3) qualifier keeps partially downloaded files out of every iteration. The read -q prompt makes the loop interactive without dragging in a separate confirmation script. Answer y to continue, anything else to stop.

For an unattended cron job, drop the read -q and let the loop run to completion. For a manual run on a workstation where you want to eyeball the mv -v output before each batch, keep the prompt in. The same loop body works for both.

Trade-offs

zsh-only. The <x-y> operator and the (.cs+3) qualifier do not exist in bash, fish, or POSIX sh. If your backup script has to run on systems where you cannot guarantee a login shell is zsh, this pattern is not portable. The bash equivalent is a for loop generating per-number globs, which is longer and slower and loses the freshness check entirely. The right way to make this portable is to ship zsh with the script and chsh the relevant user, or to put a #!/usr/bin/env zsh shebang on the script and accept that it runs zsh.

Numeric anchors are coarse. <10-14> matches every integer in the range, including 010, 0010, and 10 padded to any width. That is usually what you want for episode numbers, but if your filenames have hex or alphanumeric suffixes (like version-1a-2b), this glob will not catch them. The (^*[0-9]) boundary anchor helps but does not fix the suffix case. For hex ranges, the analog is <0x10-0x14> and it works the same way, but you have to opt in explicitly.

The (^*[0-9]) qualifier is a glob, not a regex. It uses zsh’s extended-glob dialect, which is similar to but not identical to PCRE or ERE. If you are already fluent in regex, expect a small adjustment period. The ^ is “not,” the * inside parentheses is “any sequence,” and the whole thing is a flag on the preceding pattern, not a standalone matcher. The most common mistake is writing ^[0-9] thinking it anchors the start of the filename, when in fact the parentheses already anchor it to that position.

Glob qualifiers read right-to-left. (.cs+3) is parsed as . (regular file), then c (change time), then s+3 (seconds-since-change, +3 minimum). A common mistake is to write it as (.cs+3) thinking +3 modifies . (it does not). If you are copy-pasting from this article, check the order in your own scripts and run echo *(.) first to confirm the qualifier syntax is loaded.

What I would tell past me

The mistake I made for years was reaching for bash character classes first, then fighting them when two-digit and three-digit numbers collided in the same directory. The <x-y> operator has been in zsh since the late 1990s and I had read the zshexpn page a dozen times without registering it. Numeric ranges are exactly the use case it exists for, and the manual even calls it out, but it is easy to skip past in search of something more exotic.

The second lesson is the (.cs+3) qualifier. Any time a glob is selecting files that another process is still writing to, a freshness check belongs in the glob itself, not in a follow-up find -mmin pipeline. Doing it in the glob is one process, no race condition, and the qualifier chain reads left-to-right like English once you get used to the parsing order.

If your shell is still bash and you do not want to switch, the closest portable substitute is a find call with -mmin +1 and a numeric range expressed as -name "*[0-9][0-9]" plus a Python or awk filter. It works, but it is about four times the code and roughly twice the runtime, and the freshness check becomes a separate process you have to coordinate with the move. Acceptable for a one-off script, painful for anything that runs more than once a week.

Leave a comment