Python: Automating Boring Tasks (Files, PDFs, Excel, Emails)

Python automation lets you hand the boring work to your computer: organize files with shutil and pathlib, merge and split PDFs with pypdf, read and write Excel spreadsheets with openpyxl, send emails from a script, and schedule recurring jobs. This guide gives you a copy-and-run recipe for each one.

“I choose a lazy person to do a hard job. Because a lazy person will find an easy way to do it.”

Bill Gates

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 17 minutes

Every developer has chores they do over and over: rename a pile of files, pull numbers out of Excel, merge a few PDFs into one report, fire off a weekly summary email. Each one takes ten minutes. Ten minutes feels like nothing, but do it every day and you have burned an afternoon by the end of the month. Worse, it breaks your focus every single time. Python can do all of these in seconds, and once a script is written it never gets bored, never makes a typo, and never forgets a step.

Think of this post as a small cookbook. Each section is a self-contained script you can copy, tweak, and run today. We cover the five chores people automate most: organizing files, reading and writing Excel, working with PDFs, sending email, and running jobs on a schedule.

Here is the kind of win you are signing up for. A data analyst named Pravin used to spend two hours every Friday stitching a sales report together from a folder of Excel files. He wrote a 40-line Python script that does the same thing in about 8 seconds. It has run every Friday at 9 AM for six months without a hiccup, and he has not opened those spreadsheets by hand since. That is the whole pitch: write it once, let it run forever.

CommunicationsmtplibSend emailsscheduleTimed tasksDocument ProcessingopenpyxlExcelread/writePyPDF2PDFmerge/splitpython-docxWorddocumentsFile Operationsshutilcopy, move,deletepathlibpaths, glob,walkosrename,makedirsPythonAutomation HubPython Automation: Libraries Grouped by File, Document, and Communication Tasks

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram sorts Python’s automation toolkit into three buckets. File operations lean on pathlib, shutil, and os. Document work uses openpyxl for Excel, pypdf for PDFs, and python-docx for Word files. Communication and timing come from smtplib and schedule. Each library does one job well, and the real power shows up when you chain them: watch a folder for new files, process them, then email the result. The recipes below build up from a single chore to that kind of combined workflow.

Prerequisites

You should already be comfortable with File Handling and CSV and JSON. A quick note on the PDF library: the old PyPDF2 package is no longer maintained and its name now just points at pypdf. Install pypdf directly so you get the current, supported version. Here are all the libraries this post uses:

📄 Terminal: install the automation libraries

pip install openpyxl pypdf python-docx schedule

Python Automation Recipe 1: File Organization with shutil and pathlib

Picture your Downloads folder right now. Invoices, screenshots, half-finished scripts, a random ZIP, all dumped in one pile. Sorting that by hand is like sorting your mail by walking each envelope to a different room. Boring, and you will skip it. So let Python be the mail sorter: it looks at each file’s extension and drops it into the right tray (Images, Documents, Code, Archives, or Other).

📄 organize_downloads.py: sort files into folders by extension

from pathlib import Path
import shutil

def organize_folder(source_dir):
    """Move files into subfolders, grouped by extension."""
    source = Path(source_dir)
    categories = {
        "Images": {".jpg", ".jpeg", ".png", ".gif", ".svg"},
        "Documents": {".pdf", ".docx", ".xlsx", ".txt", ".csv"},
        "Code": {".py", ".js", ".html", ".css", ".json"},
        "Archives": {".zip", ".tar", ".gz", ".rar"},
    }

    moved = 0
    for file in sorted(source.iterdir()):
        if not file.is_file():
            continue
        ext = file.suffix.lower()
        dest_folder = "Other"
        for category, extensions in categories.items():
            if ext in extensions:
                dest_folder = category
                break

        target = source / dest_folder
        target.mkdir(exist_ok=True)
        shutil.move(str(file), str(target / file.name))
        moved += 1
        print(f"  Moved {file.name} to {dest_folder}/")

    print(f"\nOrganized {moved} files")

organize_folder("/Users/viraj/Downloads")

▶ Output

  Moved app.py to Code/
  Moved data.csv to Documents/
  Moved report.pdf to Documents/
  Moved screenshot.png to Images/

Organized 4 files

