TechEarl

How to ZIP Multiple Directories Into Individual Files

Batch compress each folder in a parent directory into its own ZIP, tar.gz, or 7z archive on Linux, macOS, and Windows. Covers the for-loop one-liners, encryption, symlink handling, and the BSD vs Info-ZIP differences.

Ishan KarunaratneIshan Karunaratne⏱️ 11 min readUpdated
Batch compress each folder in a parent directory into its own ZIP, tar.gz, or 7z archive on Linux, macOS, and Windows. Covers for-loop one-liners, encryption, symlink handling, and BSD vs Info-ZIP differences.

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

In the parent directory that holds the folders you want to archive:

bash
for d in */; do
  zip -r "${d%/}.zip" "$d"
done

Why each piece is there:

  • */ matches only directories (the trailing slash is a glob qualifier).
  • "${d%/}" strips the trailing slash, so Project1/ becomes Project1 and the output is Project1.zip (not Project1/.zip).
  • -r recurses 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:

bash
dirs=("customer-a" "customer-b" "customer-c")
for d in "${dirs[@]}"; do
  zip -r "${d}.zip" "$d"
done

For 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.

bash
for d in */; do
  tar -czf "${d%/}.tar.gz" "$d"
done
  • -c create
  • -z gzip-compress on the fly
  • -f FILE write to FILE (must come last among the flags)

For tighter compression at the cost of speed:

bash
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):

bash
for d in */; do
  tar -cf - "$d" | pigz -p 8 > "${d%/}.tar.gz"
done

pigz -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:

powershell
Get-ChildItem -Directory | ForEach-Object {
  Compress-Archive -Path $_.FullName -DestinationPath "$($_.Name).zip"
}

What each piece does:

  • Get-ChildItem -Directory enumerates just the subdirectories (no files at the top level).
  • ForEach-Object { ... } is PowerShell's per-item loop.
  • $_.FullName is the absolute path of the directory.
  • $_.Name is 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:

powershell
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:

bash
for d in */; do
  if [ -n "$(ls -A "$d" 2>/dev/null)" ]; then
    zip -r "${d%/}.zip" "$d"
  else
    echo "Skipping empty: $d"
  fi
done

ls -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:

bash
for d in */; do
  if find "$d" -mindepth 1 -print -quit | grep -q .; then
    zip -r "${d%/}.zip" "$d"
  fi
done

find -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:

BehaviorLinux (Info-ZIP)macOS (BSD zip)
SymlinksStored as symlinks with -yStored as symlinks with -y; followed by default
.DS_Store filteringNot relevantNot filtered automatically: add -x '*.DS_Store'
__MACOSX resource forksNot relevantAdded automatically: avoid by using ditto -ck or tar instead
Encryption strengthInfo-ZIP supports -e (Zip 2.0, weak) and AES via patched buildsBSD supports -e only: for stronger encryption use 7z
unzip behaviorGNU-compatibleBSD-compatible; mostly identical for common flags

To suppress the macOS resource forks when sending archives to Linux peers, use ditto:

bash
for d in */; do
  ditto -c -k --sequesterRsrc "$d" "${d%/}.zip"
done

ditto 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.

bash
for d in */; do
  zip -er "${d%/}.zip" "$d"     # prompts for password per archive
done

For real encryption, switch to 7-Zip with AES-256 and encrypted file names:

bash
for d in */; do
  7z a -p"$PASSWORD" -mhe=on "${d%/}.7z" "$d"
done
  • -p sets the password (without a value, 7z prompts).
  • -mhe=on encrypts 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:

bash
for d in */; do
  tar -czf - "$d" | gpg --symmetric --cipher-algo AES256 -o "${d%/}.tar.gz.gpg"
done

gpg --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:

bash
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:

bash
for d in */; do
  tar -czf "${d%/}.tar.gz" \
    --exclude='*.tmp' \
    --exclude='*.log' \
    --exclude='node_modules' \
    --exclude='.git' \
    "$d"
done

GNU 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:

bash
for z in *.zip; do
  unzip -q "$z" -d "${z%.zip}"
done

-q suppresses verbose output; -d DIR extracts into DIR.

For tar.gz:

bash
for t in *.tar.gz; do
  mkdir -p "${t%.tar.gz}"
  tar -xzf "$t" -C "${t%.tar.gz}"
done

For 7z:

bash
for s in *.7z; do
  7z x "$s" -o"${s%.7z}"
done

Note 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):

bash
unzip -l archive.zip       # zip
tar -tzf archive.tar.gz    # tar.gz
7z l archive.7z            # 7z

Common 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:

FAQ

TagsLinuxCompressionCommand LineFile ManagementBashPowerShelltarzip7z
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Use find -type d -empty to list empty directories and find -type f -empty for empty files. The -depth trap for deleting nested empty trees, the hidden-file gotcha, the safe two-pass cleanup, and BSD vs GNU find notes.

How to Find (and Delete) Empty Directories and Files

find . -type d -empty lists every empty directory; find . -type f -empty lists every empty file. The catch is what 'empty' means (a hidden file makes a directory not empty) and the -depth trap that lets find -delete collapse whole nested empty trees in one pass. The flag reference, the safe two-pass cleanup, the BSD vs GNU notes, and the mistakes that bite.

Skip node_modules, .git, and build output from grep with --exclude-dir, exclude filename globs with --exclude, and search only matching files with --include. The one-liner, brace expansion, --exclude-from, and the BSD grep fallback.

How to Exclude Files and Directories from grep

grep does not read .gitignore, so skipping node_modules, .git, and build output is on you. The flags that do it: --exclude for filename globs, --exclude-dir for whole directories, --include for the inverse, --exclude-from to read the list from a file, plus the find -prune fallback for older macOS grep.

Use find -size +100M to list files larger than 100 megabytes. Unit suffixes (c/k/M/G), +/- sign convention, combine with sort -rn to surface the biggest files on disk, and BSD vs GNU rendering differences.

How to Find Files Larger Than a Size with find -size

find . -size +100M lists every file larger than 100 megabytes. The unit suffixes (c, k, M, G), the +/- sign convention, how to combine with sort to find the biggest files on disk, the BSD vs GNU divergence for printing sizes, and the wc -c trick for byte-exact thresholds.