Run Python Scripts with Windows Task Scheduler

The script processes a folder of invoices perfectly from a terminal. Scheduled, it reports a result code and nothing else:

Last Run Result: 0x1
Last Run Result: 0x2
Last Run Result: 0xC000013A

There is no traceback anywhere. The History tab shows the task started and completed; the output folder is empty, and running the exact same command by hand still works.

Root Cause

A scheduled task runs in a different environment from an interactive shell, and four differences account for nearly every failure. The working directory defaults to C:\Windows\System32 rather than the script's folder, so every relative path in the script resolves somewhere else — which is 0x2, file not found. PATH is the system one, not the user's, so a python installed per-user or via the Microsoft Store is not on it. Mapped network drives belong to an interactive logon session and simply do not exist for a task, so Z:\incoming is unreachable even though the same letter works in Explorer. And when the task is set to run whether or not the user is logged on, it gets no desktop and no access to the user's credential store, so anything relying on either fails. 0x1 is the script's own non-zero exit; 0xC000013A means it was terminated.

Result codes and what they usually mean Zero means success. 0x1 means the program exited non-zero, so the traceback is inside the script's own output and needs redirecting to a file. 0x2 means the system could not find the file, usually the interpreter path or the working directory. 0x41301 means the task is still running. 0x8007010B is an invalid directory in the start-in field. 0xC000013A means the process was terminated, often by the task's own execution time limit. 0x80070005 is access denied, usually the run-as account lacking rights to the folder. Result Meaning Usual cause 0x0 success nothing to do 0x1 script exited non-zero traceback not captured 0x2 file not found interpreter or start-in path 0x41301 still running previous run never ended 0x8007010B invalid directory bad start-in value 0xC000013A terminated hit the execution time limit 0x80070005 access denied run-as account lacks rights

Minimal Diagnostic

Make the task write a report of its own environment, so the difference from the terminal is visible.

# stdlib only — save as diagnose.py and point the task at it
import getpass
import os
import socket
import sys
from datetime import datetime
from pathlib import Path

REPORT = Path(r"C:\automation\logs\environment.txt")

def main() -> int:
    REPORT.parent.mkdir(parents=True, exist_ok=True)
    lines = [
        f"when:        {datetime.now().isoformat(timespec='seconds')}",
        f"host:        {socket.gethostname()}",
        f"user:        {getpass.getuser()}",
        f"executable:  {sys.executable}",
        f"script:      {Path(__file__).resolve()}",
        f"cwd:         {Path.cwd()}",
        f"PATH:        {os.environ.get('PATH', '')[:300]}",
        f"USERPROFILE: {os.environ.get('USERPROFILE', '(unset)')}",
        f"interactive: {sys.stdin is not None and sys.stdin.isatty()}",
    ]
    for label, path in [("input", r"C:\automation\in"), ("mapped", r"Z:\incoming"),
                        ("unc", r"\\fileserver\share\incoming")]:
        target = Path(path)
        lines.append(f"{label:<12} {path}: exists={target.exists()} "
                     f"readable={os.access(path, os.R_OK) if target.exists() else 'n/a'}")
    REPORT.write_text("\n".join(lines), encoding="utf-8")
    print("\n".join(lines))
    return 0

if __name__ == "__main__":
    sys.exit(main())
when:        2026-09-17T02:00:04
user:        SVC_DOCPIPE
executable:  C:\Python312\python.exe
script:      C:\automation\diagnose.py
cwd:         C:\Windows\System32
PATH:        C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;...
USERPROFILE: C:\Users\SVC_DOCPIPE
interactive: False
input        C:\automation\in: exists=True readable=True
mapped       Z:\incoming: exists=False readable=n/a
unc          \\fileserver\share\incoming: exists=True readable=True

Three findings in one run: the working directory is System32, the mapped drive does not exist, and the UNC path behind it does.

Fix: Absolute Paths, an Explicit Start-In, and Captured Output

Register the task with the full interpreter path, a working directory, and redirection so failures leave a trace.

# Run in an elevated PowerShell prompt
$python  = "C:\Python312\python.exe"
$script  = "C:\automation\pipeline\run.py"
$workdir = "C:\automation\pipeline"
$log     = "C:\automation\logs\nightly.log"

$action = New-ScheduledTaskAction `
    -Execute "cmd.exe" `
    -Argument "/c `"`"$python`" `"$script`" >> `"$log`" 2>&1`"" `
    -WorkingDirectory $workdir            # fixes the System32 default

