Guide
Automating Reporting WorkflowsDeep dive

Handle Permission Denied When Writing Excel in Python

Why openpyxl raises PermissionError when a workbook is open in Excel, how to detect the lock file, retry sensibly, fall back to a timestamped name, and avoid the problem entirely.

PermissionError: [Errno 13] Permission denied: 'report.xlsx' is the most common failure in scheduled Excel jobs, and it almost never means what the message says. Nine times in ten someone has the file open. This guide, part of Error Handling and Logging in Excel Automation, works through diagnosing the real cause and handling each one.

Four causes of a permission error, and how to tell them apart The file is open in Excel, indicated by a lock file beside it. The file is marked read-only. The directory is not writable by the job's user. Or the path is a directory rather than a file. Each has a different check and a different fix. open in Excel — most likely check: a ~$report.xlsx file beside it fix: retry, then say who to ask file is read-only check: os.access(path, os.W_OK) fix: change the mode, or write elsewhere directory not writable check: os.access(parent, os.W_OK) fix: permissions — retrying never helps path is a directory check: path.is_dir() fix: a filename, not a folder

Prerequisites

Bash
pip install openpyxl pandas

Reproduce and diagnose

Rather than guessing, ask the filesystem. Each cause has a different check, and the answer belongs in the error message:

Python
import os
from pathlib import Path

def diagnose(target):
    target = Path(target)
    parent = target.parent

    if target.is_dir():
        return f"{target} is a directory, not a file"
    if not parent.exists():
        return f"directory does not exist: {parent}"
    if not os.access(parent, os.W_OK):
        return f"no write permission on directory: {parent}"

    lock = target.with_name(f"~${target.name}")
    if lock.exists():
        return f"{target.name} appears to be open in Excel (lock file {lock.name} present)"
    if target.exists() and not os.access(target, os.W_OK):
        return f"{target} exists and is read-only"
    return f"{target} could not be written for an unknown reason"

print(diagnose("reports/march.xlsx"))

The lock file check is the useful one. Excel creates ~$march.xlsx alongside the workbook while it is open, and although the file is hidden it is perfectly visible to pathlib. Its presence turns "permission denied" into "Priya has the March report open", which is a message someone can act on.

Note that on Linux and macOS a file open in LibreOffice produces a .~lock.march.xlsx# file instead, so a cross-platform check should look for both patterns.

Save with retries

Because the usual cause resolves itself, this specific error is worth retrying — with backoff, and with a clear message when it finally gives up:

Python
import logging
import time
from pathlib import Path

log = logging.getLogger("report")

def save_with_retry(wb, target, attempts=5, base_delay=2.0):
    target = Path(target)
    for attempt in range(1, attempts + 1):
        try:
            wb.save(target)
            log.info("saved %s", target)
            return target
        except PermissionError as exc:
            if attempt == attempts:
                log.error("could not save %s after %d attempts: %s",
                          target, attempts, diagnose(target))
                raise
            delay = base_delay * (2 ** (attempt - 1))
            log.warning("save blocked (%s) — retry %d/%d in %.0fs",
                        exc.strerror or exc, attempt, attempts, delay)
            time.sleep(delay)

With base_delay=2 and five attempts the job waits about half a minute in total, which covers the common case of a colleague glancing at yesterday's numbers. Longer than that and you are simply delaying a failure someone has to act on anyway.

Exponential backoff across five save attempts The first attempt is immediate, then the job waits two, four, eight and sixteen seconds between attempts, totalling about thirty seconds before it gives up and reports the diagnosis. try 1 0s try 2 +2s try 3 +4s try 4 +8s try 5 +16s give up — report the diagnosis About 30 seconds in total — long enough for a colleague to close the file.

Fall back rather than losing the run

When the target really cannot be written, a completed report is still worth keeping. Write it beside the target under a timestamped name and say so:

Python
from datetime import datetime
from pathlib import Path

def save_with_fallback(wb, target):
    target = Path(target)
    try:
        return save_with_retry(wb, target)
    except PermissionError:
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        fallback = target.with_name(f"{target.stem}_{stamp}{target.suffix}")
        wb.save(fallback)
        log.warning("wrote %s instead — %s", fallback.name, diagnose(target))
        return fallback

The important part is the warning. A silent fallback creates a directory full of near-identical files and a reader who is looking at a stale report without knowing it, so the message has to reach the same place the report does — an alert email, not only the log.

Write atomically, and never into a folder people browse

Two structural changes remove most occurrences of this error entirely. First, write to a temporary file in the same directory and rename it into place, so a partially written workbook can never replace a good one:

Python
import os
from pathlib import Path

