Build a File Organizer Script: Escape Tutorial Hell with Real Bash Skills
You know that feeling. You’ve watched 10 Bash tutorials. You can recite ls, cd, and grep in your sleep. But the moment someone asks you to actually build something, your brain goes blank.
Welcome to Tutorial Hell.
The escape route? Stop memorizing syntax. Start solving real problems.
Today, we’re building a File Organizer - a script that transforms your chaotic Downloads folder into a perfectly sorted collection. Images go to Images. PDFs go to Documents. No more digging through 200 random files to find that one screenshot.
By the end of this post, you’ll have a working tool you can use every day. Let’s go.
The Problem: Your Downloads Folder is a Disaster
Open your Downloads folder right now. Go ahead. I’ll wait.
If you’re like most developers, you’ll see:
Screenshot 2024-01-15.pngmixed withresume_final_v3.pdfrandom_video.mp4sitting next topackage.json- That ZIP file you downloaded 6 months ago and never opened
This chaos costs you time. Every. Single. Day.
Our script will:
- Scan a target directory for files
- Identify file types by their extension
- Create category folders (Images, Documents, Videos, etc.)
- Move each file to its proper home
- Handle duplicates gracefully
- Give you a summary report
The Toolkit: Commands Grouped by Purpose
Before we dive into code, let’s understand the tools we’ll use. Not as random commands, but as building blocks for our solution.
Group 1: Reconnaissance (Viewing Data)
| Command | Purpose |
|---|---|
ls | List files in a directory |
find | Search for files with specific criteria |
basename | Extract filename from a path |
stat | Get file information (size, dates) |
Group 2: Logic and Processing
| Concept | Purpose |
|---|---|
${var##*.} | Extract file extension |
${var,,} | Convert string to lowercase |
declare -A | Create associative arrays (like dictionaries) |
for/while | Loop through files |
case/if | Make decisions |
Group 3: Action and Output
| Command | Purpose |
|---|---|
mkdir -p | Create directories (including parents) |
mv | Move files |
echo | Output messages |
>> | Append to log files |
The Walkthrough: Building Step by Step
The fatal mistake most tutorials make: showing you the final 300-line script and saying “here’s how it works.”
We’re doing the opposite. We’ll build this iteratively, like you would in real life.
Step 1: List the Files
First, let’s just see what we’re working with:
# Create a test directory with sample files
mkdir -p test_folder
touch test_folder/{photo.jpg,document.pdf,video.mp4,song.mp3,data.json}
# List the files
ls test_folder
Output:
data.json document.pdf photo.jpg song.mp3 video.mp4
Good. We have files. Now let’s process them one by one.
Step 2: Loop Through Files
for file in test_folder/*; do
echo "Found: $file"
done
Output:
Found: test_folder/data.json
Found: test_folder/document.pdf
Found: test_folder/photo.jpg
Found: test_folder/song.mp3
Found: test_folder/video.mp4
We’re now touching every file. But we have the full path - we need just the filename.
Step 3: Extract the Filename and Extension
Here’s where Bash’s parameter expansion becomes your superpower:
for file in test_folder/*; do
filename=$(basename "$file")
extension="${filename##*.}"
echo "File: $filename | Extension: $extension"
done
Output:
File: data.json | Extension: json
File: document.pdf | Extension: pdf
File: photo.jpg | Extension: jpg
File: song.mp3 | Extension: mp3
File: video.mp4 | Extension: mp4
What’s ${filename##*.} doing?
##= Remove the longest match from the beginning*.= Everything up to and including the last dot- Result = Just the extension
Step 4: Map Extensions to Categories
Now we need to know which folder each extension belongs to. Enter associative arrays:
# Define categories
declare -A CATEGORY_MAP
CATEGORY_MAP=(
["jpg"]="Images"
["png"]="Images"
["pdf"]="Documents"
["txt"]="Documents"
["mp4"]="Videos"
["mp3"]="Audio"
["json"]="Data"
)
# Get category for an extension
extension="jpg"
category="${CATEGORY_MAP[$extension]}"
echo "Extension '$extension' belongs to: $category"
Output:
Extension 'jpg' belongs to: Images
Step 5: Create Folders and Move Files
Let’s combine everything:
declare -A CATEGORY_MAP
CATEGORY_MAP=(
["jpg"]="Images"
["pdf"]="Documents"
["mp4"]="Videos"
["mp3"]="Audio"
["json"]="Data"
)
TARGET_DIR="test_folder"
for file in "$TARGET_DIR"/*; do
# Skip if not a file
[[ ! -f "$file" ]] && continue
filename=$(basename "$file")
extension="${filename##*.}"
extension="${extension,,}" # Convert to lowercase
# Get category (default to "Others")
category="${CATEGORY_MAP[$extension]:-Others}"
# Create destination folder
dest_dir="$TARGET_DIR/$category"
mkdir -p "$dest_dir"
# Move the file
mv "$file" "$dest_dir/"
echo "Moved: $filename -> $category/"
done
Output:
Moved: data.json -> Data/
Moved: document.pdf -> Documents/
Moved: photo.jpg -> Images/
Moved: song.mp3 -> Audio/
Moved: video.mp4 -> Videos/
Check the result:
ls -la test_folder/
Audio/
Data/
Documents/
Images/
Videos/
It works! But we’re not done. Real-world scripts need more.
Step 6: Handle Duplicates
What if photo.jpg already exists in the Images folder? We need to rename it:
get_unique_filename() {
local dest_dir="$1"
local filename="$2"
local base="${filename%.*}" # Everything before the last dot
local ext="${filename##*.}" # The extension
local counter=1
local new_filename="$filename"
while [[ -e "${dest_dir}/${new_filename}" ]]; do
new_filename="${base}_${counter}.${ext}"
((counter++))
done
echo "$new_filename"
}
# Usage
final_name=$(get_unique_filename "Images" "photo.jpg")
echo "$final_name" # photo.jpg or photo_1.jpg if exists
Step 7: Add Dry-Run Mode
Never run a file-moving script without testing first. A dry-run flag lets you preview changes:
DRY_RUN=false
if [[ "$1" == "-d" || "$1" == "--dry-run" ]]; then
DRY_RUN=true
echo "[DRY-RUN MODE] No files will be moved"
fi
# Later, when moving files:
if [[ "$DRY_RUN" == true ]]; then
echo "[DRY-RUN] Would move: $filename -> $category/"
else
mv "$file" "$dest_dir/$final_name"
echo "Moved: $filename -> $category/"
fi
Level Up: The Complete Script
Now let’s package everything into a proper, reusable script. Here’s the structure:
config.sh - Separate configuration for easy customization:
#!/bin/bash
# Configuration for File Organizer
declare -A CATEGORIES
CATEGORIES=(
["Images"]="jpg jpeg png gif bmp svg webp ico"
["Documents"]="pdf doc docx txt rtf odt xls xlsx ppt pptx"
["Videos"]="mp4 avi mkv mov wmv flv webm"
["Audio"]="mp3 wav flac aac ogg wma m4a"
["Archives"]="zip tar gz rar 7z bz2"
["Code"]="sh py js ts html css php java c cpp go rs"
["Data"]="json xml csv sql yaml yml toml"
)
DEFAULT_DIR="${HOME}/Downloads"
IGNORE_PATTERNS=".DS_Store Thumbs.db"
ENABLE_LOGGING=true
LOG_DIR="${HOME}/.local/log"
organize.sh - The main script:
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/config.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Options
DRY_RUN=false
VERBOSE=false
# Get category for a file extension
get_category() {
local ext="${1,,}"
for category in "${!CATEGORIES[@]}"; do
for e in ${CATEGORIES[$category]}; do
[[ "$e" == "$ext" ]] && echo "$category" && return
done
done
echo "Others"
}
# Main logic
main() {
local target_dir="${1:-$DEFAULT_DIR}"
echo -e "${BLUE}[INFO]${NC} Organizing: $target_dir"
for file in "$target_dir"/*; do
[[ ! -f "$file" ]] && continue
filename=$(basename "$file")
extension="${filename##*.}"
category=$(get_category "$extension")
dest_dir="$target_dir/$category"
mkdir -p "$dest_dir"
mv "$file" "$dest_dir/"
echo -e "${GREEN}[OK]${NC} $filename -> $category/"
done
echo -e "${GREEN}[SUCCESS]${NC} Organization complete!"
}
main "$@"
Make it executable and run:
chmod +x organize.sh config.sh
./organize.sh ~/Downloads
The Summary Report
A good script tells you what it did. Add tracking to your script:
declare -A MOVED_COUNT
TOTAL_MOVED=0
# After each move:
((TOTAL_MOVED++))
MOVED_COUNT[$category]=$((${MOVED_COUNT[$category]:-0} + 1))
# At the end, print summary:
echo "════════════════════════════════════════"
echo " ORGANIZATION SUMMARY"
echo "════════════════════════════════════════"
printf "%-15s %s\n" "Category" "Files"
echo "────────────────────────────────────────"
for cat in "${!MOVED_COUNT[@]}"; do
printf "%-15s %d\n" "$cat" "${MOVED_COUNT[$cat]}"
done
echo "────────────────────────────────────────"
printf "%-15s %d\n" "Total:" "$TOTAL_MOVED"
Output looks like:
════════════════════════════════════════
ORGANIZATION SUMMARY
════════════════════════════════════════
Category Files
────────────────────────────────────────
Images 12
Documents 8
Videos 3
Audio 5
Data 2
────────────────────────────────────────
Total: 30
What You Just Learned
You didn’t just copy-paste a script. You built it piece by piece. Along the way, you mastered:
- Parameter expansion:
${var##*.},${var,,},${var%.*} - Associative arrays:
declare -Afor key-value mapping - Defensive programming: Dry-run mode, duplicate handling
- Script structure: Separating config from logic
- User feedback: Colors, summaries, logging
These aren’t isolated tricks. They’re patterns you’ll use in every Bash script you write.
Your Challenge
The script we built is functional, but there’s room to grow. Here are some challenges to level up:
- Add recursive mode: Process files in subdirectories too
- Add undo functionality: Keep a log that can reverse all moves
- Add file size stats: Show total size organized per category
- Add date-based organization: Organize by year/month instead of type
- Add custom rules: Let users define their own extension-to-category mappings via command line
Pick one. Build it. Break it. Fix it.
That’s how you escape Tutorial Hell for good.
Full source code available at: GitHub - File Organizer Project
Next in the series: Building a System Health Monitor in Bash