EthicsComputerCourses
Join training +91 90532 10052

Automation Scripts

Python · Lesson 65 of 68 Advanced 7 min read Updated

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:

  1. Dry run first. By default, only print what would happen. Make the real action a deliberate choice, such as a --apply flag.
  2. Be idempotent. Running the script a second time should not break anything or duplicate work. Check whether the job is already done.
  3. Never overwrite or delete silently. Skip existing targets, or rename them, and print every action.
  4. No hard-coded personal paths. Take folders as arguments (see Command-line Apps with argparse) so the script works on any computer.
  5. 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:

PythonRun it yourself
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()))
Output
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

TaskCode
All .csv files in a folderfolder.glob("*.csv")
All .csv files, including sub-foldersfolder.rglob("*.csv")
Size and modified timep.stat().st_size, p.stat().st_mtime
Copy a file with its timestampsshutil.copy2(src, dst)
Move or renameshutil.move(src, dst) or p.rename(new_path)
Copy a whole foldershutil.copytree(src, dst)
Delete a filep.unlink(missing_ok=True)
Delete a whole foldershutil.rmtree(folder) (no recycle bin)
Make a zip archiveshutil.make_archive(base_name, "zip", root_dir=folder)
Free disk spaceshutil.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.

PythonRun it yourself
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"))])
Output
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):

Python
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
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.py runs 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__).parent or take them as arguments.
  • Assuming file names are simple. Real names contain spaces, Hindi text, brackets and duplicates. Use pathlib and test with odd names.
  • Overwriting on move. Check target.exists() first, or add a number to the new name.
  • Using os.system() or shell=True. Use subprocess.run() with a list and check=True.

Key points

  • Automation scripts do repetitive work; make them safe with dry runs, idempotency and clear output.
  • pathlib finds files (iterdir, glob, rglob, suffix, stat); shutil copies, 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
PythonRun it yourself
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()))
Output
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?

2. Why do backup names like backup-2026-11-02.zip sort correctly by date?

3. Which call runs git status most safely?

4. A script works from your terminal but fails when scheduled. What is the most likely cause?

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