My GIS Pipeline Fails Silently: Fixing Swallowed Errors and Wrong Exit Codes

On this page

Problem statement

The scheduler reports a green run every night. Three weeks later someone notices the output layer has been empty since the 14th. This is how that happens β€” a real script, run against a real folder whose input has gone missing:

%%writefile nightly_bad.py
import logging, geopandas as gpd, pandas as pd
logging.basicConfig(filename="logs/nightly.log", level=logging.INFO, format="[%(asctime)s] %(message)s")
logging.info("starting nightly build")
frames = []
for name in ["roads", "landuse", "places"]:
    try:
        frames.append(gpd.read_file(f"input_today/{name}.gpkg"))     # the delivery did not arrive
    except Exception:
        pass
out = gpd.GeoDataFrame(pd.concat(frames) if frames else pd.DataFrame({"geometry": []}), crs=27700)
out.to_file("output/rutland.gpkg")
logging.info("done")
import subprocess

run = subprocess.run([sys.executable, "nightly_bad.py"])
print("exit code:", run.returncode)
print(Path("logs/nightly.log").read_text().splitlines()[-2:])
print("features written:", len(gpd.read_file("output/rutland.gpkg")))
exit code: 0
['[2026-10-11 00:38:36,756] Created 0 records', '[2026-10-11 00:38:36,759] done']
features written: 0

Nothing crashed, so nothing alerted. "done" was printed by a script that swallowed three exceptions, wrote a zero-row file, and exited 0. The pipeline did not fail β€” it succeeded at producing nothing, which is much harder to notice.

How a failure gets swallowed on its way to the scheduler:

  • except Exception: pass, or an except block that logs at DEBUG level
  • an exit code of 0 returned regardless of what happened
  • a shell wrapper where only the last command's status is checked (cmd1; cmd2)
  • a pipeline a | b whose exit status reflects b alone
  • output written before validation, so a truncated result looks like a result
  • an empty input folder treated as "nothing to do" rather than as a problem
  • a try around a whole stage, so a failure skips work without stopping the run

The unifying theme: at every boundary β€” function, process, shell, scheduler β€” there is a signal that says "this went wrong", and each of those signals can be dropped independently. Every example on this page runs against Geofabrik's OpenStreetMap layers for Rutland, set up in The data used on this page.

Quick answer

To make failures visible:

  1. never write except: pass; log with logger.exception() and re-raise or record
  2. return a real exit code β€” non-zero on any failure β€” from main()
  3. use set -euo pipefail in every shell wrapper
  4. assert on the result, not just the absence of exceptions: row counts, CRS, bbox, file size
  5. emit a machine-readable run summary and alert on it, including "did the job run at all"
log = logging.getLogger("pipeline")


def run_pipeline(input_dir="input"):
    frames = [gpd.read_file(p) for p in sorted(Path(input_dir).glob("*.gpkg"))]
    out = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs=frames[0].crs) if frames else gpd.GeoDataFrame()
    out.to_file("output/rutland.gpkg")
    return {"features": len(out), "output": "output/rutland.gpkg"}


def main(input_dir="input") -> int:
    try:
        result = run_pipeline(input_dir)
    except Exception:
        log.exception("pipeline failed")          # full traceback at ERROR level
        return 1
    if result["features"] == 0:
        log.error("pipeline produced 0 features β€” treating as failure")
        return 1
    log.info("wrote %s features to %s", result["features"], result["output"])
    return 0


print("all inputs present:", main("input"))
print("empty input folder:", main("input_today"))
print("a corrupt input:   ", main("input_with_bad_file"))
all inputs present: 0
empty input folder: 1
a corrupt input:    1

raise SystemExit(main()) at the bottom of the script is the whole exit-code contract in one line: whatever main returns becomes the process status the scheduler sees. The empty folder is caught by the zero-features check, the corrupt file by the exception handler β€” the two different ways the bad script above stayed quiet.

Where the signal gets dropped

