Batch Output Files Keep Overwriting Each Other: How to Fix It
Problem statement
The batch run reports 240 files processed. The output folder contains one.
processed 240 files
$ ls data/out
output.gpkg
Or the count is right but the contents are wrong: roads.gpkg in the output holds the last region's roads, and 11 earlier regions have vanished. Nothing raised an error β every write succeeded. They just all wrote to the same place.
Where this comes from:
- the output filename is a constant, built outside the loop
- a recursive input walk flattens the tree, so
north/roads.shpandsouth/roads.shpboth becomeroads.gpkg to_file()on an existing GeoPackage replaces the layer instead of adding one- the output name is derived from a column value that repeats
- a parallel run has two workers writing the same path at the same time
- the output directory is also the input directory, so the second pass consumes its own results
The symptom is silent because writing a file that already exists is a normal operation. Nothing in GeoPandas, Fiona, or the filesystem considers it an error, so the loss is only visible when you count the results.
Quick answer
To stop outputs from colliding:
- derive the output name from the input path inside the loop β never a fixed string
- preserve the input folder structure with
relative_to()instead of flattening it - add a discriminator (region, date, layer name) when stems can repeat
- check for an existing target before writing, and either skip, version, or fail
- write to a temporary name and rename on success so partial files are never mistaken for results
from pathlib import Path
import geopandas as gpd
SRC, OUT = Path("data/raw").resolve(), Path("data/out").resolve()
for shp in sorted(SRC.rglob("*.shp")):
rel = shp.relative_to(SRC).with_suffix(".gpkg") # north/roads.gpkg
dest = OUT / rel
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
raise FileExistsError(f"refusing to overwrite {dest}")
gpd.read_file(shp).to_file(dest, driver="GPKG")
The two lines that matter are relative_to() β which keeps north/ and south/ apart β and the exists() guard, which converts a silent overwrite into a loud failure.
How outputs collide
Step-by-step solution
Move the output path inside the loop
The classic bug is a path computed once, above the loop, and reused for every iteration.
# wrong: one target for every input
out_path = Path("data/out/output.gpkg")
for shp in files:
gpd.read_file(shp).to_file(out_path, driver="GPKG")
# right: one target per input
for shp in files:
out_path = Path("data/out") / f"{shp.stem}.gpkg"
gpd.read_file(shp).to_file(out_path, driver="GPKG")
If you want everything in one file, that is a different operation β concatenate the frames and write once, or append layers explicitly (see below). Repeatedly overwriting is never the way to combine data.
Mirror the input tree instead of flattening it
Path.stem throws away the folder, which is exactly the information that made two same-named files distinct.
from pathlib import Path
SRC = Path("data/raw").resolve()
OUT = Path("data/out").resolve()
for shp in sorted(SRC.rglob("*.shp")):
rel = shp.relative_to(SRC) # north/roads.shp
dest = (OUT / rel).with_suffix(".gpkg") # data/out/north/roads.gpkg
dest.parent.mkdir(parents=True, exist_ok=True)
mkdir(parents=True, exist_ok=True) is required β writers do not create missing intermediate directories, and the resulting error is a confusing "No such file or directory" on a path you can see in the message.
Or flatten deliberately, with the folder in the name
Sometimes a flat output folder is genuinely easier to consume. Then encode the structure in the filename rather than discarding it.
rel = shp.relative_to(SRC).with_suffix("") # north/roads
flat_name = "_".join(rel.parts) + ".gpkg" # north_roads.gpkg
dest = OUT / flat_name
This keeps a single directory listing while guaranteeing uniqueness, and the origin of each file remains readable.
Detect collisions before writing anything
The cheapest fix is to compute every output path up front and check for duplicates. A collision found in the first second is far better than one found after two hours.
from collections import Counter
targets = {}
for shp in sorted(SRC.rglob("*.shp")):
dest = (OUT / shp.relative_to(SRC)).with_suffix(".gpkg")
targets.setdefault(dest, []).append(shp)
clashes = {d: srcs for d, srcs in targets.items() if len(srcs) > 1}
if clashes:
for dest, srcs in clashes.items():
print(f"! {dest.name} would be written by {len(srcs)} inputs:")
for s in srcs:
print(f" {s}")
raise SystemExit("output paths collide β fix the naming rule")
Understand what to_file() does to an existing GeoPackage
This one surprises people, because it is not a filesystem overwrite. Writing a GeoPackage that already exists replaces the layer of that name and leaves other layers intact β unless you ask for something else.
import geopandas as gpd
# replaces the layer "roads" inside atlas.gpkg, keeps other layers
gdf.to_file("atlas.gpkg", layer="roads", driver="GPKG")
# adds rows to an existing layer instead of replacing it
gdf.to_file("atlas.gpkg", layer="roads", driver="GPKG", mode="a")
So a loop that writes every region to atlas.gpkg with the same layer= name ends up with only the last region β but a loop that varies layer= produces a single, tidy multi-layer file. That is often exactly what you want.
for shp in sorted(SRC.rglob("*.shp")):
layer = "_".join(shp.relative_to(SRC).with_suffix("").parts)
gpd.read_file(shp).to_file(OUT / "atlas.gpkg", layer=layer, driver="GPKG")
Shapefiles have no such concept: one shapefile is one layer, so unique filenames are the only option there.
Write atomically so failures cannot corrupt a good file
If the process dies mid-write, you are left with a file that exists but is incomplete β and the next run will happily treat it as done.
from pathlib import Path
import geopandas as gpd
def write_atomic(gdf, dest: Path, driver="GPKG"):
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".part")
gdf.to_file(tmp, driver=driver)
tmp.replace(dest) # atomic on the same filesystem
Path.replace() is an atomic rename on POSIX and overwrites on Windows too, so a reader never observes a half-written result.
Decide the overwrite policy explicitly
Every batch needs one of three behaviours, chosen on purpose:
MODE = "skip" # "skip" | "overwrite" | "version" | "fail"
if dest.exists():
if MODE == "skip":
continue
if MODE == "fail":
raise FileExistsError(dest)
if MODE == "version":
n = 1
while dest.with_stem(f"{dest.stem}_{n}").exists():
n += 1
dest = dest.with_stem(f"{dest.stem}_{n}")
# "overwrite" falls through and writes
skip doubles as a crude resume mechanism; fail is the right default while you are still developing the naming rule.
Code examples
Example 1: safe per-file conversion with a mirrored tree
from pathlib import Path
import geopandas as gpd
SRC = Path("data/raw").resolve()
OUT = Path("data/out").resolve()
OVERWRITE = False
written, skipped, failed = [], [], []
for shp in sorted(SRC.rglob("*.shp")):
dest = (OUT / shp.relative_to(SRC)).with_suffix(".gpkg")
if dest.exists() and not OVERWRITE:
skipped.append(dest)
continue
try:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".part")
gpd.read_file(shp).to_file(tmp, driver="GPKG")
tmp.replace(dest)
written.append(dest)
except Exception as exc:
failed.append((shp.name, f"{type(exc).__name__}: {exc}"))
print(f"written {len(written)}, skipped {len(skipped)}, failed {len(failed)}")
Example 2: one output file, many layers
from pathlib import Path
import geopandas as gpd
SRC = Path("data/raw").resolve()
atlas = Path("data/out/atlas.gpkg")
atlas.parent.mkdir(parents=True, exist_ok=True)
if atlas.exists():
atlas.unlink() # start clean; appending to a stale file confuses runs
for shp in sorted(SRC.rglob("*.shp")):
layer = "_".join(shp.relative_to(SRC).with_suffix("").parts).lower()
gpd.read_file(shp).to_file(atlas, layer=layer, driver="GPKG")
print(f"wrote layer {layer}")
Example 3: genuinely combining many inputs into one layer
If the goal was a merged dataset all along, concatenate and write once β with a column recording where each row came from.
from pathlib import Path
import geopandas as gpd
import pandas as pd
SRC = Path("data/raw").resolve()
frames = []
for shp in sorted(SRC.rglob("*.shp")):
gdf = gpd.read_file(shp)
gdf["source_file"] = str(shp.relative_to(SRC))
frames.append(gdf.to_crs("EPSG:3857"))
merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:3857")
merged.to_file("data/out/merged.gpkg", layer="all_regions", driver="GPKG")
print(f"{len(merged)} features from {len(frames)} files")
Example 4: unique names when the stem is not enough
Some deliveries name every file export.shp inside a per-date folder. A short hash of the relative path keeps names unique and stable across runs.
import hashlib
from pathlib import Path
def unique_name(shp: Path, src_root: Path) -> str:
rel = shp.relative_to(src_root).as_posix()
digest = hashlib.sha1(rel.encode("utf-8")).hexdigest()[:8]
return f"{shp.stem}_{digest}.gpkg"
A hash is stable β the same input always produces the same output name β which a counter is not.
Explanation
Writing a file is a destructive operation by default. open(path, "w"), to_file(), and every GDAL driver behind them assume that if you named a target, you meant it. There is no "are you sure?" step, because in the ordinary single-file case the overwrite is the intent.
A batch changes the arithmetic. With 240 inputs and one target, 239 writes are pure waste and the result is indistinguishable from a run that processed a single file. The fix is to make the mapping from input to output injective β every distinct input must produce a distinct target. The natural key is the input's path relative to the search root, because that is exactly what made the inputs distinct in the first place. Path.stem discards it; Path.relative_to() preserves it.
GeoPackage adds a second layer of naming, and understanding it removes a lot of confusion. The file is a container, and layer= names the dataset within it. Writing the same layer name repeatedly overwrites within the container; varying it accumulates. So the same to_file() call can behave like an overwrite or like an append depending on one argument β which is why a batch that "loses" its results into a single GeoPackage usually just needs a varying layer=.
The final concern is durability. A crash during a write leaves a file that exists but is not valid, and a resumable batch that checks dest.exists() will then skip it forever. Writing to name.part and renaming into place means the target only ever appears once it is complete, which makes existence a trustworthy signal of "done".
Edge cases or notes
- Shapefile sidecars move together: Overwriting a
.shpleaves stale.dbf/.shx/.prjfiles if the write fails partway. This is one more reason to prefer GeoPackage for outputs. - Case-insensitive filesystems:
Roads.gpkgandroads.gpkgare the same file on Windows and default macOS. Normalise output names to lower case to avoid platform-dependent collisions. - Parallel workers need distinct targets: Two processes writing the same GeoPackage will corrupt it. Give each worker its own output file and merge afterwards.
mode="a"requires a matching schema: Appending to an existing layer fails if column names or types differ. Normalise the schema before appending.- Do not write into the input folder: A recursive glob will pick up your own outputs on the next run, doubling the work and eventually the data.
with_stem()needs Python 3.9+: On older versions usedest.with_name(f"{dest.stem}_{n}{dest.suffix}").
Internal links
- How to Process GIS Files in Nested Subfolders and Mirror the Output Tree
- How to Batch Convert Shapefiles to GeoPackage in Python
- How to Merge Many Shapefiles into One File in Python
- How to Read and Write GeoPackage Files in Python
- How to Build a Resumable Batch GIS Job in Python
- Python glob Is Not Finding All My Shapefiles: How to Fix It
FAQ
Why did my batch produce only one output file?
The output path was almost certainly constant β computed above the loop, or built from a fixed string. Every iteration wrote to the same target, so only the last one survives. Build the path inside the loop from the input path.
Does to_file() overwrite a GeoPackage or add to it?
It replaces the named layer and leaves other layers alone. Vary layer= per input to accumulate layers in one file, or pass mode="a" to append rows to an existing layer.
How do I keep files with the same name from different folders apart?
Use shp.relative_to(src_root) to build the output path, which preserves the folder structure. If you need a flat output folder, join the relative parts into the filename instead: north_roads.gpkg.
Should the script overwrite existing outputs or skip them?
Skip while iterating on a long batch β it gives you a cheap resume. Overwrite for scheduled production runs that must always reflect the latest input. Whichever you choose, make it an explicit flag rather than an accident.
How do I make sure a crash does not leave a corrupt output?
Write to a temporary path such as name.gpkg.part and call Path.replace() once the write returns. The rename is atomic, so the final path only ever exists in a complete state.
Can two parallel workers write to the same GeoPackage?
No. SQLite-based formats are not safe for concurrent writers from separate processes, and you risk a corrupt file. Give each worker its own output and merge at the end.
Why do I get "No such file or directory" for a path I can see in the message?
The parent directory does not exist. Writers do not create intermediate folders, so call dest.parent.mkdir(parents=True, exist_ok=True) before every write when you are mirroring an input tree.