python.blacktorgroup.com/Nightly Backup

An unattended backup that says something when it fails.

Ride Me Cycles' Access database backup was a manual copy-paste at the end of the day, which meant it was the first job skipped on a busy one. A scheduled script does the same copy every night, and, critically, only stays quiet when it actually worked.

A translucent black cube etched with a moss-green map of the world's continents, glowing softly against black.
BlackTor Group Ltd Dartmoor, UK
A note on this page

A reference, not a client write-up.

Illustrative, per the note on the main Python page: not real client work. It sits alongside the Product Photos and Duplicate Customers pages as a deeper dive on one script that comes up often enough to be worth its own write-up.

The issue

A successful copy isn't proof of a usable backup.

A plain file copy looks like a complete solution and isn't. The database can be briefly mid-write when a scheduled copy runs, particularly one that starts the instant Access happens to release its file lock: the copy finishes, reports success, and is the right size, but isn't a byte-for-byte match of a working database.

A copy that fails outright is just as much of a problem if nobody hears about it until the backup is actually needed. And a backup folder with no cleanup grows forever, one dated file per night, until something else runs out of disk space first.

Verified backup

run_backup(): copy, then prove the copy is actually correct

"""nightly_backup.py Scheduled (Windows Task Scheduler, nightly after close) backup of the Access database behind the Ride Me Cycles billing system. Verifies the copy by checksum rather than trusting a successful shutil.copy2 call alone, emails on any failure, and prunes backups older than RETENTION_DAYS so the backup folder doesn't grow forever. """ from __future__ import annotations import hashlib import logging import shutil import smtplib from datetime import datetime, timedelta from email.message import EmailMessage from pathlib import Path logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) SOURCE_DATABASE: Path = Path(r"\\FILESERVER\Billing\RideMeCycles.accdb") BACKUP_FOLDER: Path = Path(r"\\FILESERVER\Backups\Billing") RETENTION_DAYS: int = 30 ALERT_RECIPIENT: str = "enquiries@blacktorgroup.com" def file_checksum(file_path: Path, chunk_size: int = 1_048_576) -> str: """Return the SHA-256 checksum of a file, read in fixed-size chunks so a multi-hundred-megabyte database never has to be loaded into memory whole. """ hasher = hashlib.sha256() with file_path.open("rb") as file_handle: for chunk in iter(lambda: file_handle.read(chunk_size), b""): hasher.update(chunk) return hasher.hexdigest() def run_backup() -> Path: """Copy the source database to a dated backup file and confirm the copy is byte-identical to the source before trusting it, raising if not. """ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") destination_path = BACKUP_FOLDER / f"RideMeCycles-{timestamp}.accdb" shutil.copy2(SOURCE_DATABASE, destination_path) # A copy that completed without raising isn't proof it's usable: the # source can be mid-write when Access briefly releases its lock, # giving a copy that's the right size but the wrong bytes. Comparing # checksums catches that; comparing file sizes alone would not. if file_checksum(SOURCE_DATABASE) != file_checksum(destination_path): destination_path.unlink(missing_ok=True) raise IOError(f"Checksum mismatch: {destination_path.name} is not a valid backup") return destination_path def prune_old_backups() -> int: """Delete backups older than RETENTION_DAYS and return how many were removed. """ cutoff_time = datetime.now() - timedelta(days=RETENTION_DAYS) removed_count = 0 for backup_path in BACKUP_FOLDER.glob("RideMeCycles-*.accdb"): if datetime.fromtimestamp(backup_path.stat().st_mtime) < cutoff_time: backup_path.unlink() removed_count += 1 return removed_count def send_failure_alert(error: Exception) -> None: """Email ALERT_RECIPIENT so a failed backup gets noticed the next morning, rather than the next time someone actually needs the backup and finds it isn't there. """ message = EmailMessage() message["Subject"] = "Ride Me Cycles backup FAILED" message["From"] = "backups@ridemecycles.internal" message["To"] = ALERT_RECIPIENT message.set_content(f"Nightly backup failed: {error}") with smtplib.SMTP("localhost") as smtp_connection: smtp_connection.send_message(message) if __name__ == "__main__": try: backup_path = run_backup() removed_count = prune_old_backups() logger.info("Backup OK: %s (%d old backup(s) pruned)", backup_path.name, removed_count) except Exception as error: # deliberately broad: any failure here must alert logger.exception("Backup failed") send_failure_alert(error)

file_checksum is the step that actually proves the backup is trustworthy: reading in fixed one-megabyte chunks means checksumming a large database costs a small, constant amount of memory rather than loading the whole file at once, and the comparison would catch even a single corrupted byte that a file-size check never would.

The broad except Exception at the bottom is deliberate, not carelessness: this script runs once, unattended, at night, and whatever kind of failure happens, a missing network drive, a locked file, a checksum mismatch, the one thing that must always happen is somebody finding out by morning.

prune_old_backups runs only after a successful, verified backup, so a failed night never deletes a good backup from thirty days ago on its way out.

Result

A backup that's either there, verified, or someone already knows it isn't.

The backup runs itself every night, and the only time anyone hears about it is exactly the time they need to: when it didn't work.