How to Batch Convert Shapefiles to GeoPackage in Python
On this page
You have a folder of shapefiles — dozens of them, each dragging along its .dbf, .shx, .prj, and .cpg sidecars — and you are tired of the sprawl. Field names have been chopped to ten characters, one file is bumping against the 2 GB ceiling, and every time you move data around a .prj goes missing and the CRS vanishes. GeoPackage fixes all of that: one SQLite file, long field names, real data types, and either one .gpkg per source or every layer tucked into a single file. This guide shows you how to batch convert a whole folder of shapefiles to GeoPackage in Python, safely, with per-file error handling and a check that the output actually opens.
Problem statement
The folder here holds eight shapefiles: six Natural Earth layers, an analysis output whose long column names were cut to ten characters when it was saved, and a copy of the lakes file cut short at 4 KB, the way an interrupted copy leaves one. A second folder holds an airports file that arrived without its .shx. Converting them means a loop over every .shp, into one GeoPackage per file or one for all, that keeps going past the file that raises and reopens every result — because the cut-short copy does not raise at all.
Quick answer
Loop over the folder with pathlib, read each shapefile with gpd.read_file(), and write it out with gdf.to_file(out_path, driver="GPKG"). For one file per shapefile, build an output path from path.stem. For everything in one GeoPackage, write to a single .gpkg and pass layer=path.stem so each source becomes its own named layer. Wrap each file in its own try/except so one bad shapefile does not abort the batch.
import geopandas as gpd
from pathlib import Path
src_dir = Path("data/shapefiles")
out_dir = Path("data/geopackages")
out_dir.mkdir(parents=True, exist_ok=True)
converted, failed = [], []
for path in sorted(src_dir.glob("*.shp")):
try:
gdf = gpd.read_file(path)
out_path = out_dir / f"{path.stem}.gpkg"
gdf.to_file(out_path, driver="GPKG", layer=path.stem)
converted.append(out_path.name)
except Exception as exc:
failed.append((path.name, str(exc)))
print(f"Converted {len(converted)} files, {len(failed)} failed")
for name, err in failed:
print(f" FAIL {name}: {err}")
Converted 8 files, 0 failed
Nothing failed — and one of those eight files lost almost all of its geometry on the way. damaged.shp is a copy cut short at 4 KB, and GDAL reads it without an error, returning its 1,355 rows with 1,352 empty geometries. The try/except cannot catch what does not raise; only the check in "Verify the output opens" does.
Discover, convert and verify each shapefile
Discover the shapefiles
Use pathlib.Path plus glob to find every .shp. Globbing *.shp deliberately matches only the main component — GeoPandas pulls in the .dbf, .shx, .prj, and .cpg sidecars automatically when it reads the .shp. Use rglob instead of glob if your shapefiles are nested in subfolders.
from pathlib import Path
src_dir = Path("data/shapefiles")
shapefiles = sorted(src_dir.glob("*.shp"))
print(f"Found {len(shapefiles)} shapefiles")
for path in shapefiles:
print(path.name)
Found 8 shapefiles
airports.shp
countries.shp
country_stats.shp
damaged.shp
lakes.shp
places.shp
ports.shp
rivers.shp
Never glob * and expect it to work — you would try to open .dbf and .shx files directly and get errors. Match the .shp only and let the driver assemble the rest.
Choose an output layout
There are two sensible targets, and the choice shapes the rest of the script:
- One GeoPackage per shapefile —
roads.shpbecomesroads.gpkg. This is the closest one-to-one replacement, easy to reason about, and keeps datasets independent. - One GeoPackage, many layers —
roads.shp,sites.shp, andparks.shpall become layers insideproject.gpkg. This collapses a whole folder into a single portable file, which is the strongest reason to adopt GeoPackage in the first place.
GeoPackage supports both because a .gpkg is a SQLite database that can hold any number of layers. Pick per-file when datasets are unrelated and shared separately; pick single-file when they belong to one project. If you need the format background, see how to read and write GeoPackage files in Python.
Convert one file per shapefile
Read each shapefile and write it straight back out with driver="GPKG". Build the output name from path.stem so roads.shp maps cleanly to roads.gpkg. Setting layer=path.stem names the single layer sensibly rather than leaving it as the default file stem.
import geopandas as gpd
from pathlib import Path
src_dir = Path("data/shapefiles")
out_dir = Path("data/geopackages")
out_dir.mkdir(parents=True, exist_ok=True)
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path)
out_path = out_dir / f"{path.stem}.gpkg"
gdf.to_file(out_path, driver="GPKG", layer=path.stem)
print(f"{path.name} -> {out_path.name}")
airports.shp -> airports.gpkg
countries.shp -> countries.gpkg
country_stats.shp -> country_stats.gpkg
damaged.shp -> damaged.gpkg
lakes.shp -> lakes.gpkg
places.shp -> places.gpkg
ports.shp -> ports.gpkg
rivers.shp -> rivers.gpkg
One .shp (plus its sidecars) collapses into one self-contained .gpkg. The CRS travels inside the file, so you never have to worry about a missing .prj again.
Convert everything into one GeoPackage
To collapse the whole folder into a single file, write each shapefile to the same .gpkg with a distinct layer= name. Use path.stem as the layer name so each source file is identifiable inside the database.
import geopandas as gpd
from pathlib import Path
src_dir = Path("data/shapefiles")
out_path = Path("data/project.gpkg")
out_path.parent.mkdir(parents=True, exist_ok=True)
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path)
gdf.to_file(out_path, driver="GPKG", layer=path.stem)
print(f"Wrote layer '{path.stem}' to {out_path.name}")
Wrote layer 'airports' to project.gpkg
Wrote layer 'countries' to project.gpkg
Wrote layer 'country_stats' to project.gpkg
Wrote layer 'damaged' to project.gpkg
Wrote layer 'lakes' to project.gpkg
Wrote layer 'places' to project.gpkg
Wrote layer 'ports' to project.gpkg
Wrote layer 'rivers' to project.gpkg
Because each write targets a different layer=, they coexist in one file instead of overwriting each other. Two files with the same stem would collide — the second write replaces the first layer — so make sure your stems are unique.
Handle each file's errors independently
A real folder always has one surprise: a corrupt geometry, a shapefile missing a sidecar, or an empty file. Wrap the read-write cycle for each file in its own try/except so a single failure is logged and skipped rather than aborting an overnight batch. Here it runs over data/incoming, where airports arrived without its .shx:
import geopandas as gpd
from pathlib import Path
src_dir = Path("data/incoming")
out_dir = Path("data/geopackages")
out_dir.mkdir(parents=True, exist_ok=True)
failed = []
for path in sorted(src_dir.glob("*.shp")):
try:
gdf = gpd.read_file(path)
gdf.to_file(out_dir / f"{path.stem}.gpkg", driver="GPKG", layer=path.stem)
print(f"OK {path.name}")
except Exception as exc:
print(f"FAIL {path.name}: {exc}")
failed.append((path.name, str(exc)))
print(f"\nDone. {len(failed)} failed.")
FAIL airports.shp: Unable to open data/incoming/airports.shx or data/incoming/airports.SHX. Set SHAPE_RESTORE_SHX config option to YES to restore or create it.
OK ports.shp
Done. 1 failed.
GDAL names the fix in its own message: with the SHAPE_RESTORE_SHX=YES configuration option it rebuilds the index from the .shp.
Verify the output opens
Converting is not done until you have confirmed the GeoPackage reads back. List the layers in the file, read each one back, and count the features and the geometries — a feature count alone passes the damaged file.
import geopandas as gpd
import fiona
out_path = "data/project.gpkg"
layers = fiona.listlayers(out_path)
print(f"{out_path} contains {len(layers)} layers: {layers}")
for name in layers:
check = gpd.read_file(out_path, layer=name)
missing = int(check.geometry.isna().sum())
flag = f" ! {missing:,} without geometry" if missing else ""
print(f" {name:14} {len(check):6,} features, EPSG:{check.crs.to_epsg()}{flag}")
data/project.gpkg contains 8 layers: ['airports', 'countries', 'country_stats', 'damaged', 'lakes', 'places', 'ports', 'rivers']
airports 893 features, EPSG:4326
countries 258 features, EPSG:4326
country_stats 258 features, EPSG:4326
damaged 1,355 features, EPSG:4326 ! 1,352 without geometry
lakes 1,355 features, EPSG:4326
places 7,342 features, EPSG:4326
ports 1,081 features, EPSG:4326
rivers 1,473 features, EPSG:4326
Matching feature counts are not enough: damaged has every row and almost none of its shapes. Count missing geometries as well, and compare against the source when it matters.
Converting Natural Earth shapefiles, truncated names included
The data used on this page
The folder is six Natural Earth 1:10m shapefiles — countries, populated places, airports, ports, rivers and lakes — plus two that make it look like a real folder: country_stats.shp, an analysis output with long column names saved as a shapefile (so GDAL truncated them), and damaged.shp, a copy of the lakes file cut short at 4 KB, as an interrupted copy leaves it. A second folder, data/incoming, holds ports and an airports file that arrived without its .shx.
import io
import shutil
import zipfile
from pathlib import Path
import geopandas as gpd
import requests
src = Path("data/shapefiles")
src.mkdir(parents=True, exist_ok=True)
LAYERS = {"countries": "cultural/ne_10m_admin_0_countries", "places": "cultural/ne_10m_populated_places",
"airports": "cultural/ne_10m_airports", "ports": "cultural/ne_10m_ports",
"rivers": "physical/ne_10m_rivers_lake_centerlines", "lakes": "physical/ne_10m_lakes"}
for name, part in LAYERS.items():
if (src / f"{name}.shp").exists():
continue
reply = requests.get(f"https://naciscdn.org/naturalearth/10m/{part}.zip", timeout=120)
reply.raise_for_status()
stem = part.split("/")[1]
with zipfile.ZipFile(io.BytesIO(reply.content)) as z:
for ext in (".shp", ".shx", ".dbf", ".prj", ".cpg"):
if f"{stem}{ext}" in z.namelist():
(src / f"{name}{ext}").write_bytes(z.read(f"{stem}{ext}"))
# An analysis output someone saved as a shapefile, long column names and all.
if not (src / "country_stats.shp").exists():
c = gpd.read_file(src / "countries.shp")
stats = gpd.GeoDataFrame({
"country_name": c["NAME"],
"population_density": c["POP_EST"] / (c.to_crs(6933).area / 1e6), # people per km²
"gdp_per_capita_usd": c["GDP_MD"] * 1e6 / c["POP_EST"],
}, geometry=c.geometry, crs=c.crs)
stats.to_file(src / "country_stats.shp")
# And the file every real folder has: a .shp cut short by an interrupted copy.
if not (src / "damaged.shp").exists():
for ext in (".shx", ".dbf", ".prj"):
shutil.copy(src / f"lakes{ext}", src / f"damaged{ext}")
(src / "damaged.shp").write_bytes((src / "lakes.shp").read_bytes()[:4096])
# A second folder that arrived without one of its .shx index files.
incoming = Path("data/incoming")
incoming.mkdir(parents=True, exist_ok=True)
for name, exts in (("ports", (".shp", ".shx", ".dbf", ".prj")), ("airports", (".shp", ".dbf", ".prj"))):
for ext in exts:
if not (incoming / f"{name}{ext}").exists():
shutil.copy(src / f"{name}{ext}", incoming / f"{name}{ext}")
for path in sorted(src.glob("*.shp")):
sidecars = sorted(p.suffix for p in src.glob(f"{path.stem}.*"))
print(f"{path.name:18} {path.stat().st_size / 1e6:6.2f} MB {' '.join(sidecars)}")
airports.shp 0.03 MB .cpg .dbf .prj .shp .shx
countries.shp 8.81 MB .cpg .dbf .prj .shp .shx
country_stats.shp 8.81 MB .cpg .dbf .prj .shp .shx
damaged.shp 0.00 MB .dbf .prj .shp .shx
lakes.shp 2.68 MB .cpg .dbf .prj .shp .shx
places.shp 0.21 MB .cpg .dbf .prj .shp .shx
ports.shp 0.03 MB .cpg .dbf .prj .shp .shx
rivers.shp 4.21 MB .cpg .dbf .prj .shp .shx
Example 1: One GeoPackage per shapefile
The simplest layout — each shapefile becomes its own .gpkg in a parallel output folder.
import geopandas as gpd
from pathlib import Path
src_dir = Path("data/shapefiles")
out_dir = Path("data/geopackages")
out_dir.mkdir(parents=True, exist_ok=True)
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path)
out_path = out_dir / f"{path.stem}.gpkg"
gdf.to_file(out_path, driver="GPKG", layer=path.stem)
print(f"{path.name} -> {out_path.name} ({len(gdf)} features)")
airports.shp -> airports.gpkg (893 features)
countries.shp -> countries.gpkg (258 features)
country_stats.shp -> country_stats.gpkg (258 features)
damaged.shp -> damaged.gpkg (1355 features)
lakes.shp -> lakes.gpkg (1355 features)
places.shp -> places.gpkg (7342 features)
ports.shp -> ports.gpkg (1081 features)
rivers.shp -> rivers.gpkg (1473 features)
Example 2: All shapefiles into one GeoPackage with layer=path.stem
Collapse a whole folder into a single portable file, one named layer per source.
import geopandas as gpd
import fiona
from pathlib import Path
src_dir = Path("data/shapefiles")
out_path = Path("data/project.gpkg")
out_path.parent.mkdir(parents=True, exist_ok=True)
# Remove an existing file so a re-run starts clean instead of appending.
if out_path.exists():
out_path.unlink()
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path)
gdf.to_file(out_path, driver="GPKG", layer=path.stem)
print(f"Wrote {len(fiona.listlayers(out_path))} layers to {out_path.name}")
Wrote 8 layers to project.gpkg
Deleting the target first avoids stale layers lingering from a previous run.
Example 3: Preserve field names that the shapefile truncated
Shapefiles cap column names at 10 characters, so the truncation already happened when the shapefile was written — GeoPackage cannot magically recover population_density from population. What you can do is detect the truncation and restore the intended names with a rename map before writing.
import geopandas as gpd
from pathlib import Path
# Map the 10-char shapefile names back to their intended full names.
RENAMES = {
"country_na": "country_name",
"population": "population_density",
"gdp_per_ca": "gdp_per_capita_usd",
}
src_dir = Path("data/shapefiles")
out_dir = Path("data/geopackages")
out_dir.mkdir(parents=True, exist_ok=True)
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path)
truncated = [c for c in gdf.columns if len(c) == 10]
if truncated:
print(f"{path.name}: possibly truncated -> {truncated}")
gdf = gdf.rename(columns={k: v for k, v in RENAMES.items() if k in gdf.columns})
gdf.to_file(out_dir / f"{path.stem}.gpkg", driver="GPKG", layer=path.stem)
print(list(gpd.read_file(out_dir / "country_stats.gpkg").columns))
airports.shp: possibly truncated -> ['featurecla', 'wikidataid', 'wdid_score']
countries.shp: possibly truncated -> ['featurecla', 'SOVEREIGNT', 'NAME_CIAWF', 'MAPCOLOR13', 'INCOME_GRP', 'ADM0_A3_US', 'ADM0_A3_FR', 'ADM0_A3_RU', 'ADM0_A3_ES', 'ADM0_A3_CN', 'ADM0_A3_TW', 'ADM0_A3_IN', 'ADM0_A3_NP', 'ADM0_A3_PK', 'ADM0_A3_DE', 'ADM0_A3_GB', 'ADM0_A3_BR', 'ADM0_A3_IL', 'ADM0_A3_PS', 'ADM0_A3_SA', 'ADM0_A3_EG', 'ADM0_A3_MA', 'ADM0_A3_PT', 'ADM0_A3_AR', 'ADM0_A3_JP', 'ADM0_A3_KO', 'ADM0_A3_VN', 'ADM0_A3_TR', 'ADM0_A3_ID', 'ADM0_A3_PL', 'ADM0_A3_GR', 'ADM0_A3_IT', 'ADM0_A3_NL', 'ADM0_A3_SE', 'ADM0_A3_BD', 'ADM0_A3_UA', 'ADM0_A3_UN', 'ADM0_A3_WB', 'ABBREV_LEN', 'WIKIDATAID', 'FCLASS_ISO', 'FCLASS_TLC']
country_stats.shp: possibly truncated -> ['country_na', 'population', 'gdp_per_ca']
damaged.shp: possibly truncated -> ['featurecla', 'wikidataid']
lakes.shp: possibly truncated -> ['featurecla', 'wikidataid']
places.shp: possibly truncated -> ['FEATURECLA', 'MAX_POP300', 'MAX_POP310', 'MAX_NATSCA', 'MIN_AREAKM', 'MAX_AREAKM', 'MIN_AREAMI', 'MAX_AREAMI', 'MIN_BBXMIN', 'MAX_BBXMIN', 'MIN_BBXMAX', 'MAX_BBXMAX', 'MIN_BBYMIN', 'MAX_BBYMIN', 'MIN_BBYMAX', 'MAX_BBYMAX', 'WIKIDATAID', 'GEONAMESID', 'FCLASS_ISO', 'FCLASS_TLC']
ports.shp: possibly truncated -> ['featurecla']
rivers.shp: possibly truncated -> ['featurecla', 'wikidataid']
['country_name', 'population_density', 'gdp_per_capita_usd', 'geometry']
The length test is a heuristic, not a detector: it flags every name that happens to be exactly ten characters. Natural Earth's own columns are designed to fit, so the flags on those layers are false alarms — which is why the rename map has to come from someone who knows what the columns were meant to be called.
Once the data is in GeoPackage, long names survive on every future write. For why the truncation happens in the first place, see why shapefile column names get truncated.
Example 4: Full batch with try/except and a summary table
The version you would actually run on a real folder — per-file error handling plus a summary you can scan or save.
import geopandas as gpd
import pandas as pd
from pathlib import Path
src_dir = Path("data/shapefiles")
out_dir = Path("data/geopackages")
out_dir.mkdir(parents=True, exist_ok=True)
rows = []
for path in sorted(src_dir.glob("*.shp")):
try:
gdf = gpd.read_file(path)
out_path = out_dir / f"{path.stem}.gpkg"
gdf.to_file(out_path, driver="GPKG", layer=path.stem)
rows.append({"file": path.name, "features": len(gdf),
"no_geometry": int(gdf.geometry.isna().sum()),
"fields": len(gdf.columns) - 1, "status": "ok"})
except Exception as exc:
rows.append({"file": path.name, "features": 0, "no_geometry": 0,
"fields": 0, "status": f"error: {type(exc).__name__}"})
report = pd.DataFrame(rows)
print(report.to_string(index=False))
report.to_csv(out_dir / "_conversion_report.csv", index=False)
file features no_geometry fields status
airports.shp 893 0 40 ok
countries.shp 258 0 168 ok
country_stats.shp 258 0 3 ok
damaged.shp 1355 1352 41 ok
lakes.shp 1355 0 41 ok
places.shp 7342 0 137 ok
ports.shp 1081 0 6 ok
rivers.shp 1473 0 38 ok
Example 5: Read a layer back and list all layers to verify
After the batch, confirm the GeoPackage is sound. Both fiona.listlayers and pyogrio.list_layers enumerate what is inside; read one layer back to check the feature count.
import geopandas as gpd
import fiona
import pyogrio
out_path = "data/project.gpkg"
# Fiona: returns a plain list of layer names.
print("fiona:", fiona.listlayers(out_path))
# pyogrio: returns an array of [name, geometry_type] pairs.
print("pyogrio:", pyogrio.list_layers(out_path))
for name in fiona.listlayers(out_path):
gdf = gpd.read_file(out_path, layer=name)
print(f" {name}: {len(gdf)} features, CRS {gdf.crs}")
fiona: ['airports', 'countries', 'country_stats', 'damaged', 'lakes', 'places', 'ports', 'rivers']
pyogrio: [['airports' 'Point']
['countries' 'MultiPolygon']
['country_stats' 'MultiPolygon']
['damaged' 'Polygon']
['lakes' 'MultiPolygon']
['places' 'Point']
['ports' 'Point']
['rivers' 'MultiLineString']]
airports: 893 features, CRS EPSG:4326
countries: 258 features, CRS EPSG:4326
country_stats: 258 features, CRS EPSG:4326
damaged: 1355 features, CRS EPSG:4326
lakes: 1355 features, CRS EPSG:4326
places: 7342 features, CRS EPSG:4326
ports: 1081 features, CRS EPSG:4326
rivers: 1473 features, CRS EPSG:4326
Every layer reads back with the feature count its source had — including damaged, which is why a count is not a verification. Its 1,352 missing geometries show up only in the geometry check under "Verify the output opens" and in the no_geometry column of Example 4's report.
The limits GeoPackage removes
Two of those limits showed up in this folder. country_stats lost its column names to the ten-character cap before the conversion ever ran, and the airports file could not be read at all without its .shx, short of asking GDAL to rebuild the index. In a GeoPackage both are gone: names are stored in full, and a layer is one table inside one file, with no sidecar to lose.
Truncated names, collisions and re-runs
Field names were already truncated at the source
Converting to GeoPackage does not un-truncate anything. If a name was cut to population when the shapefile was written, that is the name GeoPandas reads. GeoPackage lets you store the full name once you rename the column (Example 3), but you must know or reconstruct the original — the information is not hiding in the shapefile.
Duplicate layer names collide in single-file mode
When you write many shapefiles into one .gpkg, two source files with the same stem produce the same layer= name, and the second write overwrites the first. If your folder has 2020/roads.shp and 2021/roads.shp, disambiguate the layer name — prefix it with the parent folder, for example layer=f"{path.parent.name}_{path.stem}".
Re-running appends rather than replacing
Writing a layer that already exists replaces that layer, but layers from a previous run that no longer have a matching source file stay behind. For a clean rebuild of a single-file GeoPackage, delete the .gpkg before the loop (Example 2) so the output reflects only the current folder.
Mixed or missing CRS across the folder
to_file writes whatever CRS each GeoDataFrame carries, so a folder of shapefiles in different CRSs produces GeoPackages in different CRSs. That is fine for a straight conversion, but if you need everything in one CRS — or some files have no .prj and read as crs=None — handle that separately. See how to standardise and repair CRS across a folder of files; this guide deliberately stays focused on the format conversion.
Internal links
- How to Batch Process GIS Files in Python — the parent guide to folder-based GIS automation
- How to Read and Write GeoPackage Files in Python
- How to Batch Process Multiple Shapefiles in Python
- How to Standardise and Repair CRS Across a Folder of Files
- Why Are My Shapefile Column Names Truncated?
- How to Merge Many Shapefiles into One File
FAQ
How do I convert a whole folder of shapefiles to GeoPackage in Python?
Loop over the folder with pathlib, read each .shp with gpd.read_file(), and write it out with gdf.to_file(out_path, driver="GPKG"). Build the output name from path.stem for one file per shapefile, or write every file to the same .gpkg with a distinct layer= name to collapse the folder into a single file. Wrap each file in try/except so one bad shapefile does not stop the batch.
Should I make one GeoPackage per shapefile or put them all in one file?
Both are valid. Use one .gpkg per shapefile when the datasets are unrelated and shared separately — it stays close to the original layout. Use a single .gpkg with many named layers when the files belong to one project and you want a single portable file. GeoPackage handles both because a .gpkg is a SQLite database that can store many layers.
Does converting to GeoPackage restore my truncated field names?
No. The shapefile format truncated the names to 10 characters when it was written, so the full names are already lost by the time you read the file. GeoPackage supports long names going forward, but you must supply the intended names yourself — for example with a rename map (Example 3). See why shapefile column names get truncated for the underlying cause.
What does the layer= argument do in to_file?
layer= names the layer inside the GeoPackage. Because a .gpkg can hold many layers, writing several GeoDataFrames to the same file with different layer= names stores them side by side instead of overwriting. Writing two GeoDataFrames with the same layer= name replaces the layer, so keep your layer names unique when collapsing a folder into one file.
How do I check that the GeoPackage actually converted correctly?
List the layers with fiona.listlayers(path) or pyogrio.list_layers(path) and confirm they match what you expect. Then read each layer back with gpd.read_file(path, layer=name), compare the feature count to the source shapefile, and count missing geometries with gdf.geometry.isna().sum(). A shapefile cut short by an interrupted copy reads without an error and keeps every row — measured here, 1,352 of 1,355 geometries came back empty — so the count alone does not prove the conversion.
Why is my second layer overwriting the first in the same GeoPackage?
You are almost certainly writing both with the same layer= name — often because two source shapefiles share a stem, so path.stem produces an identical name. Give each layer a unique name, for example by prefixing the parent folder: layer=f"{path.parent.name}_{path.stem}".
Do I need to handle the CRS during conversion?
Not for a straight format conversion — to_file writes whatever CRS each file already carries, and GeoPackage stores it internally per layer. If files are in different CRSs and you need one common CRS, or some shapefiles are missing their .prj and read as crs=None, handle that as a separate step. See how to standardise and repair CRS across a folder of files.
How do I keep one corrupt shapefile from stopping the whole batch?
Wrap the read-and-write cycle for each file in its own try/except, log the exception, append the failure to a list, and continue the loop. The bad file ends up in your summary marked as an error while every other file still converts (Example 4). Never let a single failure abort a large or overnight conversion.