How to Load a Folder of Shapefiles into PostGIS with Python
Problem statement
You have 340 shapefiles from a data supplier and a PostGIS database. The loop looks trivial:
for path in Path("data").glob("*.shp"):
gpd.read_file(path).to_postgis(path.stem, engine, if_exists="replace")
It runs for two hours and then you discover what it actually produced:
- Some tables are in EPSG:4326 and some in EPSG:27700, because the source files were mixed.
- Half the tables have no spatial index, so every subsequent query scans.
- One file was
Cheshire East.shp, giving a table calledCheshire Eastthat needs quoting forever after. - The
MultiPolygon/Polygonsplit means one table rejects half its own data on the next append. - The job died at file 212 and rerunning it starts from zero.
- Column names were truncated to ten characters by the shapefile format long before Python saw them.
None of these are hard problems individually. The point is that a folder load has to solve all of them at once, and the one-line version solves none.
Quick answer
from pathlib import Path
import geopandas as gpd
from sqlalchemy import create_engine, text
import re
engine = create_engine("postgresql+psycopg://user@localhost/gis")
SRID = 27700
def safe_name(path):
name = re.sub(r"[^a-z0-9_]+", "_", path.stem.lower()).strip("_")
return f"t_{name}" if not name[0].isalpha() else name
for path in sorted(Path("data").glob("*.shp")):
table = safe_name(path)
gdf = gpd.read_file(path)
if gdf.crs is None:
print(f" β {path.name}: no CRS, skipped"); continue
gdf = gdf.to_crs(SRID)
gdf.columns = [c.lower() for c in gdf.columns]
gdf.to_postgis(table, engine, if_exists="replace", index=False, chunksize=10_000)
with engine.begin() as con:
con.execute(text(f'CREATE INDEX ON "{table}" USING GIST (geometry)'))
con.execute(text(f'ANALYZE "{table}"'))
print(f" β {table:<28} {len(gdf):>8,} rows")
| Step | Why it cannot be skipped |
|---|---|
| audit before loading | mixed CRS and geometry types are the norm, not the exception |
| sanitise table names | spaces and capitals mean quoting every query forever |
| unify the CRS | otherwise no two tables can be joined |
| load in chunks | a single 4-million-row insert is one long transaction |
index and ANALYZE |
an unindexed table makes every later query a scan |
| verify counts | a partial load looks exactly like a complete one |
Step-by-step solution
1. Audit the folder before writing anything
from pathlib import Path
import geopandas as gpd, pandas as pd, pyogrio
def audit(folder, pattern="*.shp"):
rows = []
for path in sorted(Path(folder).glob(pattern)):
try:
info = pyogrio.read_info(path)
rows.append({
"file": path.name,
"rows": info["features"],
"crs": info["crs"],
"geom_type": info["geometry_type"],
"fields": len(info["fields"]),
"mb": round(sum(p.stat().st_size for p in path.parent.glob(path.stem + ".*")) / 1e6, 1),
})
except Exception as exc:
rows.append({"file": path.name, "error": str(exc)[:60]})
df = pd.DataFrame(rows)
print(df.groupby("crs", dropna=False)["rows"].agg(["count", "sum"]))
print(df["geom_type"].value_counts().to_dict())
return df
df = audit("data/")
count sum
crs
EPSG:27700 318 8412993
EPSG:4326 21 104882
None 1 0
{'MultiPolygon': 294, 'Polygon': 41, 'Point': 5}
pyogrio.read_info reads the header only, so auditing 340 shapefiles takes seconds rather than loading gigabytes. Three findings are already visible: twenty-one files need reprojecting, one has no CRS at all, and the geometry type is split between Polygon and MultiPolygon.
2. Sanitise table names
PostgreSQL folds unquoted identifiers to lower case. A table created as "Cheshire East" can only ever be referenced as "Cheshire East", quotes included, by everyone, forever.
import re
def safe_name(stem, prefix="", max_len=63):
name = re.sub(r"[^a-z0-9_]+", "_", stem.lower()).strip("_")
name = re.sub(r"_+", "_", name)
if not name or not name[0].isalpha():
name = "t_" + name
return (prefix + name)[:max_len] # 63 bytes is Postgres's limit
for stem in ["Cheshire East", "2026-boundaries", "Ward Areas (final)"]:
print(f"{stem!r:<24} β {safe_name(stem)}")
'Cheshire East' β cheshire_east
'2026-boundaries' β t_2026_boundaries
'Ward Areas (final)' β ward_areas_final
Watch for collisions: Ward-Areas and Ward Areas both sanitise to ward_areas. Check before loading rather than discovering it when the second file silently replaces the first.
from collections import Counter
names = [safe_name(p.stem) for p in Path("data").glob("*.shp")]
clashes = {n: c for n, c in Counter(names).items() if c > 1}
if clashes:
raise SystemExit(f"table name collisions: {clashes}")
3. Unify the CRS and the geometry type
TARGET_SRID = 27700
def prepare(gdf, srid=TARGET_SRID, promote_multi=True):
if gdf.crs is None:
raise ValueError("no CRS β cannot load safely")
if gdf.crs.to_epsg() != srid:
gdf = gdf.to_crs(srid)
gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].copy()
if promote_multi:
from shapely.geometry import MultiPolygon, MultiLineString
types = set(gdf.geom_type)
if types <= {"Polygon", "MultiPolygon"} and len(types) > 1:
gdf["geometry"] = [
g if g.geom_type == "MultiPolygon" else MultiPolygon([g])
for g in gdf.geometry
]
elif types <= {"LineString", "MultiLineString"} and len(types) > 1:
gdf["geometry"] = [
g if g.geom_type == "MultiLineString" else MultiLineString([g])
for g in gdf.geometry
]
gdf.columns = [c.lower() for c in gdf.columns]
return gdf
The mixed-type promotion matters because to_postgis creates a typed geometry column from whatever it finds first. A file whose first row is a Polygon gets a geometry(Polygon, 27700) column, and the first MultiPolygon row then fails:
Geometry type (MultiPolygon) does not match column type (Polygon)
Promoting the other way round β a single Polygon into a geometry(MultiPolygon) column β is accepted by PostGIS, which promotes it silently. Only MultiPolygon into geometry(Polygon) is rejected. Promoting everything to multi is therefore the safe direction. See PostGIS write fails on SRID or geometry type.
4. Load, then index, then analyse β in that order
from sqlalchemy import text
def load_one(gdf, table, engine, srid=TARGET_SRID, chunksize=10_000):
gdf.to_postgis(table, engine, if_exists="replace",
index=False, chunksize=chunksize)
with engine.begin() as con:
con.execute(text(f'CREATE INDEX "{table}_geom_idx" ON "{table}" USING GIST (geometry)'))
con.execute(text(f'ANALYZE "{table}"'))
return table
The order is not stylistic. if_exists="replace" drops the table and every index on it, so an index created beforehand is discarded β and even if it survived, maintaining it through the insert would cost more than building it once at the end. ANALYZE gives the planner the statistics it needs to actually choose the new index; without it, a fresh index is often ignored. See PostGIS spatial indexes explained.
chunksize bounds how much is in flight at once. Without it, a 4-million-row frame becomes one enormous statement and one very long transaction.
5. Make the job resumable
A folder load is a batch job, and it should behave like one:
def already_loaded(engine, table):
with engine.begin() as con:
return con.execute(text(
"SELECT to_regclass(:t) IS NOT NULL"), {"t": f"public.{table}"}).scalar()
Skipping existing tables turns a two-hour job that died at file 212 into a twenty-minute resume. The general pattern is in how to build a resumable batch GIS job.
6. Verify
def verify(engine, table, expected_rows, srid=TARGET_SRID):
with engine.begin() as con:
got = con.execute(text(f'SELECT COUNT(*) FROM "{table}"')).scalar()
srids = con.execute(text(
f'SELECT DISTINCT ST_SRID(geometry) FROM "{table}"')).scalars().all()
invalid = con.execute(text(
f'SELECT COUNT(*) FROM "{table}" WHERE NOT ST_IsValid(geometry)')).scalar()
has_idx = con.execute(text(
"SELECT COUNT(*) FROM pg_indexes WHERE tablename = :t "
"AND indexdef LIKE '%gist%'"), {"t": table}).scalar()
problems = []
if got != expected_rows: problems.append(f"{expected_rows:,} in β {got:,} out")
if srids != [srid]: problems.append(f"SRIDs {srids}")
if not has_idx: problems.append("no GiST index")
if invalid: problems.append(f"{invalid:,} invalid geometries")
return problems
Row count in versus out is the check that catches a partial load, which otherwise looks exactly like a complete one.
Code examples
Example 1: the complete loader
from pathlib import Path
from collections import Counter
import re, time
import geopandas as gpd, pandas as pd, pyogrio
from sqlalchemy import create_engine, text
def load_folder(folder, engine, *, srid=27700, pattern="*.shp",
prefix="", resume=True, chunksize=10_000):
paths = sorted(Path(folder).glob(pattern))
names = [safe_name(p.stem, prefix) for p in paths]
clashes = {n: c for n, c in Counter(names).items() if c > 1}
if clashes:
raise ValueError(f"table name collisions: {clashes}")
results = []
for path, table in zip(paths, names):
t0 = time.perf_counter()
if resume and already_loaded(engine, table):
results.append({"file": path.name, "table": table, "status": "skipped"})
continue
try:
expected = pyogrio.read_info(path)["features"]
gdf = prepare(gpd.read_file(path), srid=srid)
load_one(gdf, table, engine, srid=srid, chunksize=chunksize)
problems = verify(engine, table, len(gdf), srid=srid)
results.append({
"file": path.name, "table": table, "rows": len(gdf),
"dropped": expected - len(gdf),
"status": "ok" if not problems else "warn",
"problems": "; ".join(problems),
"seconds": round(time.perf_counter() - t0, 1),
})
except Exception as exc:
results.append({"file": path.name, "table": table,
"status": "failed", "problems": str(exc)[:120]})
df = pd.DataFrame(results)
marks = {"ok": "β", "warn": "!", "skipped": "Β·", "failed": "β"}
for r in results:
print(f" {marks[r['status']]} {r['table']:<30} "
f"{r.get('rows', 0):>9,} {r.get('problems', '')}")
print(df["status"].value_counts().to_dict())
return df
engine = create_engine("postgresql+psycopg://user@localhost/gis")
report = load_folder("data/", engine, srid=27700)
report.to_csv("load_report.csv", index=False)
β cheshire_east 182,447
β cheshire_west 204,118
! stockport 118,204 412 invalid geometries
β ward_areas_final 0 no CRS β cannot load safely
Β· trafford 0
{'ok': 316, 'warn': 12, 'skipped': 8, 'failed': 4}
Four statuses, not two. "Warn" means the table loaded but something needs attention; collapsing it into "ok" hides 412 invalid geometries that will break the next spatial join, and collapsing it into "failed" would send someone re-running a load that worked. dropped records the difference between the header count and the loaded count β null and empty geometries removed by prepare, which you want to know about rather than discover later.
Example 2: many files into one table
Often the 340 files are tiles of one dataset, and the useful output is one table:
def append_folder(folder, table, engine, *, srid=27700,
source_col="source_file", pattern="*.shp"):
"""Load every file into a single table, tagged with its source."""
paths = sorted(Path(folder).glob(pattern))
total = 0
for i, path in enumerate(paths):
gdf = prepare(gpd.read_file(path), srid=srid)
gdf[source_col] = path.stem
gdf.to_postgis(table, engine,
if_exists="replace" if i == 0 else "append",
index=False, chunksize=10_000)
total += len(gdf)
print(f" {path.name:<28} +{len(gdf):>8,} total {total:>10,}")
with engine.begin() as con:
con.execute(text(f'CREATE INDEX ON "{table}" USING GIST (geometry)'))
con.execute(text(f'CREATE INDEX ON "{table}" ({source_col})'))
con.execute(text(f'ANALYZE "{table}"'))
return total
append_folder("tiles/", "parcels", engine)
Three things make this work where the naive version fails. replace on the first file and append afterwards creates the schema once β using replace throughout leaves you with only the last file. The source_file column preserves provenance that the merge would otherwise destroy, and makes a single tile reloadable with a DELETE WHERE source_file = β¦. And the indexes are built once at the end rather than maintained across 340 inserts.
Column mismatches are the remaining risk: appending a frame with different columns raises. Align them first if the supplier's schema drifts between tiles.
Example 3: ogr2ogr when Python is not the right tool
For a straight bulk load with no per-file logic, GDAL's own loader is faster because it streams and never builds a DataFrame:
import subprocess
from pathlib import Path
def ogr_load(path, table, dsn, srid=27700):
cmd = [
"ogr2ogr", "-f", "PostgreSQL", f"PG:{dsn}", str(path),
"-nln", table,
"-nlt", "PROMOTE_TO_MULTI", # solves the Polygon/MultiPolygon split
"-t_srs", f"EPSG:{srid}",
"-lco", "GEOMETRY_NAME=geom",
"-lco", "SPATIAL_INDEX=GIST",
"-lco", "PRECISION=NO",
"-overwrite",
"--config", "PG_USE_COPY", "YES", # COPY instead of INSERT β much faster
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip()[:300])
return table
DSN = "host=localhost dbname=gis user=gis"
for path in sorted(Path("data").glob("*.shp")):
try:
ogr_load(path, safe_name(path.stem), DSN)
print(f" β {path.name}")
except RuntimeError as exc:
print(f" β {path.name}: {exc}")
PG_USE_COPY=YES switches from per-row INSERT to COPY, which is typically three to five times faster on a large load. PROMOTE_TO_MULTI handles the geometry-type split in the loader rather than in your code, and SPATIAL_INDEX=GIST builds the index as part of table creation.
The trade-off is real: ogr2ogr is faster and knows more about formats, but you cannot inspect or clean the data on the way through. Use it when the files are trustworthy, and the Python route when they need cleaning β which, with supplier data, is most of the time.
Explanation
A folder load is a schema negotiation, and that is what makes it harder than it looks. Every shapefile carries its own implicit schema β field names, types, CRS, geometry type β and the database needs one explicit schema per table. Loading one file lets the file decide. Loading 340 means reconciling 340 opinions, and the reconciliation has no default.
The shapefile format makes this worse in specific, well-known ways. Field names are limited to ten characters, so population_density arrives as populatio β truncated by the format long before Python sees it, and unrecoverable without the supplier's documentation. The CRS lives in a separate .prj file that is routinely lost in transit, giving crs = None. There is no distinction between Polygon and MultiPolygon at the file level, so a single file can hold both. And there is no NULL: empty strings and zeros stand in for missing values. Every one of these becomes a database problem the moment you load without checking. GIS vector file formats compared covers why GeoPackage avoids most of them.
Typed geometry columns are where the format's laxity meets the database's strictness. PostGIS's geometry(MultiPolygon, 27700) is a constraint, and constraints reject data. Confirmed against PostGIS 3.4: a single Polygon inserted into a geometry(MultiPolygon, β¦) column is accepted and promoted, while a MultiPolygon into a geometry(Polygon, β¦) column is rejected with Geometry type (MultiPolygon) does not match column type (Polygon). The asymmetry is why promoting everything to multi is the safe direction, and why -nlt PROMOTE_TO_MULTI exists.
Indexing after loading rather than during is not a micro-optimisation. A GiST index maintained across four million inserts is rebalanced continuously; built once at the end it is a single bulk operation on data already in place. The difference is routinely an order of magnitude. The same reasoning applies to constraints and foreign keys, and it is why ogr2ogr's SPATIAL_INDEX=GIST creates the index as part of table creation rather than before the copy.
Finally, treat this as a batch job rather than a script. It has the same three requirements as any batch GIS job: per-file error isolation so one bad file does not sink the run, resumability so a failure at file 212 does not discard 211 successes, and a report that distinguishes "loaded", "loaded with problems" and "not loaded". The database part is the easy half.
Edge cases or notes
- Shapefile field names are truncated to 10 characters by the format. Nothing downstream can recover them; check the supplier's documentation.
- A missing
.prjmeanscrs = None. Do not guess β assigning a CRS relabels coordinates without moving them. - Encoding matters. Shapefiles have no reliable encoding declaration; pass
encoding="latin-1"or check the.cpgfile. See garbled attribute text. if_exists="replace"drops indexes and constraints along with the table.to_postgisnames the geometry columngeometry, whileogr2ogrdefaults towkb_geometryunless you passGEOMETRY_NAME=geom. Pick one convention.- Reserved words β a table or column called
order,userordefaultneeds quoting forever. Sanitise those too. - 63 bytes is the identifier limit. Longer names are silently truncated, which can create collisions.
- Loading into a schema needs
schema="raw"onto_postgisand matching qualification in your index DDL. PG_USE_COPY=YESmakesogr2ogrthree to five times faster on large loads.- Wrap per-file work in its own transaction so a failure rolls back one table, not the whole run.
Internal links
- How to write a GeoDataFrame to PostGIS β the single-table case in detail
- PostGIS spatial indexes explained β why the index goes last
- PostGIS write fails on SRID or geometry type β the errors this guide avoids
- How to build an inventory of a GIS data folder in Python β the audit step, generalised
- How to build a resumable batch GIS job in Python β surviving a failure at file 212
- How to batch process a folder of GIS files in Python β the pattern this follows
- GIS vector file formats compared β why shapefiles cause most of these problems
- How to update a PostGIS table from a GeoDataFrame without duplicates β the next load, not the first
FAQ
Why does my table name need quotes in every query?
It was created with capitals or spaces. PostgreSQL folds unquoted identifiers to lower case, so "Cheshire East" can only be referenced with quotes. Sanitise names before loading.
Why does the load fail with "Geometry type does not match column type"?
The table has a typed column such as geometry(Polygon, 27700) and the file contains MultiPolygon rows. Promote everything to multi before loading, or use -nlt PROMOTE_TO_MULTI.
Should I create the spatial index before or after loading?
After. if_exists="replace" drops it anyway, and maintaining an index through a bulk insert costs far more than building it once. Follow with ANALYZE.
How do I make the load resumable?
Check whether the target table already exists and skip it. SELECT to_regclass('public.tablename') IS NOT NULL is a cheap test that turns a restart into a resume.
Is ogr2ogr faster than GeoPandas?
Yes, substantially β it streams and can use COPY. Use it when the files need no inspection, and Python when they need cleaning or per-file logic.
What if the files have different CRS?
Reproject each to a single target SRID as it loads. Tables in different SRIDs cannot be joined without a per-query transform, and PostGIS raises rather than returning nothing.
Why are my column names truncated to ten characters?
The shapefile format truncates them on write. The full names never reached Python, and no code can recover them β check the supplier's documentation.