Make a dated ZIP backup and verify what was archived
Create a dated ZIP archive from a folder without changing the originals, then verify that every source file appears in the archive with matching content. A small synthetic folder makes the expected paths, sizes, and totals easy to check by hand.
Content checked 2026.09.20Hand-checked example
Show contents
Who this is forThis guide is for people who want a simple dated folder backup with an explicit verification step instead of assuming that archive creation succeeded.
What you need
Python 3.12 and a terminal command that starts that version.
A working folder where the scripts can create folders beneath outputs.
Read permission for the source files and enough disk space for the ZIP archive.
Only the Python standard library is required: datetime, hashlib, pathlib, and zipfile.
01Define what the backup should contain
The script will archive regular files from one source folder, including files in nested subfolders. Paths inside the ZIP are stored relative to the source folder, so a file such as reports/week1.txt stays reports/week1.txt instead of becoming an absolute filesystem path.
The archive name contains the current local calendar date in YYYY-MM-DD form. If the example is run on 2026-09-20, the expected filename is backup_demo_2026-09-20.zip. Running it again on the same day with the same destination causes the script to stop instead of replacing the previous backup.
02Create a synthetic source folder
The following dataset is synthetic and was written specifically for this article. Save the setup script as create_backup_demo.py. It creates four files with exact byte contents, including one file in a nested reports folder.
python
from pathlib import Path
SOURCE = Path("outputs") / "backup_demo"
FILES = {
"notes.txt": b"alpha\n",
"reports/week1.txt": b"beta\n",
"reports/week2.txt": b"gamma\n",
"settings.ini": b"mode=test\n",
}
SOURCE.parent.mkdir(parents=True, exist_ok=True)
SOURCE.mkdir() # Stop if the synthetic source already exists.
for relative_name, content in FILES.items():
target = SOURCE / relative_name
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("xb") as stream:
stream.write(content)
text
python create_backup_demo.py
Relative path
Exact content
Bytes
notes.txt
alpha plus newline
6
reports/week1.txt
beta plus newline
5
reports/week2.txt
gamma plus newline
6
settings.ini
mode=test plus newline
10
The source contains 4 regular files totaling 27 bytes of uncompressed content: 6 + 5 + 6 + 10. The size of the final ZIP file itself cannot be predicted from that total because ZIP compression and archive metadata add their own overhead.
03Prepare the dated backup run
Save the main script as dated_zip_backup.py beside the setup script.
Keep SOURCE set to outputs/backup_demo for this example.
Keep BACKUP_DIR set to outputs/backups so the archive is outside the source folder.
Run the script from the same working directory.
text
python dated_zip_backup.py
The script checks that the source exists and that the backup directory is not inside the source directory. The backup parent folder may already exist. The dated ZIP file itself must not exist.
04Create the ZIP and verify every member
The script first builds a sorted list of source files. It creates the archive with exclusive file creation, writes each file with ZIP_DEFLATED compression, and then reopens the archive for verification. Verification compares the intended member list, checks CRC integrity with testzip, and compares source and archived SHA-256 hashes.
python
from datetime import date
import hashlib
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
SOURCE = Path("outputs") / "backup_demo"
BACKUP_DIR = Path("outputs") / "backups"
CHUNK_SIZE = 1024 * 1024
def hash_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def hash_member(archive: ZipFile, member_name: str) -> str:
digest = hashlib.sha256()
with archive.open(member_name, "r") as stream:
while chunk := stream.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def main() -> None:
source_root = SOURCE.resolve(strict=True)
if not source_root.is_dir():
raise ValueError("SOURCE must be a directory.")
backup_root = BACKUP_DIR.resolve()
if backup_root.is_relative_to(source_root):
raise ValueError("BACKUP_DIR must be outside SOURCE.")
files = sorted(
path for path in source_root.rglob("*")
if path.is_file() and not path.is_symlink()
)
if not files:
raise ValueError("SOURCE contains no regular files to back up.")
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
archive_name = f"{source_root.name}_{date.today().isoformat()}.zip"
archive_path = BACKUP_DIR / archive_name
if archive_path.exists():
raise FileExistsError(f"Backup already exists: {archive_path}")
expected = {}
with archive_path.open("xb") as raw:
with ZipFile(raw, "w", compression=ZIP_DEFLATED) as archive:
for path in files:
relative = path.relative_to(source_root).as_posix()
expected[relative] = (path.stat().st_size, hash_file(path))
archive.write(path, arcname=relative)
with ZipFile(archive_path, "r") as archive:
bad_member = archive.testzip()
if bad_member is not None:
raise RuntimeError(f"CRC check failed: {bad_member}")
members = [info for info in archive.infolist() if not info.is_dir()]
actual_names = [info.filename for info in members]
expected_names = list(expected)
if actual_names != expected_names:
raise RuntimeError("Archive member list does not match the source list.")
for info in members:
expected_size, expected_hash = expected[info.filename]
if info.file_size != expected_size:
raise RuntimeError(f"Size mismatch: {info.filename}")
if hash_member(archive, info.filename) != expected_hash:
raise RuntimeError(f"Hash mismatch: {info.filename}")
total_bytes = sum(size for size, _ in expected.values())
print(f"Verified {len(expected)} files totaling {total_bytes} bytes.")
print(f"Archive: {archive_path.as_posix()}")
if __name__ == "__main__":
main()
The code skips symbolic links and archives regular files only. The expected hashes are calculated from the source immediately before each file is added. For a normal local folder that remains unchanged during the run, this provides a useful content-level comparison after the archive is created.
05Check the expected archive by hand
If the script is run on 2026-09-20, the expected archive path is outputs/backups/backup_demo_2026-09-20.zip. The ZIP should contain exactly the following four member paths.
Archive member
Uncompressed bytes
notes.txt
6
reports/week1.txt
5
reports/week2.txt
6
settings.ini
10
The nested reports folder is represented by the member paths themselves. The script does not need a separate directory entry for reports because the files already carry that relative path.
The expected console output below is derived manually for a run on 2026-09-20. It is not a captured execution log.
Open the ZIP with an archive viewer and confirm that there are exactly 4 files with the expected relative paths.
Extract the archive into a separate temporary location and compare the text contents with the 4 source files.
Confirm that the original files still exist in outputs/backup_demo after the backup completes.
Run the backup script again on the same date without changing the destination. It should stop with FileExistsError instead of replacing the existing archive.
Change a copied source file in a separate test and create a new backup on a different archive name if you want to confirm that verification follows the new content.
Do not use the archive file size as the primary verification check. Compression means a valid archive can be smaller or larger than the uncompressed source total, depending on the data and metadata.
07Recognize common errors
Symptom
What to check
FileNotFoundError
Check SOURCE and the terminal's working directory. Run the synthetic setup script first for this example.
FileExistsError
A backup with the same dated filename already exists. Review it and choose a different destination or wait for a different intended backup date instead of overwriting it.
PermissionError
Confirm that the script can read every source file and create files inside BACKUP_DIR.
CRC check failed
Treat the archive as failed. Do not rely on it as a backup; investigate storage or write errors and create a fresh archive.
Member list mismatch
The source may have changed during the run, or the archive contents may not match the file list assembled at the beginning.
Hash or size mismatch
Treat verification as failed. Keep the original source unchanged and create a new archive after investigating the mismatch.
If an exception occurs after the ZIP file has been created, a partial or unverified archive can remain in the backup folder. The script does not automatically delete it because automatic cleanup can hide evidence useful for diagnosing the failure.
08Understand what this backup does not guarantee
This is a file-copy archive workflow, not a transactional filesystem snapshot. If another application modifies a file while it is being hashed or archived, the result can become inconsistent. For important data, stop writes to the source or use a snapshot-capable backup system.
The example does not preserve every filesystem property. Permissions, ownership, access-control lists, extended attributes, alternate data streams, symbolic links, and application-specific metadata may require a different backup tool. ZIP is useful for portable file collections, but it is not a complete image of a filesystem.
A verified archive is still only one copy. A real backup plan should consider multiple copies, storage failure, accidental deletion, ransomware, retention periods, encryption, and whether recovery has actually been tested. Verification here shows that this archive matched the source content observed during this run; it does not establish a complete disaster-recovery strategy.
Execution and verification record
2026-09-20 · hand-checked example · target: Python 3.12 · standard library: datetime, hashlib, pathlib, zipfile · no execution
Manually checked the four synthetic file sizes as 6, 5, 6, and 10 bytes.
Manually calculated the uncompressed source total as 27 bytes.
Manually listed the expected archive member paths, including the two nested reports paths.
Inspected the code to confirm that source files are read, the ZIP is written beneath outputs, and an existing dated archive causes the run to stop.
Inspected the verification logic for CRC testing, member-list comparison, uncompressed-size comparison, and SHA-256 comparison.
Derived the expected 2026-09-20 archive filename and console output by hand.
Verification limits
The code was not executed by the author of this response; no ZIP archive was created or opened.
SHA-256 values and CRC results were not calculated manually because the comparison logic is the relevant check for this example.
Concurrent source changes, permission failures, storage corruption, interrupted writes, symbolic links, and large-directory performance were not tested.
Compressed archive size was not predicted because it depends on compression results and ZIP metadata.
The official documentation URLs were provided from known documentation locations but were not checked live.
Split a CSV by data-record count, repeat its header in every output file, and leave the original unchanged. Use a seven-record synthetic example to check the boundaries and verify that all records survive in their original order.
Filter files by size, compare SHA-256 digests, and write a review-only CSV without changing the source files. Use a seven-file synthetic example to check which paths belong in the report and which do not.
Resize multiple images into a new folder without stretching them or replacing the source files. A small synthetic image set makes the expected output dimensions easy to calculate by hand.
Scan subfolders and record file paths, extensions, sizes, and modification times in a table. Start with 4 small example files while keeping the originals and existing results intact.