$trigger = New-ScheduledTaskTrigger -Daily -At 02:00

$settings = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
    -MultipleInstances IgnoreNew `
    -StartWhenAvailable `
    -RestartCount 2 -RestartInterval (New-TimeSpan -Minutes 10) `
    -DontStopIfGoingOnBatteries -AllowStartIfOnBatteries

$principal = New-ScheduledTaskPrincipal `
    -UserId "DOMAIN\SVC_DOCPIPE" -LogonType Password -RunLevel Limited

Register-ScheduledTask -TaskName "Nightly document pipeline" `
    -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force

Wrapping the command in cmd.exe /c is what captures the traceback. Task Scheduler discards a program's standard output and error, so without redirection a 0x1 result is all the evidence there is; with it, the exception is in the log file the next morning.

-MultipleInstances IgnoreNew prevents two copies running when one night's job overruns into the next, and -StartWhenAvailable runs a missed task once the machine is back — both worth having on a document pipeline, where a duplicated run does more damage than a late one. -ExecutionTimeLimit is what turns a hang into a 0xC000013A at a predictable hour rather than a task that is still running a week later.

Setting up a task that actually runs Choose a service account with a password that does not expire and grant it rights to the folders and shares involved. Use absolute paths for the interpreter and the script, never a bare python command. Set the start-in directory so relative paths resolve. Redirect standard output and error to a log file so a failure leaves a traceback. Set the multiple-instances rule and an execution time limit. Finally run the task by hand from the scheduler and read the log, before trusting the schedule. Choose the run-as account rights to every path Absolute interpreter path no bare python Set start-in not System32 Redirect output traceback to a file Instance and time limits no overlap, no hang Run once by hand read the log

Variant Fix 1: Network Shares and Mapped Drives

A mapped letter is per-logon-session, so a task never sees it. Use UNC paths, and give the task's account rights to the share:

# stdlib only
import os
import subprocess
from pathlib import Path

INCOMING = Path(r"\\fileserver\share\incoming")          # UNC, never Z:\

def ensure_share(path: Path, timeout: int = 30) -> None:
    if path.exists():
        return
    host = path.parts[0] if path.parts else ""
    probe = subprocess.run(["net", "use", host], capture_output=True, text=True, timeout=timeout)
    raise FileNotFoundError(
        f"{path} unreachable as {os.environ.get('USERNAME')}; net use says: "
        f"{(probe.stderr or probe.stdout).strip()[:200]}")

A task set to "Run whether user is logged on or not" with the LogonType Password principal can reach shares, because it has network credentials. The S4U logon type — "Do not store password" — cannot, which is the single most common reason a working script fails only when scheduled. If the share must be reached, the account needs its password stored.

Variant Fix 2: Virtual Environments and Dependencies

Point the task at the environment's own interpreter rather than activating anything:

$python = "C:\automation\pipeline\.venv\Scripts\python.exe"
$action = New-ScheduledTaskAction -Execute "cmd.exe" `
    -Argument "/c `"`"$python`" -m pipeline.run >> `"$log`" 2>&1`"" `
    -WorkingDirectory "C:\automation\pipeline"

A virtual environment's python.exe already resolves its own site-packages, so activate.bat adds nothing a scheduled task needs. Using -m pipeline.run rather than a script path also makes imports resolve from the package rather than from the current directory, removing the last dependency on where the task happens to start.

Guard the environment at startup so a missing dependency fails clearly:

# stdlib only
import importlib
import sys

REQUIRED = ["pandas", "pypdf", "openpyxl"]

def check_imports() -> None:
    missing = []
    for name in REQUIRED:
        try:
            importlib.import_module(name)
        except ImportError:
            missing.append(name)
    if missing:
        print(f"missing package(s) {missing} in {sys.executable}", file=sys.stderr)
        sys.exit(3)                           # a distinct exit code, visible as 0x3 in the task history

Distinct exit codes turn the result column into information. Reserving a few — 2 for configuration, 3 for dependencies, 4 for unreachable inputs — means the scheduler's own display says what went wrong before anyone opens the log.

Why the terminal and the task disagree An interactive run starts in the folder the user is in, inherits the user's PATH including per-user Python installs, sees drive letters mapped in that logon session, and can prompt for or read stored credentials. A scheduled run starts in System32, has only the system PATH, sees no mapped drives at all, and has credentials only if the principal stores a password. Each difference produces a distinct failure, and all four are fixed in the task definition rather than in the script. Working directory user folder vs System32, amber, relative paths break PATH user PATH vs system PATH, amber, python not found Drive letters mapped vs absent, red, use UNC paths Credentials session vs stored password, red, S4U cannot reach shares Output console vs discarded, red, redirect to a file

Logging That Survives the Night

A redirected log file is the minimum. Rotating it and writing structured records makes it useful after the second week:

# stdlib only
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path

def configure_logging(logdir: Path, name: str = "pipeline") -> logging.Logger:
    logdir.mkdir(parents=True, exist_ok=True)
    handler = RotatingFileHandler(logdir / f"{name}.log", maxBytes=5_000_000,
                                  backupCount=7, encoding="utf-8")
    handler.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)-8s %(name)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S"))
    logger = logging.getLogger(name)
    logger.setLevel(logging.INFO)
    logger.addHandler(handler)
    logging.captureWarnings(True)
    return logger

Setting encoding="utf-8" explicitly avoids the other classic Windows surprise: the default console encoding on some systems cannot represent characters found in customer filenames, and a UnicodeEncodeError inside a log call crashes the job for reasons entirely unrelated to the work. Keeping seven rotated files gives a week of history in a few tens of megabytes, which is enough to answer most questions without any external system.

Choosing the Account the Task Runs As

The run-as account decides more about whether a task works than any other setting, and the convenient choices are the ones that cause trouble later.

Running as a named person means the task stops the day they change their password or leave, and the failure arrives as 0x80070005 weeks after the cause. Running as SYSTEM avoids the password problem but gives the job local administrator rights it does not need, and SYSTEM reaches network shares as the machine account, which usually has no permissions on them. A dedicated service account with a non-expiring password, granted only the rights the job needs — read on the incoming share, write on the output folder, nothing else — is the arrangement that survives.

Whichever account is chosen, grant it "Log on as a batch job" in the local security policy. Without that right, the task fails immediately with a logon failure that looks nothing like a permissions problem, and the account's other rights are irrelevant because it never starts.

Verification

Run the task by hand and assert the outcome from outside it.

# stdlib only
import subprocess
import time
from datetime import datetime, timedelta
from pathlib import Path

TASK = "Nightly document pipeline"

def verify_task(log: Path, outdir: Path, timeout: int = 900) -> None:
    size_before = log.stat().st_size if log.exists() else 0
    subprocess.run(["schtasks", "/Run", "/TN", TASK], check=True, capture_output=True)
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        query = subprocess.run(["schtasks", "/Query", "/TN", TASK, "/FO", "LIST", "/V"],
                               capture_output=True, text=True, check=True)
        status = next((l.split(":", 1)[1].strip() for l in query.stdout.splitlines()
                       if l.strip().startswith("Status:")), "")
        if status.lower() != "running":
            break
        time.sleep(5)
    else:
        raise TimeoutError(f"task still running after {timeout}s")
    result = next((l.split(":", 1)[1].strip() for l in query.stdout.splitlines()
                   if "Last Result" in l), "?")
    assert result in {"0", "0x0"}, f"last result {result} — check {log}"
    assert log.exists() and log.stat().st_size > size_before, "task wrote nothing to the log"
    recent = [p for p in outdir.glob("*") if
              datetime.fromtimestamp(p.stat().st_mtime) > datetime.now() - timedelta(hours=1)]
    assert recent, f"no output written to {outdir} in the last hour"
    print(f"task ran, result {result}, {len(recent)} output file(s), log grew by "
          f"{log.stat().st_size - size_before} bytes")

Checking that the log grew is the assertion that catches a task reporting success while doing nothing — a redirection that silently failed, or a script that exited early on a condition nobody expected. A result of 0 with an unchanged log file and an empty output folder is the quietest failure this environment produces.

FAQ

Why does the task work when I am logged on but not otherwise? Almost always the S4U logon type, which has no network credentials. Switch the principal to store a password.

Should I use pythonw.exe instead? Only for a GUI script. pythonw discards output, which removes the one diagnostic channel that works here.

Can I use a .bat wrapper instead of cmd /c? Yes, and it is often clearer — put the redirection and any environment setup in the batch file and point the task at that.

How do I stop two runs overlapping? Set multiple instances to IgnoreNew, and add a lock file in the script for the case where the task is also started manually.

Part of Scheduling and Logging Automation Jobs.