The chain from exception to alert, and where the signal is dropped. five hand-offs, five ways to lose itexceptioninside a stepexceptblockpass hides itmain()returnno return = 0shellwrapperno set -eschedulerreads $?alertnever firesBreak the chain anywhere and the run reports green spatialworkflow.io
Five hand-offs, five chances to lose the failure β€” one break makes the run look green.

Stop swallowing exceptions, return honest exit codes

An exit-code convention and what each value should mean to a scheduler. an exit code is an APImeansthe scheduler shouldexit 0βœ“successrecord and move onexit 1βœ•the run failedalert, retry onceexit 2!bad config or inputalert, do not retryexit 3nothing to processlog, alert if unexpectedNobody reads your summary at 02:30 β€” a scheduler reads this number spatialworkflow.io
An exit code is an API. Decide what each value means, and be consistent.

Stop swallowing exceptions

There are three legitimate reasons to catch an exception: to add context and re-raise, to record a per-item failure in a batch, or to handle a genuinely expected condition. Everything else hides bugs. The three patterns, on the truncated GeoPackage:

path = Path("input_with_bad_file/buildings.gpkg")
failures = []

# 1. hides everything, including typos in your own code
try:
    gdf = gpd.read_file(path)
except Exception:
    pass
print("1. pass: nothing printed, nothing recorded")

# 2. records the failure and keeps the batch alive β€” the pattern for per-file loops
try:
    gdf = gpd.read_file(path)
except Exception as exc:
    failures.append((path.name, f"{type(exc).__name__}: {exc}"))
print("2. recorded:", failures[0][1][:90])

# 3. adds context and re-raises β€” the pattern for a single-step pipeline
try:
    try:
        gdf = gpd.read_file(path)
    except Exception as exc:
        raise RuntimeError(f"could not read {path}") from exc
except RuntimeError as err:
    print("3. raised:", err, "| cause:", type(err.__cause__).__name__)
