Handle Permission Denied When Writing Excel in Python
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.
Prerequisites
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:
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:
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.
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:
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:
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.
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:
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:
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
| Symptom | Cause | Fix |
|---|---|---|
PermissionError every morning at the same time | A reader opens the file before the job runs | Generate privately, publish afterwards |
| Retry loop never succeeds | Directory permissions, not a lock | Diagnose first; retrying cannot fix permissions |
| Half-written workbook left behind | Saved directly onto the target | Save to a temp file and os.replace |
| Job reports success but the file is stale | Silent fallback filename | Warn loudly and alert when a fallback is used |
~$ file left behind forever | Excel crashed while the file was open | Treat a lock file older than a day as stale |
| Works locally, fails on the server | Different user, different share permissions | Probe-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.
Related
Up to the parent guide:
- Error Handling and Logging in Excel Automation — where this fits among the other failure classes.
Related guides:
- Retry a Failed Excel Report Job in Python — backoff and which errors qualify.
- Log Python Excel Script Output to a File — recording the diagnosis where you will find it.
- Scheduling Python Excel Scripts with cron — the environment where this failure appears most.
- Using openpyxl for Excel File Manipulation — the save call itself.