What happened here: source.iterdir() hands you every item in the folder, and we wrap it in sorted() so the run is predictable instead of relying on whatever order the filesystem feels like today. For each real file we look at file.suffix.lower() (the extension, lowercased so .PNG and .png land in the same place), find its category, create that subfolder with mkdir(exist_ok=True) if it does not exist yet, and move the file in with shutil.move. Anything that does not match a category goes to Other, so nothing is ever left behind. One tip before you point this at a real folder: run it on a copy first, because shutil.move actually moves the files, it does not make copies.

Recipe 2: Excel Processing with openpyxl

Spreadsheets are where business data lives, and openpyxl lets you treat an .xlsx file like a normal Python object. Think of a workbook as a notebook and each worksheet as a page inside it. You can write rows, drop in a live SUM formula, style the header, save it, then open it back up and read the numbers. Here we build a tiny sales report for four salespeople, Rahul, Niranjan, Viraj, and Aditi, and then pull out the top performers.

📄 excel_processor.py: create, style, and read back an Excel file

from openpyxl import Workbook, load_workbook

# Create a new Excel file
wb = Workbook()
ws = wb.active
ws.title = "Sales Report"

# Add headers
ws.append(["Name", "Region", "Sales", "Quarter"])

# Add data
sales_data = [
    ["Rahul", "West", 45000, "Q1"],
    ["Niranjan", "East", 38000, "Q1"],
    ["Viraj", "North", 52000, "Q1"],
    ["Aditi", "South", 41000, "Q1"],
]
for row in sales_data:
    ws.append(row)

# Add a total formula
ws.append(["TOTAL", "", f"=SUM(C2:C{len(sales_data)+1})", ""])

# Style the header row
from openpyxl.styles import Font, PatternFill
for cell in ws[1]:
    cell.font = Font(bold=True, color="FFFFFF")
    cell.fill = PatternFill(start_color="282a36", fill_type="solid")

wb.save("sales_report.xlsx")
print("Created sales_report.xlsx")

# Read the file back and find the top performers
wb2 = load_workbook("sales_report.xlsx")
ws2 = wb2.active
for row in ws2.iter_rows(min_row=2, values_only=True):
    if row[2] and isinstance(row[2], (int, float)) and row[2] > 40000:
        print(f"  Top performer: {row[0]} ({row[1]}), {row[2]:,}")

▶ Output

Created sales_report.xlsx
  Top performer: Rahul (West), 45,000
  Top performer: Viraj (North), 52,000
  Top performer: Aditi (South), 41,000

What happened here: Workbook() gives you a fresh file with one empty sheet, and ws.append([...]) adds a row at a time, just like jotting the next line in a notebook. The total row uses an Excel formula string, "=SUM(C2:C5)", so the sum is computed by Excel when the file opens, not baked in by Python. When we read the file back, the filter is row[2] > 40000, and that is the catch worth noticing: it matches anyone above 40,000, so Aditi at 41,000 shows up right alongside Rahul and Viraj. Three names, not two. A filter only returns what you actually asked for, so it pays to read the comparison out loud before you trust the output.

Recipe 3: PDF Operations with pypdf

PDFs are the file format nobody loves but everybody uses. The modern library for them is pypdf (the maintained successor to the old PyPDF2, which is now just a thin wrapper around it). Two classes do almost everything: PdfReader opens an existing PDF and lets you read its pages, and PdfWriter collects pages and writes a new file. Merging is like stapling several printouts into one stack; splitting is photocopying just the pages you want.

📄 pdf_tools.py: merge, split, and extract text from PDFs

from pypdf import PdfReader, PdfWriter

# Merge several PDFs into one file
def merge_pdfs(pdf_files, output_path):
    writer = PdfWriter()
    for pdf in pdf_files:
        writer.append(pdf)
    writer.write(output_path)
    writer.close()
    print(f"Merged {len(pdf_files)} PDFs into {output_path}")

# Pull all the text out of a PDF
def extract_text(pdf_path):
    reader = PdfReader(pdf_path)
    text = ""
    for page in reader.pages:
        text += page.extract_text() + "\n"
    return text

# Split a PDF: keep only the pages you ask for
def split_pdf(input_path, pages, output_path):
    reader = PdfReader(input_path)
    writer = PdfWriter()
    for page_num in pages:
        writer.add_page(reader.pages[page_num])
    writer.write(output_path)
    print(f"Extracted pages {pages} to {output_path}")

# Try it on two sample PDFs
merge_pdfs(["invoice.pdf", "terms.pdf"], "combined.pdf")
split_pdf("combined.pdf", [0], "first_page.pdf")