1. pass: nothing printed, nothing recorded
2. recorded: DataSourceError: sqlite3_prepare_v2(SELECT COUNT(*) FROM sqlite_master WHERE name IN ('gpk
3. raised: could not read input_with_bad_file/buildings.gpkg | cause: DataSourceError

raise ... from exc keeps the original exception attached as __cause__, so the traceback shows both the context and the cause.

Log exceptions at a level someone reads

A traceback logged at DEBUG in a job configured at INFO is a traceback nobody will ever see:

logging.basicConfig(filename="logs/levels.log", level=logging.INFO, force=True,
                    format="%(asctime)s %(levelname)-8s %(name)s %(message)s")
job = logging.getLogger("pipeline")
try:
    gpd.read_file(path)
except Exception:
    job.debug("read failed", exc_info=True)       # below the configured level: dropped
    job.exception("read failed")                  # ERROR + traceback
text = Path("logs/levels.log").read_text()
print(text.count("DEBUG"), "DEBUG lines,", text.count("ERROR"), "ERROR line,", text.count("Traceback"), "traceback in the log")
0 DEBUG lines, 1 ERROR line, 1 traceback in the log

logger.exception() is logger.error() plus the traceback, and it is meant for use inside an except block β€” which is exactly where you want it.

Return an honest exit code

Schedulers, CI and make all decide success from the process status. A script that always exits 0 cannot be monitored:

%%writefile nightly.py
import sys, geopandas as gpd
from pathlib import Path

def main() -> int:
    folder = Path(sys.argv[1])
    if not folder.is_dir():
        print(f"config error: {folder} is not a folder", file=sys.stderr)
        return 2
    files = sorted(folder.glob("*.gpkg"))
    if not files:
        print("no input files", file=sys.stderr)
        return 3
    try:
        total = sum(len(gpd.read_file(p)) for p in files)
    except Exception as exc:
        print(f"failed: {type(exc).__name__}: {exc}", file=sys.stderr)
        return 1
    print(f"ok: {total} features from {len(files)} files")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
for folder in ["input", "input_with_bad_file", "input_today", "no_such_folder"]:
    run = subprocess.run([sys.executable, "nightly.py", folder], capture_output=True, text=True)
    print(f"{folder:20} exit {run.returncode}  {(run.stdout or run.stderr).strip()[:70]}")
input                exit 0  ok: 9997 features from 3 files
input_with_bad_file  exit 1  failed: DataSourceError: sqlite3_prepare_v2(SELECT COUNT(*) FROM sqlit
input_today          exit 3  no input files
no_such_folder       exit 2  config error: no_such_folder is not a folder

Distinct codes let a wrapper react differently: retry a transient failure, page someone for a configuration error, ignore "no input" on a public holiday.

Make the shell wrapper strict

By default, a shell script continues after a failing command and reports the status of the last one. Four wrappers around the same failing step:

wrappers = {
    "no options": "\"$PY\" nightly.py no_such_folder\necho transform ran on stale data",
    "set -e": "set -e\n\"$PY\" nightly.py no_such_folder\necho transform ran on stale data",
    "| tee, no pipefail": "set -e\n\"$PY\" nightly.py no_such_folder 2>&1 | tee logs/tee.log",
    "| tee, pipefail": "set -eo pipefail\n\"$PY\" nightly.py no_such_folder 2>&1 | tee logs/tee.log",
}
for label, script in wrappers.items():
    run = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=dict(os.environ, PY=sys.executable))
    ran_on = "transform ran" in run.stdout
    print(f"{label:20} exit {run.returncode}{'   (and the next step ran)' if ran_on else ''}")
no options           exit 0   (and the next step ran)
set -e               exit 2
| tee, no pipefail   exit 0
| tee, pipefail      exit 2

Without -e, the failing step is followed by the next one and the wrapper exits 0. With -e but without pipefail, python job.py | tee log.txt reports tee's status β€” 0. Only set -euo pipefail passes the failure through. Where you need to continue deliberately, say so:

if ! python optional_step.py; then
  echo "optional step failed β€” continuing" >&2
fi
Exit status reported by four shell wrappers around a Python step that exits 2. one step exits 2; what the wrapper reportswrappernext step ran?wrapper exitno optionsyes0set -eno2set -e, | teeno0set -eo pipefail, | teeno2A pipe to tee hides the failure unless pipefail is set spatialworkflow.io
Measured on this page: two of the four wrappers report success for a step that failed.

Validate the output, not just the absence of errors

The most dangerous failures raise nothing at all. Check the result against expectations before declaring success:

def assert_output_sane(gdf, previous_count: int | None = None) -> None:
    if len(gdf) == 0:
        raise ValueError("output has zero features")
    if gdf.crs is None:
        raise ValueError("output has no CRS")
    if gdf.geometry.isna().any():
        raise ValueError(f"{gdf.geometry.isna().sum()} null geometries in output")
    if not gdf.geometry.is_valid.all():
        raise ValueError(f"{(~gdf.geometry.is_valid).sum()} invalid geometries in output")
    if previous_count and len(gdf) < previous_count * 0.5:
        raise ValueError(f"feature count halved: {previous_count} β†’ {len(gdf)}")


good = gpd.read_file("output/rutland.gpkg")
checks = {"today's output": (good, None), "yesterday had 3Γ— as many": (good, 3 * len(good)),
          "the zero-row file": (good.iloc[0:0], None), "no CRS": (good.set_crs(None, allow_override=True), None)}
for label, (gdf, previous) in checks.items():
    try:
        assert_output_sane(gdf, previous)
        print(f"{label:25} ok ({len(gdf):,} features)")
    except ValueError as err:
        print(f"{label:25} {err}")
today's output            ok (9,997 features)
yesterday had 3Γ— as many  feature count halved: 29991 β†’ 9997
the zero-row file         output has zero features
no CRS                    output has no CRS

Comparing against the previous run catches the class of failure where an upstream source quietly starts returning a fraction of its data β€” no error, just less of it.

Write a run summary a machine can read

Human-readable logs are for debugging; a structured summary is for alerting:

import json
from datetime import datetime, timezone


def write_summary(path: Path, **fields) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    record = {"finished_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), **fields}
    path.write_text(json.dumps(record, indent=2), encoding="utf-8")


write_summary(Path("logs/last_run.json"), status="ok", features=len(good), output="output/rutland.gpkg",
              input_files=len(list(Path("input").glob("*.gpkg"))), failures=0)
print(Path("logs/last_run.json").read_text())
{
  "finished_at": "2026-10-10T22:38:39+00:00",
  "status": "ok",
  "features": 9997,
  "output": "output/rutland.gpkg",
  "input_files": 3,
  "failures": 0
}

A monitor that reads last_run.json can alert on status != "ok", on a feature count that dropped, and on a finished_at that is older than expected β€” which is the only way to detect a job that never started.

Detect the job that did not run

A silent failure includes silence itself. Dead-man's-switch monitoring inverts the logic: alert when the success signal is missing. The heartbeat is a request to a monitoring service on success (a hosted cron monitor gives you a URL such as https://hc-ping.com/<uuid>); without one, a check on the summary's age does the same job:

def heartbeat(url: str) -> None:
    try:
        requests.get(url, timeout=10)
    except Exception:
        log.warning("heartbeat failed β€” not fatal")


def is_stale(summary=Path("logs/last_run.json"), hours=26) -> bool:
    finished = datetime.fromisoformat(json.loads(summary.read_text())["finished_at"])
    return datetime.now(timezone.utc) - finished > timedelta(hours=hours)


heartbeat(HEARTBEAT_URL)
print("heartbeats received:", len(pings), "| summary stale:", is_stale())
heartbeats received: 1 | summary stale: False

An entry point with a real contract

The data used on this page

Three folders built from Geofabrik's OpenStreetMap shapefile extract for Rutland, each layer saved as a GeoPackage in British National Grid:

  • input/ β€” roads, land use and places: a normal night.
  • input_today/ β€” empty: the night the delivery did not arrive.
  • input_with_bad_file/ β€” roads, plus the buildings layer cut to half its length, as an interrupted copy leaves it.

The setup also starts a local HTTP listener that records heartbeat and alert requests, standing in for a monitoring service.

import io
import logging
import os
import shutil
import subprocess
import sys
import threading
import zipfile
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path

import geopandas as gpd
import pandas as pd
import requests

for folder in ["input", "input_today", "input_with_bad_file", "output", "logs"]:
    Path(folder).mkdir(exist_ok=True)
if not Path("input/roads.gpkg").exists():
    reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
                         timeout=300)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extractall("osm")
    for name, layer in [("roads", "roads_free_1"), ("landuse", "landuse_a_free_1"), ("places", "places_free_1"),
                        ("buildings", "buildings_a_free_1")]:
        gpd.read_file(f"osm/gis_osm_{layer}.shp").to_crs(27700).to_file(f"input/{name}.gpkg")
    shutil.move("input/buildings.gpkg", "input_with_bad_file/buildings_full.gpkg")
    shutil.copy("input/roads.gpkg", "input_with_bad_file/roads.gpkg")
    data = Path("input_with_bad_file/buildings_full.gpkg").read_bytes()
    Path("input_with_bad_file/buildings.gpkg").write_bytes(data[: len(data) // 2])      # an interrupted copy
    Path("input_with_bad_file/buildings_full.gpkg").unlink()

pings = []


class Listener(BaseHTTPRequestHandler):
    def do_GET(self):
        pings.append(self.path)
        self.send_response(200)
        self.end_headers()

    do_POST = do_GET

    def log_message(self, *args):
        pass


server = HTTPServer(("127.0.0.1", 0), Listener)
threading.Thread(target=server.serve_forever, daemon=True).start()
HEARTBEAT_URL = f"http://127.0.0.1:{server.server_port}/ping/rutland-nightly"
os.environ["ALERT_URL"] = f"http://127.0.0.1:{server.server_port}/alert"
for folder in ["input", "input_today", "input_with_bad_file"]:
    print(folder, sorted(p.name for p in Path(folder).glob("*.gpkg")))
input ['landuse.gpkg', 'places.gpkg', 'roads.gpkg']
input_today []
input_with_bad_file ['buildings.gpkg', 'roads.gpkg']

Example 1: a pipeline entry point with a real contract

import time

log = logging.getLogger("nightly")


def run(cfg) -> dict:
    started = time.perf_counter()
    files = sorted(Path(cfg["input_dir"]).glob("*.gpkg"))
    if not files:
        raise FileNotFoundError(f"no input files in {cfg['input_dir']}")

    frames, failures = [], []
    for path in files:
        try:
            frames.append(gpd.read_file(path))
        except Exception as exc:
            failures.append((path.name, str(exc)))
            log.warning("skipping %s: %s", path.name, exc)
    if not frames:
        raise RuntimeError(f"all {len(files)} inputs failed")

    out = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs=frames[0].crs)
    assert_output_sane(out)
    dest = Path(cfg["output_dir"]) / "rutland.gpkg"
    dest.parent.mkdir(parents=True, exist_ok=True)
    tmp = dest.with_name(dest.stem + ".part.gpkg")        # GDAL picks the driver from the extension
    out.to_file(tmp, driver="GPKG")
    tmp.replace(dest)                                     # atomic on the same filesystem
    return {"features": len(out), "output": str(dest), "inputs": len(files), "failures": len(failures),
            "duration_s": round(time.perf_counter() - started, 1)}


def main_contract(input_dir) -> int:
    try:
        result = run({"input_dir": input_dir, "output_dir": "output"})
    except Exception as exc:
        log.error("run failed: %s", exc)
        write_summary(Path("logs/last_run.json"), status="failed", error=str(exc))
        return 1
    write_summary(Path("logs/last_run.json"), status="ok", **result)
    return 1 if result["failures"] else 0


for folder in ["input", "input_with_bad_file", "input_today"]:
    code = main_contract(folder)
    summary = json.loads(Path("logs/last_run.json").read_text())
    print(f"{folder:20} exit {code}  status={summary['status']}  features={summary.get('features', '-')}"
          f"  failures={summary.get('failures', '-')}")
input                exit 0  status=ok  features=9997  failures=0
input_with_bad_file  exit 1  status=ok  features=7292  failures=1
input_today          exit 1  status=failed  features=-  failures=-

A batch where one input failed still writes the output from the others, but exits 1 and records the failure count, so the partial result is never mistaken for a complete one. The temporary file is named rutland.part.gpkg rather than rutland.gpkg.part: GDAL chooses the driver from the extension, and a .part suffix would leave it guessing.

Example 2: a decorator that guarantees a step is logged

import functools

logging.basicConfig(filename="logs/steps.log", level=logging.INFO, force=True, format="%(levelname)s %(message)s")
steps = logging.getLogger("pipeline")


def step(name):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            steps.info("β†’ %s", name)
            try:
                result = fn(*args, **kwargs)
            except Exception:
                steps.exception("βœ— %s failed", name)
                raise
            steps.info("βœ“ %s", name)
            return result
        return wrapper
    return decorator


@step("clip to Oakham")
def clip_to_box(gdf, box):
    return gpd.clip(gdf, box)


roads = gpd.read_file("input/roads.gpkg")
print(len(clip_to_box(roads, (485000, 307000, 488000, 310000))), "road segments in the box")
try:
    clip_to_box(roads, "not a box")
except Exception:
    pass
print([line.split("\n")[0] for line in Path("logs/steps.log").read_text().splitlines() if line[:4] in ("INFO", "ERRO")])
1138 road segments in the box
['INFO β†’ clip to Oakham', 'INFO βœ“ clip to Oakham', 'INFO β†’ clip to Oakham', 'ERROR βœ— clip to Oakham failed']

Every step now reports start, success or failure, and no step can fail quietly β€” even when a caller further up swallows the exception, as the except: pass here does, the log already has it.

Example 3: guard against a shrinking dataset

STATE = Path("logs/last_run.json")


def check_against_previous(count: int, tolerance: float = 0.2) -> None:
    if not STATE.exists():
        return
    previous = json.loads(STATE.read_text()).get("features")
    if previous and count < previous * (1 - tolerance):
        raise ValueError(f"feature count dropped {previous} β†’ {count} (>{tolerance:.0%})")


write_summary(STATE, status="ok", features=len(good))
check_against_previous(len(good))
try:
    check_against_previous(len(gpd.read_file("input/roads.gpkg")))       # only one of three layers arrived
except ValueError as err:
    print("ValueError:", err)
ValueError: feature count dropped 9997 β†’ 7292 (>20%)

Example 4: fail the shell wrapper properly

%%writefile nightly.sh
#!/usr/bin/env bash
set -euo pipefail

LOG="logs/nightly-$(date +%Y%m%d).log"

on_error() {
  local code=$?
  echo "FAILED at line ${BASH_LINENO[0]} with exit $code" >&2
  curl -fsS -m 10 "$ALERT_URL?job=nightly&status=failed&code=$code" || true
  exit "$code"
}
trap on_error ERR

"$PY" nightly.py "$1" >> "$LOG" 2>&1

curl -fsS -m 10 "$ALERT_URL?job=nightly&status=ok" || true
for folder in ["input", "input_today"]:
    before = len(pings)
    run = subprocess.run(["bash", "nightly.sh", folder], capture_output=True, text=True,
                         env=dict(os.environ, PY=sys.executable))
    print(f"{folder:12} exit {run.returncode} | sent: {pings[before:]} | {run.stderr.strip()}")
input        exit 0 | sent: ['/alert?job=nightly&status=ok'] | 
input_today  exit 3 | sent: ['/alert?job=nightly&status=failed&code=3'] | FAILED at line 14 with exit 3

The trap sends the failure with its exit code β€” 3 here, "no input" β€” and the wrapper still exits with that code, so the scheduler sees it too.

Example 5: the edge cases, checked

import warnings
from concurrent.futures import ThreadPoolExecutor


def code_of(snippet, *flags):
    return subprocess.run([sys.executable, *flags, "-c", snippet], capture_output=True, text=True)


print("sys.exit('message')      ->", code_of("import sys; sys.exit('bad config')").returncode)
print("sys.exit(256)            ->", code_of("import sys; sys.exit(256)").returncode)
print("assert under -O          ->", code_of("assert False, 'never checked'", "-O").returncode)

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    swallow = compile("def f():\n    try:\n        1 / 0\n    finally:\n        return 'ok'\n", "demo", "exec")
namespace = {}
exec(swallow, namespace)
print("return in finally        ->", namespace["f"](), "|", [str(w.category.__name__) for w in caught])

with ThreadPoolExecutor() as pool:
    future = pool.submit(lambda: gpd.read_file("input_with_bad_file/buildings.gpkg"))
print("exception in a thread    -> stored, not raised:", type(future.exception()).__name__)
sys.exit('message')      -> 1
sys.exit(256)            -> 0
assert under -O          -> 0
return in finally        -> ok | ['SyntaxWarning']
exception in a thread    -> stored, not raised: DataSourceError

How far a failure travels before anyone hears

A failure has to travel a long way before a human hears about it. An exception is raised inside a function; a caller either propagates or catches it; main() turns the outcome into an exit code; the process hands that code to a shell; the shell hands its own status to the scheduler; the scheduler decides whether to alert. Each hand-off can drop the signal, and dropping it anywhere makes the whole run look successful.

Six guardrails that keep a pipeline failure visible. one guardrail per hand-offNo "except: pass" anywherelog or raisemain() returns a real exit codeSystemExitset -euo pipefail in the wrappershellOutput asserted: rows, CRS, bboxpost-conditionsRun summary written as JSONmachine-readableHeartbeat or freshness alertcatches "never ran"The last one is the only thing that detects a job that never started spatialworkflow.io
Six guardrails, one per hand-off β€” each closes a different way the signal is lost.

The most common break is the first one. except Exception: pass is usually written to get past one annoying edge case, and then it quietly absorbs every future bug in the same block. The second most common is the exit code: a script that ends by falling off the bottom of the file exits 0, no matter what it logged on the way. The third lives in the shell, where the default is to keep going after an error and report only the last command's status.

Then there is the failure mode that no exception handling can catch, because nothing raised: the job read an empty folder, wrote an empty file, and finished. Correctness here is not about errors at all β€” it is about expectations. A pipeline that knows how many features it should produce, what CRS the output must carry, and roughly how long it should take can detect wrongness that Python has no opinion about. That is why output assertions belong in the same tier as exception handling rather than in a "nice to have" tier.

Finally, monitoring needs to cover absence. If the scheduler never fired, or the machine was down, there is no log, no exit code and no error to notice β€” just silence, which reads exactly like a quiet success. A heartbeat or a freshness check on the run summary is the only thing that distinguishes the two.

Exit codes that do not mean what you think

  • sys.exit("message") exits with code 1: Passing a string prints it to stderr and uses status 1 (checked in Example 5). Pass an int when you mean a specific code.
  • Exceptions in threads do not reach the main thread: A ThreadPoolExecutor stores them in the Future. Call future.result() or the failure is invisible.
  • assert disappears under -O: Never use bare assert for production validation; raise a real exception instead.
  • logging swallows its own errors: A misconfigured handler will not crash the program. Verify the log file is actually being written after a deploy.
  • Warnings are not failures: A geographic-CRS warning from GeoPandas is easy to lose in a long log. Elevate the ones that matter with warnings.simplefilter("error", UserWarning) in tests.
  • Exit codes above 255 wrap: On POSIX only the low 8 bits survive, so sys.exit(256) exits 0 β€” a failure code that reads as success. Keep codes between 1 and 125.
  • A finally that returns swallows the exception: Returning from finally discards the in-flight exception entirely β€” the check in Example 5 returned 'ok' from a division by zero. Python 3.14 now emits a SyntaxWarning for it (PEP 765); never do it.

FAQ

Why does my scheduler show a successful run when the job clearly failed?

Because the process exited 0. Either the exception was caught and not re-raised, or main() returned nothing. End the script with raise SystemExit(main()) and return non-zero on failure.

Is except Exception always wrong?

No β€” it is right at a batch boundary where one item's failure must not stop the others, provided you record the exception. It is wrong when it hides the error, which is why pass is the real problem rather than the broad catch.

How do I make a shell wrapper fail properly?

Start it with set -euo pipefail, and add a trap ... ERR handler if you want to send an alert. Without pipefail, piping output through tee masks the script's exit code entirely.

What should the exit code be when there was nothing to process?

Decide deliberately and document it. Many pipelines treat an empty input as a failure, because "no files today" usually means an upstream delivery is missing rather than genuinely nothing to do.

How do I detect a job that never ran at all?

Write a run summary with a timestamp on every success, and alert when it becomes stale. A heartbeat ping to a cron-monitoring service does the same thing without extra infrastructure.

Should validation raise or just log a warning?

Raise for anything that makes the output unusable β€” zero rows, missing CRS, invalid geometries. Warn for conditions worth watching that do not invalidate the result, and put those counts in the run summary so a trend is visible.

How do I stop a partially written output from looking like a success?

Write to a temporary path and rename it into place once the write returns. Path.replace() is atomic, so the final file only ever exists in a complete state.