Automation Scripts
A huge amount of real Python work is not apps at all, but automation scripts: small programs that do a boring job reliably, such as sorting a messy downloads folder, renaming 300 scanned invoices, backing up a project every night, or running a report and emailing it. Operations teams, data teams and support teams in every company write them, and they are a great way to save your own time too.
This lesson builds two practical scripts and teaches the habits that make automation safe: a dry run before real changes, scripts that can run twice without harm, clear output of what was done, careful deletes, running other programs with subprocess, and scheduling. You should know Reading & Writing Files, especially pathlib.
Rules for safe automation
A script that moves or deletes files can do a lot of damage in one second. Follow these rules from the first version:
- Dry run first. By default, only print what would happen. Make the real action a deliberate choice, such as a
--applyflag. - Be idempotent. Running the script a second time should not break anything or duplicate work. Check whether the job is already done.
- Never overwrite or delete silently. Skip existing targets, or rename them, and print every action.
- No hard-coded personal paths. Take folders as arguments (see Command-line Apps with argparse) so the script works on any computer.
- Log what happened, so you can check a nightly job the next morning (see Logging).
Script 1: organise a downloads folder
This script sorts files into sub-folders by type. It loops over the folder with iterdir(), chooses a target folder from the file's suffix (its extension), and moves files with shutil.move(). With dry_run=True, the default, it only reports. The browser editor has a temporary in-memory folder, so the example creates some sample files first:
import shutil
from pathlib import Path
FOLDERS = {
".pdf": "Documents", ".docx": "Documents", ".xlsx": "Sheets",
".csv": "Sheets", ".jpg": "Photos", ".png": "Photos", ".zip": "Archives",
}
def organise(folder: Path, dry_run: bool = True) -> dict[str, int]:
moved: dict[str, int] = {}
for file in sorted(folder.iterdir()):
if not file.is_file():
continue
target_dir = folder / FOLDERS.get(file.suffix.lower(), "Other")
target = target_dir / file.name
if target.exists():
print(f"skip {file.name} (already in {target_dir.name})")
continue
print(f"{'would move' if dry_run else 'move'} {file.name} -> {target_dir.name}/")
if not dry_run:
target_dir.mkdir(exist_ok=True)
shutil.move(file, target)
moved[target_dir.name] = moved.get(target_dir.name, 0) + 1
return moved
downloads = Path("downloads")
downloads.mkdir(exist_ok=True)
for name in ["fees-receipt.PDF", "marks.xlsx", "trip-jaipur.jpg", "notes.txt", "photo2.png"]:
(downloads / name).write_text("demo", encoding="utf-8")
print(organise(downloads)) # dry run: nothing changes
print(organise(downloads, dry_run=False))
print(sorted(p.relative_to(downloads).as_posix() for p in downloads.rglob("*") if p.is_file()))would move fees-receipt.PDF -> Documents/
would move marks.xlsx -> Sheets/
would move notes.txt -> Other/
would move photo2.png -> Photos/
would move trip-jaipur.jpg -> Photos/
{'Documents': 1, 'Sheets': 1, 'Other': 1, 'Photos': 2}
move fees-receipt.PDF -> Documents/
move marks.xlsx -> Sheets/
move notes.txt -> Other/
move photo2.png -> Photos/
move trip-jaipur.jpg -> Photos/
{'Documents': 1, 'Sheets': 1, 'Other': 1, 'Photos': 2}
['Documents/fees-receipt.PDF', 'Other/notes.txt', 'Photos/photo2.png', 'Photos/trip-jaipur.jpg', 'Sheets/marks.xlsx']Details that make it robust: suffix.lower() treats .PDF and .pdf alike; is_file() skips the sub-folders the script itself created, so a second run does nothing harmful; exists() prevents overwriting; and sorted() makes the order predictable. rglob("*") walks through all sub-folders, which the last line uses to check the result.
Useful file tools
| Task | Code |
|---|---|
All .csv files in a folder | folder.glob("*.csv") |
All .csv files, including sub-folders | folder.rglob("*.csv") |
| Size and modified time | p.stat().st_size, p.stat().st_mtime |
| Copy a file with its timestamps | shutil.copy2(src, dst) |
| Move or rename | shutil.move(src, dst) or p.rename(new_path) |
| Copy a whole folder | shutil.copytree(src, dst) |
| Delete a file | p.unlink(missing_ok=True) |
| Delete a whole folder | shutil.rmtree(folder) (no recycle bin) |
| Make a zip archive | shutil.make_archive(base_name, "zip", root_dir=folder) |
| Free disk space | shutil.disk_usage(path).free |
Script 2: dated backups with rotation
A nightly backup should create a zip with the date in its name, and delete old backups so the disk does not fill up. This is called rotation. Using ISO dates (2026-11-02) in file names has a neat benefit: sorting the names alphabetically also sorts them by date.
import shutil
from datetime import date, timedelta
from pathlib import Path
project = Path("school-project")
(project / "data").mkdir(parents=True, exist_ok=True)
(project / "report.py").write_text("print('report')\n", encoding="utf-8")
(project / "data" / "marks.csv").write_text("name,marks\nIsha,91\n", encoding="utf-8")
backups = Path("backups")
backups.mkdir(exist_ok=True)
def back_up(day: date) -> Path:
base = backups / f"school-project-{day.isoformat()}"
return Path(shutil.make_archive(str(base), "zip", root_dir=project))
def rotate(keep: int = 3) -> None:
archives = sorted(backups.glob("school-project-*.zip")) # ISO dates sort by time
for old in archives[:-keep]:
old.unlink()
print("deleted", old.name)
start = date(2026, 10, 28) # use date.today() in a real script
for offset in range(5):
print("created", back_up(start + timedelta(days=offset)).name)
rotate(keep=3)
print([p.name for p in sorted(backups.glob("*.zip"))])created school-project-2026-10-28.zip created school-project-2026-10-29.zip created school-project-2026-10-30.zip created school-project-2026-10-31.zip created school-project-2026-11-01.zip deleted school-project-2026-10-28.zip deleted school-project-2026-10-29.zip ['school-project-2026-10-30.zip', 'school-project-2026-10-31.zip', 'school-project-2026-11-01.zip']
The example simulates five days so you can see the rotation; a real script calls back_up(date.today()) once per run. archives[:-keep] is every archive except the newest keep, and the rotation only touches files matching the backup pattern, never anything else in the folder. A backup is only useful if you can restore it, so open one now and then with the zipfile module or your file manager.
Running other programs with subprocess
Automation often means running other tools: git, ffmpeg, a database dump command, another Python script. Use subprocess.run() and pass the command as a list of arguments. capture_output=True collects what the program prints, text=True gives you strings instead of bytes, and check=True raises an exception if the program fails (a non-zero exit code):
import subprocess
import sys
result = subprocess.run(
[sys.executable, "--version"], capture_output=True, text=True, check=True
)
print("Output:", result.stdout.strip())
print("Exit code:", result.returncode)
try:
subprocess.run([sys.executable, "-c", "import sys; sys.exit(3)"], check=True)
except subprocess.CalledProcessError as error:
print("Failed with exit code", error.returncode)Output: Python 3.14.8 Exit code: 0 Failed with exit code 3
sys.executable is the path of the Python running your script, which is safer than assuming python is on the PATH. Avoid shell=True: with a list of arguments, file names containing spaces or special characters are passed safely, and nothing in them can be run as an extra command (see Secure Coding Basics). Add timeout=60 for programs that might hang. The browser editor cannot start programs, so this example runs on your computer only.
Scheduling scripts
A script becomes automation when it runs by itself. Each operating system has a scheduler:
- Windows: Task Scheduler. Use the Task Scheduler app, or a command such as
schtasks /create /tn "School backup" /sc daily /st 02:00 /tr "C:\scripts\.venv\Scripts\python.exe C:\scripts\backup.py". - Linux and macOS: cron. A crontab line such as
0 2 * * * /home/priya/scripts/.venv/bin/python /home/priya/scripts/backup.pyruns the script at 2:00 every night (see Scheduling: cron & systemd Timers).
Scheduled jobs run without your terminal, so three things change: use absolute paths for the Python inside your virtual environment and for every file, write output to a log file instead of the screen, and make the script exit with a non-zero code on failure (an uncaught exception does this) so the scheduler records the failure.
Common mistakes
- Testing on real data first. Copy a few files into a test folder, run the dry run, then the real run, and only then point the script at the real folder.
- Relative paths in scheduled jobs. The scheduler starts in a different folder; build paths from
Path(__file__).parentor take them as arguments. - Assuming file names are simple. Real names contain spaces, Hindi text, brackets and duplicates. Use
pathliband test with odd names. - Overwriting on move. Check
target.exists()first, or add a number to the new name. - Using
os.system()orshell=True. Usesubprocess.run()with a list andcheck=True.
Key points
- Automation scripts do repetitive work; make them safe with dry runs, idempotency and clear output.
pathlibfinds files (iterdir,glob,rglob,suffix,stat);shutilcopies, moves, zips and deletes.- Deletes are permanent; print and dry-run first, and restrict deletes to known patterns.
- Name backups with ISO dates so sorting by name sorts by time, and rotate old ones.
- Run other programs with
subprocess.run([...], capture_output=True, text=True, check=True). - Schedule with Task Scheduler or cron, using absolute paths, log files and proper exit codes.
Exercise
A clinic in Kochi scans prescriptions with names like scan_0007.jpg. Write a function rename_scans(folder, date_text, dry_run=True) that renames every scan_*.jpg file to 2026-11-02-rx-007.jpg (date, then rx, then the three-digit number from the old name). It must print each planned rename, skip a file if the new name already exists, and only rename when dry_run is False. Test it on three sample files.
Show solution
import re
from pathlib import Path
SCAN = re.compile(r"scan_(\d+)\.jpg", re.IGNORECASE)
def rename_scans(folder: Path, date_text: str, dry_run: bool = True) -> int:
count = 0
for file in sorted(folder.glob("*.jpg")):
match = SCAN.fullmatch(file.name)
if not match:
continue
new_path = folder / f"{date_text}-rx-{int(match.group(1)):03}.jpg"
if new_path.exists():
print(f"skip {file.name}: {new_path.name} exists")
continue
print(f"{'would rename' if dry_run else 'rename'} {file.name} -> {new_path.name}")
if not dry_run:
file.rename(new_path)
count += 1
return count
scans = Path("scans")
scans.mkdir(exist_ok=True)
for name in ["scan_0007.jpg", "scan_0012.jpg", "scan_0103.jpg"]:
(scans / name).write_text("img", encoding="utf-8")
rename_scans(scans, "2026-11-02")
print(rename_scans(scans, "2026-11-02", dry_run=False), "renamed")
print(sorted(p.name for p in scans.iterdir()))would rename scan_0007.jpg -> 2026-11-02-rx-007.jpg would rename scan_0012.jpg -> 2026-11-02-rx-012.jpg would rename scan_0103.jpg -> 2026-11-02-rx-103.jpg rename scan_0007.jpg -> 2026-11-02-rx-007.jpg rename scan_0012.jpg -> 2026-11-02-rx-012.jpg rename scan_0103.jpg -> 2026-11-02-rx-103.jpg 3 renamed ['2026-11-02-rx-007.jpg', '2026-11-02-rx-012.jpg', '2026-11-02-rx-103.jpg']
fullmatch() ignores files that merely contain scan_, and int(...) followed by :03 turns 0007 into 007. Running the script a second time does nothing, because the renamed files no longer match the pattern.
Quick Quiz
1. What is a dry run?
A dry run lets you check the plan before real files are touched.
2. Why do backup names like backup-2026-11-02.zip sort correctly by date?
Year-month-day with leading zeros makes alphabetical order match time order.
3. Which call runs git status most safely?
A list of arguments avoids the shell, and check=True raises an error if the command fails.
4. A script works from your terminal but fails when scheduled. What is the most likely cause?
Use absolute paths, or build paths from the script's own location.
Learn Python with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