▶ Output

Merged 2 PDFs into combined.pdf
Extracted pages [0] to first_page.pdf

What happened here: merge_pdfs creates one PdfWriter and calls writer.append(pdf) for each input file, which copies in all of its pages in order, then writes the combined result once at the end. (If you used PyPDF2 before, this replaces the old PdfMerger class, which has been removed; PdfWriter.append is the current way.) split_pdf works the other direction: it reads the source, then adds only the page numbers you list to a new writer, so [0] pulls out the first page. extract_text walks every page and stitches the text together, which is perfect for searching a stack of invoices.

One honest caveat: text extraction only works on real text PDFs. If the page is a scanned image, there is no text to grab, and you need to run Optical Character Recognition (OCR) on it first (more on that in the FAQ).

Recipe 4: Sending Emails with smtplib

Once your script has built a report, the natural next step is to mail it to someone, say a manager named Anvay who wants the sales numbers in his inbox every Friday. Python’s built-in smtplib talks to a mail server the same way your email app does: it logs in, hands over the message, and hangs up. Building the message is like packing a parcel. The MIMEMultipart object is the box, the HTML body is the letter inside, and the spreadsheet is the item you tape to it as an attachment.

📄 send_email.py: send an HTML email with an attachment

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import os

def send_report_email(to_email, subject, body, attachment_path=None):
    """Send an HTML email with an optional file attachment."""
    sender = os.getenv("EMAIL_USER")    # never hardcode credentials
    password = os.getenv("EMAIL_PASS")

    msg = MIMEMultipart()
    msg["From"] = sender
    msg["To"] = to_email
    msg["Subject"] = subject
    msg.attach(MIMEText(body, "html"))

    if attachment_path:
        with open(attachment_path, "rb") as f:
            part = MIMEBase("application", "octet-stream")
            part.set_payload(f.read())
            encoders.encode_base64(part)
            part.add_header(
                "Content-Disposition",
                f"attachment; filename={os.path.basename(attachment_path)}"
            )
            msg.attach(part)

    with smtplib.SMTP("smtp.gmail.com", 587) as server:
        server.starttls()
        server.login(sender, password)
        server.send_message(msg)
        print(f"Email sent to {to_email}")