def atomic_save(wb, target):
    target = Path(target)
    temp = target.with_name(f".{target.stem}.tmp{target.suffix}")
    wb.save(temp)
    os.replace(temp, target)          # atomic within one filesystem
    return target

Second, generate into a working directory that nobody opens, and publish a copy to the shared location as a separate step. The publish step is a file copy, which is fast and easy to retry — and if the shared copy is locked, the generated report still exists and can be published later without re-running the whole job.

Generate privately, publish separately The job writes the report into a working directory that nobody opens, so the generation step cannot hit a lock. A separate publish step copies it to the shared folder, and only that cheap step needs retrying if a reader has the file open. /var/reports/work/ generation happens here no human ever opens it copy //share/finance/reports/ readers open this copy only the copy step can be locked A failed publish is cheap to retry; a failed generation is not.

Check before you start

Preflight belongs at the top of the job, not at the point of saving. Finding out that the output directory is read-only after twenty minutes of processing wastes the whole run:

Python
import os
from pathlib import Path

def check_writable(target):
    target = Path(target)
    target.parent.mkdir(parents=True, exist_ok=True)
    probe = target.parent / f".write_test_{os.getpid()}"
    try:
        probe.write_text("ok", encoding="utf-8")
        probe.unlink()
    except OSError as exc:
        raise SystemExit(f"cannot write to {target.parent}: {exc}")
    if target.exists() and not os.access(target, os.W_OK):
        raise SystemExit(f"{target} exists and is not writable")

check_writable("reports/march.xlsx")

Actually writing a probe file is more reliable than os.access alone, which reports the permission bits rather than what a network share will really allow. Including the process id in the probe name keeps two concurrent runs from deleting each other's test file.

Stale lock files

Excel removes its ~$ file when the workbook closes cleanly. After a crash, a network drop or a laptop that went to sleep on a VPN, the lock file is left behind and everyone believes the report is open when nobody has it. Treat age as the signal:

Python
import time
from pathlib import Path

def lock_age_hours(target):
    lock = Path(target).with_name(f"~${Path(target).name}")
    if not lock.exists():
        return None
    return (time.time() - lock.stat().st_mtime) / 3600

age = lock_age_hours("reports/march.xlsx")
if age is None:
    print("no lock file")
elif age > 24:
    print(f"lock file is {age:.0f}h old — probably stale, ask before deleting it")
else:
    print(f"someone opened the file {age:.1f}h ago")

Report the age rather than acting on it. Deleting a lock file that is genuinely in use lets two people save over each other, so the script's job is to say "this lock has been here since Tuesday" and let a person decide. Where the report is generated privately and published as a copy, a stale lock stops only the publish step, which is exactly the containment you want.

Common pitfalls and gotchas

SymptomCauseFix
PermissionError every morning at the same timeA reader opens the file before the job runsGenerate privately, publish afterwards
Retry loop never succeedsDirectory permissions, not a lockDiagnose first; retrying cannot fix permissions
Half-written workbook left behindSaved directly onto the targetSave to a temp file and os.replace
Job reports success but the file is staleSilent fallback filenameWarn loudly and alert when a fallback is used
~$ file left behind foreverExcel crashed while the file was openTreat a lock file older than a day as stale
Works locally, fails on the serverDifferent user, different share permissionsProbe-write in preflight under the job's own account

Performance and scale notes

None of this costs measurable time: the diagnosis is a few stat calls, the probe write is a handful of bytes, and os.replace is a metadata operation. The retries are the only real cost, and they are bounded by design — five attempts with a two-second base is about thirty seconds of waiting in the worst case. When a job produces many reports, run the publish step for all of them at the end rather than interleaving generation and publication, so one locked file delays a copy rather than a pipeline.

Conclusion

PermissionError on save is a symptom with four common causes, and the filesystem can tell you which one you have. Diagnose before reacting: look for the ~$ lock file, check the directory and the file mode, and put the answer in the message. Retry with backoff because the usual cause resolves itself, save atomically so a failure cannot destroy the previous report, and structure the job to generate privately and publish separately — which removes the problem rather than handling it.

Frequently asked questions

Why does saving fail when the file is open in Excel? Excel holds an exclusive lock on the workbook while it is open on Windows, so no other process may replace it. Python sees that as PermissionError.

How can I tell whether the file is open rather than read-only? Look for Excel's lock file — the same name prefixed with ~$ — in the same directory. Its presence means someone has the workbook open.

Is retrying a good idea? Yes for this specific error, with exponential backoff. A colleague usually closes the file within a minute, and the alternative is a failed run for a reason nobody caused deliberately.

What is the cleanest way to avoid it altogether? Write to a directory people do not browse, then publish a copy. A report nobody has open cannot be locked.

Up to the parent guide:

Related guides: