The "zip every subdirectory into its own archive" pattern shows up in three places: per-customer backup folders that need to be shipped separately, per-project source trees that get archived before a tooling migration, and per-month log directories that get rotated to cold storage. The canonical Linux one-liner is for d in */; do zip -r "${d%/}.zip" "$d"; done, but the moment you cross over to macOS (BSD zip, with subtly different behavior around symlinks), Windows (no zip in PATH, but Compress-Archive exists), or large datasets (where tar czf beats zip on speed and ratio), the right answer changes. Below: every variant I actually use, plus the encryption, restore, and "skip empty directories" patterns.
How do I ZIP each directory into its own file?
The canonical Bash one-liner is for d in */; do zip -r "${d%/}.zip" "$d"; done: it iterates over every subdirectory in the current folder, strips the trailing slash from the directory name, and writes dirname.zip for each one. The -r flag is recursive (includes subdirectories and their contents). For tar.gz output, swap to for d in */; do tar czf "${d%/}.tar.gz" "$d"; done. On Windows PowerShell, the equivalent is Get-ChildItem -Directory | ForEach-Object { Compress-Archive -Path $_.FullName -DestinationPath "$($_.Name).zip" }. For encrypted archives, use zip -er (Info-ZIP's symmetric encryption, weak by modern standards) or 7z a -p -mhe=on (AES-256, the actually-secure option). Always verify the resulting archive with unzip -l or tar tzf before deleting the source directories. For the underlying loop syntax, see Bash For Loops.
Jump to:
- The canonical zip one-liner
- tar.gz: the cross-platform standard
- PowerShell Compress-Archive on Windows
- Skipping empty directories
- macOS BSD zip vs Linux Info-ZIP
- Encryption at rest: zip -e vs 7z
- Excluding file types and paths
- Restore patterns
- Common pitfalls
- What to do next
- FAQ
The canonical zip one-liner
In the parent directory that holds the folders you want to archive:
for d in */; do
zip -r "${d%/}.zip" "$d"
doneWhy each piece is there:
*/matches only directories (the trailing slash is a glob qualifier)."${d%/}"strips the trailing slash, soProject1/becomesProject1and the output isProject1.zip(notProject1/.zip).-rrecurses into subdirectories. Without it, only the top-level files of each folder go into the archive.- The directory variable is double-quoted so spaces in folder names don't break the loop.
To run a faster check first, drop the -r flag: the archives will contain only the files directly inside each folder, no nested subdirectories. Most of the time the recursive form is what you want.
If you'd rather iterate over an explicit list (e.g., only specific folders), use the array form:
dirs=("customer-a" "customer-b" "customer-c")
for d in "${dirs[@]}"; do
zip -r "${d}.zip" "$d"
doneFor the full pattern reference, including arrays, brace ranges, and the parallel xargs -P variant, see Bash For Loops.
tar.gz: the cross-platform standard
For Linux/macOS-to-Linux/macOS workflows, tar.gz (or tar.xz for tighter compression at higher CPU cost) is almost always the better choice than ZIP. Reasons: better compression on text-heavy content, preserves Unix permissions and symlinks correctly, single-stream (no central directory rebuild on append), and decompression is a single tool (tar) that's everywhere.
for d in */; do
tar -czf "${d%/}.tar.gz" "$d"
done-ccreate-zgzip-compress on the fly-f FILEwrite to FILE (must come last among the flags)
For tighter compression at the cost of speed:
for d in */; do
tar -cJf "${d%/}.tar.xz" "$d"
done-J swaps gzip for xz. For very compressible data (logs, JSON, source code), xz can be 30 to 50 percent smaller than gzip at 4 to 6 times the CPU cost.
For absolute speed on multi-core machines, pair tar with pigz (parallel gzip):
for d in */; do
tar -cf - "$d" | pigz -p 8 > "${d%/}.tar.gz"
donepigz -p 8 uses 8 threads. The combined pipeline writes to disk once and saturates the CPU.
PowerShell Compress-Archive on Windows
PowerShell 5.0+ ships with Compress-Archive. The equivalent of the Bash one-liner:
Get-ChildItem -Directory | ForEach-Object {
Compress-Archive -Path $_.FullName -DestinationPath "$($_.Name).zip"
}What each piece does:
Get-ChildItem -Directoryenumerates just the subdirectories (no files at the top level).ForEach-Object { ... }is PowerShell's per-item loop.$_.FullNameis the absolute path of the directory.$_.Nameis the directory name without the path;"$($_.Name).zip"is string interpolation.
Compress-Archive is fine for ad-hoc work but slow on large folders (it builds the entire central directory in memory). For multi-gigabyte archives on Windows, install 7-Zip and use its CLI:
Get-ChildItem -Directory | ForEach-Object {
& "C:\Program Files\7-Zip\7z.exe" a "$($_.Name).7z" $_.FullName
}7z's CLI uses native parallelism and produces noticeably smaller archives than Compress-Archive.
Skipping empty directories
Empty folders still get a ZIP file (just with no contents) by default. To skip them, test the directory before archiving:
for d in */; do
if [ -n "$(ls -A "$d" 2>/dev/null)" ]; then
zip -r "${d%/}.zip" "$d"
else
echo "Skipping empty: $d"
fi
donels -A lists hidden files except . and ... If the output is empty, the directory is empty and we skip it. The -n test (string is non-zero length) flips the condition cleanly. For more control flow patterns, see Bash If/Else.
A faster alternative using find:
for d in */; do
if find "$d" -mindepth 1 -print -quit | grep -q .; then
zip -r "${d%/}.zip" "$d"
fi
donefind -print -quit exits after the first match, so the test is fast even on huge directories. See find Command Cheat Sheet for more -print -quit patterns.
macOS BSD zip vs Linux Info-ZIP
macOS ships a BSD-derived zip binary by default; most Linux distros ship Info-ZIP. The differences that bite:
| Behavior | Linux (Info-ZIP) | macOS (BSD zip) |
|---|---|---|
| Symlinks | Stored as symlinks with -y | Stored as symlinks with -y; followed by default |
.DS_Store filtering | Not relevant | Not filtered automatically: add -x '*.DS_Store' |
__MACOSX resource forks | Not relevant | Added automatically: avoid by using ditto -ck or tar instead |
| Encryption strength | Info-ZIP supports -e (Zip 2.0, weak) and AES via patched builds | BSD supports -e only: for stronger encryption use 7z |
unzip behavior | GNU-compatible | BSD-compatible; mostly identical for common flags |
To suppress the macOS resource forks when sending archives to Linux peers, use ditto:
for d in */; do
ditto -c -k --sequesterRsrc "$d" "${d%/}.zip"
doneditto is Apple's preferred archive tool. -c -k writes a PKZip-format archive, and --sequesterRsrc packs Mac-specific resource forks into a __MACOSX folder that Linux/Windows can ignore.
Encryption at rest: zip -e vs 7z
zip -e (or -er for recursive + encrypted) uses Zip 2.0 encryption. It's CRC-based, and modern password crackers chew through it. Don't use it for anything sensitive.
for d in */; do
zip -er "${d%/}.zip" "$d" # prompts for password per archive
doneFor real encryption, switch to 7-Zip with AES-256 and encrypted file names:
for d in */; do
7z a -p"$PASSWORD" -mhe=on "${d%/}.7z" "$d"
done-psets the password (without a value, 7z prompts).-mhe=onencrypts the file headers: without this, the file names inside the archive are visible to anyone with the archive.
For end-to-end encryption when shipping archives over the network, pair this with GPG:
for d in */; do
tar -czf - "$d" | gpg --symmetric --cipher-algo AES256 -o "${d%/}.tar.gz.gpg"
donegpg --symmetric prompts for a passphrase once per archive (or read it from --passphrase-fd for non-interactive use).
Excluding file types and paths
To skip .tmp files, log archives, node_modules, or anything else inside each folder:
for d in */; do
zip -r "${d%/}.zip" "$d" \
-x "*.tmp" \
-x "*.log" \
-x "*/node_modules/*" \
-x "*/.git/*"
done-x PATTERN excludes paths matching the pattern. Patterns are glob-style and matched against the full path inside the archive.
The tar equivalent uses --exclude:
for d in */; do
tar -czf "${d%/}.tar.gz" \
--exclude='*.tmp' \
--exclude='*.log' \
--exclude='node_modules' \
--exclude='.git' \
"$d"
doneGNU tar's --exclude matches against path components. On BSD tar (macOS default), the syntax is the same but the matching is occasionally less forgiving: --exclude='*/node_modules' is safer than --exclude='node_modules' if you want to match nested copies.
Restore patterns
To unzip every archive in the current directory back into its own folder:
for z in *.zip; do
unzip -q "$z" -d "${z%.zip}"
done-q suppresses verbose output; -d DIR extracts into DIR.
For tar.gz:
for t in *.tar.gz; do
mkdir -p "${t%.tar.gz}"
tar -xzf "$t" -C "${t%.tar.gz}"
doneFor 7z:
for s in *.7z; do
7z x "$s" -o"${s%.7z}"
doneNote the -o flag with no space before the value: 7z's CLI parsing differs from most Unix tools.
For one-shot listing without extraction (verifying contents):
unzip -l archive.zip # zip
tar -tzf archive.tar.gz # tar.gz
7z l archive.7z # 7zCommon pitfalls
Spaces in directory names. The for d in */ form handles this correctly, but only if you double-quote "$d" everywhere. The moment you write zip foo.zip $d without quotes, Bash splits the path on whitespace and the archive name is wrong.
Hidden directories skipped silently. */ does not match folders starting with .. To include them, set shopt -s dotglob before the loop (Bash) or add an explicit glob: for d in */ .*/.
The zip becomes the source for the next iteration. If you run the loop in a directory and the archives land in the same directory, the next time the loop runs */ is unchanged (zips aren't directories), so this is fine: but if you also have a script that processes both directories AND archives, write to a sibling output directory: zip -r "../out/${d%/}.zip" "$d".
Symlinks blow up the archive size on macOS. BSD zip follows symlinks by default. If a folder contains a symlink to a 10 GB directory elsewhere, that 10 GB lands in your archive. Use -y to store symlinks as symlinks: zip -ry "${d%/}.zip" "$d".
File modification times reset on extract. zip and tar both preserve mtime, but unzip's default is to set permissions to the archive's stored values, which may not match the original. Use tar -xpzf (-p preserves permissions) when restoring.
Encryption with zip -e is not real encryption. It's CRC-based and trivially crackable. If the data matters, switch to 7z -mhe=on or gpg --symmetric --cipher-algo AES256.
What to do next
If you're building this into a larger workflow:
- Bash For Loops: the underlying loop syntax with the parallel
xargs -Pvariant and the safe file-iteration patterns for filenames with spaces. - Bash While Loops: for "archive until disk is 80% full" style conditional loops.
- find Command Cheat Sheet: better than
*/when you need to filter by depth, mtime, or size. - Export or Backup All MySQL Databases: the same per-X archive pattern, applied to database dumps.
- How to Optimize JPEG Images Using jpegoptim: pair with the zip loop when your folders contain image assets that should be shrunk before archiving.