# Usage (needs EMAIL_USER and EMAIL_PASS set in your environment)
send_report_email(
    "anvay@example.com",
    "Weekly Sales Report",
    "

Sales Report

See attached spreadsheet.

", "sales_report.xlsx", )
Note: this snippet needs a real mail account and network connection to actually send, so we cannot show live output here. The code is tested for correctness (the message assembles and the Simple Mail Transfer Protocol (SMTP) calls are valid), but the send step is left for you to run with your own credentials. For Gmail, turn on two-factor authentication and create an App Password, then set EMAIL_USER and EMAIL_PASS as environment variables before running.

What happened here: we read the login from environment variables with os.getenv instead of typing it into the file, because a password committed to your code is a password leaked to the world. The message is assembled as a MIMEMultipart with an HTML body and, optionally, a file attachment encoded in base64. The real work is three lines: starttls() upgrades the connection to an encrypted one, login() authenticates, and send_message() hands the parcel to Gmail’s server. The with block closes the connection for you even if something fails partway through.

Recipe 5: Scheduling Tasks

Writing a report script is half the job. The other half is making it run on its own every Friday so you never have to remember. The schedule library lets you describe timing in plain English, like an alarm clock you set once. You tell it “every Friday at 9 AM, run this function”, and then a small loop keeps checking the time and fires the job when it is due.

📄 scheduler.py: run tasks on a recurring schedule

import schedule
import time

def weekly_report():
    print("Generating weekly report...")
    # Call your report generation function here

def daily_backup():
    print("Running daily backup...")

schedule.every().friday.at("09:00").do(weekly_report)
schedule.every().day.at("23:00").do(daily_backup)

print("Scheduler started. Press Ctrl+C to stop.")
while True:
    schedule.run_pending()
    time.sleep(60)

That while True loop runs forever, so it is hard to show output for in a tutorial. Here is a short, finite version that registers the same two jobs and just prints when each one will fire next, so you can see that the timing actually took hold.

📄 inspect_jobs.py: check when each job runs next

import schedule

def weekly_report():
    print("Generating weekly report...")

def daily_backup():
    print("Running daily backup...")

schedule.every().friday.at("09:00").do(weekly_report)
schedule.every().day.at("23:00").do(daily_backup)

# Show what is registered and when each job runs next
for job in schedule.get_jobs():
    print(f"{job.job_func.__name__:<16} next run: {job.next_run}")

# Run any jobs that are due right now (none are, so nothing prints)
schedule.run_pending()
print("Checked for due jobs.")

▶ Output (run on Sunday, 2026-06-21)

weekly_report    next run: 2026-06-26 09:00:00
daily_backup     next run: 2026-06-21 23:00:00
Checked for due jobs.

What happened here: each schedule.every()...do(...) line registers a job and figures out its next run time. Because we ran this on a Sunday, the weekly Friday job points to the coming Friday (2026-06-26), while the daily 23:00 job points to later the same day. run_pending() looks at the clock and runs only the jobs that are due, which is why nothing fired here. The real script wraps that call in a loop with time.sleep(60) so it checks once a minute, forever.

Your dates will differ from these, of course, since they depend on the day you run it. One thing to know: this library only runs while your script is running. If the machine reboots, the schedule is gone. For jobs that must survive restarts, hand them to the operating system instead (cron on Linux or macOS, Task Scheduler on Windows).

Common Mistakes

Mistake 1: Hardcoding file paths

❌ Wrong

# Breaks on other machines and on Linux or macOS
path = "C:\\Users\\Rahul\\Documents\\report.xlsx"

✅ Correct

from pathlib import Path
path = Path.home() / "Documents" / "report.xlsx"

Why: a hardcoded string like "C:\\Users\\Rahul\\..." only exists on your machine. It is like giving someone directions that start from your own front door: perfectly clear to you, useless to everyone else. Hand the script to a teammate, or run it on a Linux server, and it breaks instantly. Path.home() asks the operating system where the current user’s home folder is, and the / operator joins path parts using the right separator for that system (backslash on Windows, forward slash everywhere else). Write it once, run it anywhere.

Mistake 2: Calling mkdir() without exist_ok

❌ Wrong

from pathlib import Path
folder = Path("Reports")
folder.mkdir()   # fine the first time
folder.mkdir()   # crashes the second time the script runs

▶ Output

Traceback (most recent call last):
  File "C:\tmp\mkdir_demo.py", line 4, in <module>
    folder.mkdir()   # crashes the second time the script runs
    ~~~~~~~~~~~~^^
  File "C:\Users\Rahul\AppData\Local\Programs\Python\Python314\Lib\pathlib\__init__.py", line 1011, in mkdir
    os.mkdir(self, mode)
    ~~~~~~~~^^^^^^^^^^^^
FileExistsError: [WinError 183] Cannot create a file when that file already exists: 'Reports'

✅ Correct

from pathlib import Path
folder = Path("Reports")
folder.mkdir(exist_ok=True)   # quietly does nothing if it already exists

Why: automation scripts run again and again, so the folders they create are usually already there by the second run. Plain mkdir() raises FileExistsError when the target exists. (The traceback above is from a Windows machine, so the final line shows [WinError 183]; on Linux and macOS it reads [Errno 17] File exists instead, and the file paths will match wherever you saved the script.) Passing exist_ok=True tells Python “make it if it is missing, otherwise leave it alone”, which is almost always what you want in a script you run on a schedule.

Conclusion

You now have five working recipes: sort a messy folder with pathlib and shutil, build and read Excel reports with openpyxl, merge and split PDFs with pypdf, mail the results with smtplib, and put the whole thing on a timer with schedule. Each one is small on its own, but chained together they replace hours of weekly busywork. Start with the chore that annoys you most, automate just that, and grow from there. Next up, we turn these scripts into polished command-line tools with argparse and click. And if you want the full learning path from beginner to AI/ML, visit the Python + AI/ML tutorial series home.

Frequently Asked Questions

What is the best Python automation library for Excel?

openpyxl is the go-to for .xlsx files: read, write, format, and add formulas. Use xlsxwriter when you only need to write and want advanced formatting, pandas when you are doing data analysis on the numbers, and xlrd only for the old .xls format.

How do I run a Python automation task on a schedule?

For a script that stays running, the schedule library is the simplest option. For jobs that must survive reboots, use the operating system: cron on Linux or macOS, Task Scheduler on Windows. For heavier production needs, reach for Celery with Redis or a cloud scheduler.

Is it safe to send emails from Python?

Yes, as long as you never hardcode passwords. Read credentials from environment variables or a .env file loaded with python-dotenv. For Gmail, create an App Password instead of using your main password. At scale, send through a service like SendGrid or AWS SES.

How do I handle large or scanned PDF files?

pypdf reads pages lazily, so memory stays reasonable even for big files; for very large PDFs (1000+ pages) process them in chunks. If the PDF is a scanned image, there is no text to extract, so run it through OCR with pytesseract first.

Should I use PyPDF2 or pypdf?

Use pypdf. The old PyPDF2 package is no longer maintained and now simply re-exports pypdf. The modern API merges files with PdfWriter.append() rather than the removed PdfMerger class.

Try It Yourself

Put all five recipes together into one script. Scan a folder for CSV files with pathlib, merge them into a single Excel workbook with one sheet per file using openpyxl, add a summary sheet that lists each file and its row count, and email the finished workbook to yourself with smtplib. Then wrap it in a schedule job so it runs every Monday morning on its own. When you are done, you will have built the kind of weekly report Pravin uses, end to end.

Interview Questions on Python Automation

If you can walk through these without peeking, you are ready for this topic in an interview.

Q: What is the difference between shutil.copy(), shutil.copy2(), and shutil.move()?

shutil.copy() copies the file contents and permission bits but resets timestamps, while shutil.copy2() also preserves metadata like the modification time, which matters for backup scripts that compare dates. shutil.move() relocates the file: on the same filesystem it is a fast rename, but across filesystems (say, to a USB drive) it silently falls back to copy-then-delete, which is slower and can fail halfway. Knowing that fallback exists is what separates a script that works on your laptop from one that works on a server with mounted drives.

Q: Your file organizer script crashed halfway through moving 500 files. What does the folder look like now, and how would you make the script safe to rerun?

The folder is in a mixed state: some files already moved into category subfolders, the rest still in place, and possibly one file lost mid-move if the crash hit during a cross-filesystem copy. To make it rerunnable, design for idempotency: create folders with mkdir(exist_ok=True), skip files that already exist at the destination (or rename with a suffix instead of overwriting), and process one file completely before touching the next. With those guards, rerunning the script simply picks up where it left off instead of crashing or clobbering files.

Q: You call load_workbook() on a 300 MB Excel file and your machine’s memory usage explodes. What do you change?

Open it with load_workbook(path, read_only=True), which streams rows on demand instead of loading every cell object into memory, and iterate with ws.iter_rows(values_only=True) so you get plain values rather than heavyweight cell objects. If you are producing a large file rather than reading one, the mirror option is Workbook(write_only=True). For pure number crunching on that much data, it is often better to hand the file to pandas and only use openpyxl for formatting.

Q: You wrote “=SUM(C2:C5)” into a cell with openpyxl, saved, and read the file back, but the cell returns the formula string instead of a number. Why?

openpyxl does not have a calculation engine, so it never evaluates formulas. It stores the formula string and, when Excel later opens and saves the file, Excel computes and caches the result. Reading with load_workbook(path, data_only=True) returns that cached value, but only if Excel (or another engine) has actually opened the file since the formula was written; on a file straight out of your script the cache is empty and you get None. If you need the number inside Python, compute it in Python and write the value.

Q: A job registered with schedule.every().day.at(“09:00”) fires at the wrong hour after you deploy the script to a cloud server. What is going on?

The schedule library uses the machine’s local clock, and most cloud servers run on UTC, so “09:00” means 9 AM UTC, not 9 AM your time (for India, that is 2:30 PM IST). Fix it by setting the server timezone, converting your target time to the server’s zone, or passing a timezone-aware time if your tooling supports it. Also remember that schedule only runs while the process is alive, so pair it with a process manager or use cron if the job must survive restarts.

Q: When sending email with smtplib, what is the difference between using SMTP with starttls() and SMTP_SSL?

smtplib.SMTP on port 587 opens a plain connection and then upgrades it to encrypted with starttls(), while smtplib.SMTP_SSL on port 465 is encrypted from the very first byte. Both are secure once established; which one you use usually depends on what your mail provider supports, and Gmail accepts both. The real interview point is what not to do: never call login() on an unencrypted connection, because that sends your password in plain text.

Further reading: for the full reference, see the official Python documentation.

Previous: Python: Asyncio, async/await for I/O Concurrency

Next: Python: Command-Line Interface (CLI) Tools with argparse and click

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *