How to Merge Many Shapefiles into One File in Python

You have a folder of shapefiles that are really one dataset chopped into pieces — parcels split by district, survey tiles cut by grid square, monthly exports dumped one file at a time. They all describe the same kind of feature, but they live in a dozen separate .shp files that you now have to load, filter, and hand off as a single layer. This guide shows you how to read every file into a list, stack them into one GeoDataFrame with pd.concat, keep the CRS and columns straight, and write the result to a single GeoPackage — without letting a stray projection or a renamed column quietly wreck the output.

Problem statement

You need to combine many shapefiles into one file, and you are hitting some mix of these:

  • The data is split across files that belong togetherparcels_north.shp, parcels_south.shp, parcels_east.shp are all parcels, and you want one parcels.gpkg you can query as a whole.
  • Inputs are in different CRSs — concatenating them naively stacks incompatible coordinates into one column, so nothing lines up and later spatial operations are wrong.
  • The columns do not match — one file has a notes field another lacks, or the same attribute is spelled POP in one file and population in another, and the merged table ends up full of gaps.
  • You lose track of where each row came from — after merging you cannot tell which original file a feature belongs to, which makes debugging and re-exporting painful.
  • You are not sure merge is even the right operation — you actually want to combine rows, but the tutorials you found describe spatial joins or dissolves, which do something else entirely.

The goal: one repeatable script that reads a folder of same-geometry shapefiles, guarantees a single shared CRS, aligns the columns, records provenance, and writes one clean output file.

Quick answer

Read each shapefile into a GeoDataFrame, collect them in a list, and combine the list with pandas.concat. concat returns a plain DataFrame, so wrap it back into a GeoDataFrame and set the CRS explicitly. The one rule you cannot skip: every input must already share the same CRS before you concatenate — reproject first with to_crs(), then stack. Use ignore_index=True so the merged frame gets one clean run of row numbers.

Several shapefiles sharing one CRS concatenated into a single merged layer.
Reproject every file to one common CRS, then stack their rows into a single GeoDataFrame.
import geopandas as gpd
import pandas as pd
from pathlib import Path

src_dir = Path("data/parcels")
out_path = Path("data/merged/parcels.gpkg")
out_path.parent.mkdir(parents=True, exist_ok=True)

TARGET_CRS = "EPSG:27700"        # every file ends up here before concat

frames = []
for path in sorted(src_dir.glob("*.shp")):
    gdf = gpd.read_file(path)
    if gdf.crs is None:
        raise ValueError(f"{path.name} has no CRS - fix it before merging")
    if gdf.crs.to_epsg() != 27700:
        gdf = gdf.to_crs(TARGET_CRS)     # align coordinates first
    gdf["source_file"] = path.stem       # provenance for later
    frames.append(gdf)

merged = gpd.GeoDataFrame(
    pd.concat(frames, ignore_index=True),
    crs=TARGET_CRS,
)
merged.to_file(out_path, driver="GPKG")
print(f"Merged {len(frames)} files into {len(merged)} rows -> {out_path}")

That is the whole job. The rest of this article explains each decision — the CRS guard, the column alignment, the provenance column, and the GeoPackage write — so you can adapt it to messy folders instead of running it blind.

Step-by-step solution

Discover the shapefiles

Use pathlib.Path with glob("*.shp") to list the shapefiles. Globbing *.shp deliberately ignores the .dbf, .shx, and .prj sidecars — GeoPandas reads those automatically from the .shp. Sort the results so the merge order is deterministic and reproducible across runs.

from pathlib import Path

src_dir = Path("data/parcels")
files = sorted(src_dir.glob("*.shp"))

print(f"Found {len(files)} shapefiles")
for f in files:
    print(f.name)

If your data is nested in per-region subfolders, swap glob for rglob("*.shp") to walk the whole tree.

Read each file and check its CRS

Read each shapefile into a GeoDataFrame and immediately check gdf.crs. This is the single most important step in a merge: coordinates from two different CRSs are just numbers in different units, and stacking them produces a layer where half the features sit in the wrong place. Reproject anything that is not already on your target CRS.

import geopandas as gpd

TARGET_CRS = "EPSG:27700"

gdf = gpd.read_file("data/parcels/parcels_north.shp")

if gdf.crs is None:
    raise ValueError("No CRS - set the correct source CRS before merging")

if gdf.crs.to_epsg() != 27700:
    gdf = gdf.to_crs(TARGET_CRS)

If a file has no CRS at all, do not guess it here — repair it first. Standardising a whole folder's coordinate systems is its own job; see standardise and repair CRS across a folder before you merge.

Collect the frames in a list

Append each prepared GeoDataFrame to a Python list. Building a list and concatenating once at the end is far faster and cleaner than repeatedly concatenating inside the loop, which re-copies the growing frame every iteration.

import geopandas as gpd
from pathlib import Path

frames = []
for path in sorted(Path("data/parcels").glob("*.shp")):
    gdf = gpd.read_file(path).to_crs("EPSG:27700")
    frames.append(gdf)

print(f"Collected {len(frames)} frames")

Concatenate and re-wrap as a GeoDataFrame

pandas.concat stacks the frames row-wise into one table, but it returns a plain DataFrame — the GeoPandas-ness is lost. Wrap the result back into a GeoDataFrame and pass crs= explicitly so the geometry column is recognised again. Use ignore_index=True to discard the per-file row indices and produce one continuous index.

import geopandas as gpd
import pandas as pd

merged = gpd.GeoDataFrame(
    pd.concat(frames, ignore_index=True),
    crs="EPSG:27700",
)

print(type(merged))        # <class 'geopandas.geodataframe.GeoDataFrame'>
print(merged.crs)          # EPSG:27700
print(len(merged))         # total rows across every file

Setting crs= here is belt-and-braces: because every input already shared that CRS, the value is correct, and it guarantees the merged frame is labelled even if concat drops the metadata.

Write the merged layer to one file

Write the combined GeoDataFrame to a single GeoPackage with driver="GPKG". GeoPackage is the right target for a merge: it is one file, stores the CRS internally, keeps full-length field names, and has no practical size limit — none of which is true of shapefiles.

from pathlib import Path

out_path = Path("data/merged/parcels.gpkg")
out_path.parent.mkdir(parents=True, exist_ok=True)

merged.to_file(out_path, driver="GPKG")

For more on the format and its read/write options, see read and write GeoPackage files.

Code examples

Example 1: Concatenate a list of GeoDataFrames

The core operation, stripped to its essentials. Given a list of GeoDataFrames that already share a CRS, stack them and re-wrap the result.

import geopandas as gpd
import pandas as pd

# a, b, c are GeoDataFrames already in the same CRS
merged = gpd.GeoDataFrame(
    pd.concat([a, b, c], ignore_index=True),
    crs=a.crs,
)

print(len(merged), "rows total")

Reusing a.crs as the target keeps the code honest: the merged frame inherits the CRS the inputs actually share, so there is no magic string to get wrong.

Example 2: Full folder loop that reprojects, then concatenates

The realistic version. Read every shapefile in a folder, reproject each to a common CRS, then merge into one file.

import geopandas as gpd
import pandas as pd
from pathlib import Path

src_dir = Path("data/parcels")
out_path = Path("data/merged/parcels.gpkg")
out_path.parent.mkdir(parents=True, exist_ok=True)

TARGET_CRS = "EPSG:27700"

frames = []
for path in sorted(src_dir.glob("*.shp")):
    gdf = gpd.read_file(path)

    if gdf.crs is None:
        print(f"Skipping {path.name}: no CRS")
        continue

    if gdf.crs.to_epsg() != 27700:
        gdf = gdf.to_crs(TARGET_CRS)

    frames.append(gdf)

if not frames:
    raise SystemExit("No usable files found")

merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs=TARGET_CRS)
merged.to_file(out_path, driver="GPKG")
print(f"Merged {len(frames)} files -> {len(merged)} rows")

Example 3: Add a source_file column for provenance

Once rows from many files share one table, you often need to know which file each row came from. Tag every frame before concatenating.

import geopandas as gpd
import pandas as pd
from pathlib import Path

frames = []
for path in sorted(Path("data/parcels").glob("*.shp")):
    gdf = gpd.read_file(path).to_crs("EPSG:27700")
    gdf["source_file"] = path.stem      # e.g. "parcels_north"
    frames.append(gdf)

merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:27700")

# how many rows came from each file?
print(merged["source_file"].value_counts())

The source_file column costs almost nothing and makes the merge auditable: you can filter back to any origin file, spot a file that contributed zero rows, or re-export one subset later.

Example 4: Align differing schemas across files

If the files do not share identical columns, pd.concat still works — it takes the union of all columns and fills missing values with NaN. That is usually what you want, but sometimes you need to force a consistent schema or reconcile columns that mean the same thing under different names.

import geopandas as gpd
import pandas as pd
from pathlib import Path

# canonical columns every row should end up with
KEEP = ["parcel_id", "population", "geometry"]
RENAME = {"POP": "population", "pop_total": "population"}

frames = []
for path in sorted(Path("data/parcels").glob("*.shp")):
    gdf = gpd.read_file(path).to_crs("EPSG:27700")
    gdf = gdf.rename(columns=RENAME)                    # unify synonyms

    for col in KEEP:
        if col not in gdf.columns and col != "geometry":
            gdf[col] = pd.NA                             # add missing as null

    frames.append(gdf[KEEP])                            # same column order

merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:27700")
print(merged.columns.tolist())

Selecting gdf[KEEP] on every frame guarantees each contributes exactly the same columns in the same order, so the merged table is clean rather than a sparse patchwork of one-off fields.

Example 5: Merge into one GeoPackage, then dissolve duplicates at borders

When tiled data overlaps at its edges — parcels or boundaries that were split along a shared line — merging can leave duplicate or adjacent features that you want to fuse. Merge first, then dissolve on the attribute that should be unique.

import geopandas as gpd
import pandas as pd
from pathlib import Path

frames = [
    gpd.read_file(p).to_crs("EPSG:27700")
    for p in sorted(Path("data/regions").glob("*.shp"))
]
merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:27700")

# fuse features that share a region code into one geometry each
regions = merged.dissolve(by="region_code", as_index=False)

regions.to_file("data/merged/regions.gpkg", driver="GPKG")
print(f"{len(merged)} rows dissolved to {len(regions)} regions")

Merging and dissolving are separate steps: the merge stacks the rows, and dissolve collapses the ones that should be a single feature. Only dissolve when you genuinely want fewer, fused geometries — otherwise keep every merged row.

Explanation

A merge in the sense of this article is a vertical stack: you are appending the rows of many files into one longer table that has the same columns. That is exactly what pandas.concat does, and because a GeoDataFrame is a DataFrame with a geometry column, concat handles the geometries along with every other column for free.

Two things make the geospatial version different from a plain pandas concat. First, coordinates are only meaningful relative to a CRS, so all inputs must share one CRS before you stack them — otherwise you are mixing metres and degrees in a single geometry column and the result is nonsense that GeoPandas cannot detect for you. Second, pd.concat returns a plain DataFrame and drops the CRS metadata, so you must re-wrap the result in gpd.GeoDataFrame(..., crs=...) to get a usable spatial layer back.

The pattern is therefore always the same three moves: normalise (reproject and, if needed, align columns), concatenate with ignore_index=True, and re-wrap with an explicit CRS. Everything else — provenance columns, schema reconciliation, dissolving overlaps — is optional polish layered on top of those three moves.

It helps to test the pipeline on two files before running it on two hundred. Confirm the row count of the merge equals the sum of the inputs, confirm merged.crs is what you expect, and confirm the columns are the union you intended. Once that holds for two files it holds for the whole folder.

Edge cases or notes

All inputs must share one CRS before you concat

This is the rule that breaks silently. pd.concat does not check CRS — it will happily stack a frame in EPSG:4326 (degrees) on top of one in EPSG:27700 (metres), and the merged geometry column will contain both, mislocating every feature from the minority file. GeoPandas will not warn you. Always reproject every frame to one target CRS with to_crs() before appending it, and set that CRS explicitly on the merged result.

Mismatched or heterogeneous columns

If the files have different columns, concat produces the union of all columns and fills gaps with NaN. That is often fine, but watch for the same attribute stored under different names (POP vs population) — those become two half-empty columns instead of one. Rename synonyms to a canonical name before concatenating, and consider selecting a fixed column list per frame (Example 4) so the output schema is predictable.

Geometry types should be consistent

Merging assumes every file holds the same geometry type — all polygons, or all points. You can stack points and polygons into one GeoDataFrame, but the result is a mixed-geometry layer that many tools and formats reject or mishandle, and area or length calculations stop making sense. Check gdf.geom_type.unique() per file if you are unsure, and merge points, lines, and polygons into separate outputs.

Use ignore_index=True

Each input frame carries its own 0..n index. Without ignore_index=True, concat preserves those, so the merged frame has repeated index values (three rows numbered 0, three numbered 1, and so on), which breaks row selection by label and any later join on the index. Passing ignore_index=True renumbers the whole thing 0..total-1.

Merge is not a spatial join or a dissolve

These get conflated constantly. A merge (this article) stacks rows: N files become one longer table with the same columns. A spatial join (gpd.sjoin) is horizontal — it adds columns to features based on where they fall relative to another layer. A dissolve collapses many rows into fewer by fusing geometries that share an attribute. If you want "put all these files together as one layer," you want merge. If you want "attach the district each point falls in," you want a spatial join. If you want "one geometry per region," you want dissolve.

Very large folders and memory

gpd.read_file() loads each file fully into memory, and the list-of-frames approach holds them all at once before the final concat. For a folder that will not fit in RAM, write incrementally instead: read one file, reproject it, and append it to a GeoPackage layer inside the loop rather than building one giant list. GeoPackage supports appending, which lets you merge folders far larger than memory.

FAQ

How do I merge multiple shapefiles into one file with GeoPandas?

Read each shapefile into a GeoDataFrame, make sure they all share one CRS, collect them in a list, and combine them with pandas.concat. Because concat returns a plain DataFrame, wrap it back into a GeoDataFrame with an explicit CRS, then write it out:

import geopandas as gpd, pandas as pd
merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs=frames[0].crs)
merged.to_file("merged.gpkg", driver="GPKG")

Why do I need pd.concat instead of a GeoPandas function?

There is no dedicated GeoPandas "merge many files" function because a GeoDataFrame is a pandas DataFrame with a geometry column, so pandas.concat already does the row-stacking correctly, geometry included. The only extra step is re-wrapping the concatenated result in gpd.GeoDataFrame(..., crs=...), because concat returns a plain DataFrame and drops the CRS.

What happens if the shapefiles are in different CRSs?

The coordinates get mixed into one geometry column without being aligned, so features from any file not in the majority CRS land in the wrong place — and nothing errors to tell you. Always reproject every frame to a single target CRS with to_crs() before concatenating. If your folder has inconsistent or missing CRSs, standardise them first.

Why is my merged GeoDataFrame missing its CRS?

Because pd.concat returns a plain DataFrame and does not preserve the geometry column's CRS metadata. Fix it by wrapping the result explicitly: gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:27700"). Since every input already shared that CRS, stating it here just re-labels the merged frame correctly.

How do I keep track of which file each feature came from?

Add a provenance column to each frame before concatenating: gdf["source_file"] = path.stem. After the merge you can run merged["source_file"].value_counts() to see how many rows each file contributed, filter back to any origin file, or re-export a single source's features.

What if the shapefiles have different columns?

pd.concat takes the union of all columns and fills missing values with NaN, so mismatched schemas still merge. The trap is the same attribute stored under different names, which becomes two sparse columns. Rename synonyms to one canonical name, and optionally select a fixed list of columns per frame so the merged schema is exactly what you intend.

Is merging the same as a spatial join or a dissolve?

No. Merging stacks rows — many files become one longer table with the same columns. A spatial join adds columns based on spatial relationships between two layers, and a dissolve fuses many geometries into fewer based on a shared attribute. Use merge to combine files into one layer; reach for dissolve only when you want fewer, fused features afterwards.

Should I merge into a shapefile or a GeoPackage?

Prefer GeoPackage. A merged dataset is often large and wide, and shapefiles cap at 2 GB, truncate field names to 10 characters, split each layer across four-plus files, and store the CRS in a fragile .prj sidecar. GeoPackage is a single SQLite file that stores the CRS internally, keeps long field names, and has no practical size limit — write it with driver="GPKG".