How to Merge Many Shapefiles into One File in Python
On this page
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
Turning a folder of shapefiles into one file is more than a concat:
- The data is split across files that belong together β
parcels_north.shp,parcels_south.shp,parcels_east.shpare all parcels, and you want oneparcels.gpkgyou can query as a whole. - Inputs are in different CRSs, or one has lost its
.prjβ current GeoPandas refuses to concatenate frames in different CRSs, but a file with no CRS at all is quietly adopted into the others' CRS, wherever its coordinates really are. - The columns do not match β one file has a
notesfield another lacks, or the same attribute is spelledPOPin one file andpopulationin 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.
Merging them safely means one CRS for all, aligned columns, a record of which file each row came from, and one output file.
Quick answer
Read each shapefile into a GeoDataFrame, collect them in a list, and combine the list with pandas.concat. With GeoPandas 1.1, concat of GeoDataFrames returns a GeoDataFrame and keeps the CRS they share; wrapping it and passing crs= states the target explicitly. The one rule you cannot skip: every input must have a CRS, and the same one, 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.
The examples run on a real delivery of exactly this kind: HM Land Registry's INSPIRE parcels for four neighbouring districts β Harborough, Melton, Oadby and Wigston, and Rutland β one shapefile per district (set up under Code examples).
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}")
Merged 4 files into 121194 rows -> data/merged/parcels.gpkg
That is the whole job β nearly. 121,194 rows is more parcels than the four districts hold, because a parcel that straddles a district boundary is delivered in both files; Example 5 finds and removes them. 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.
Read, check, concatenate and write once
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)
Found 4 shapefiles
harborough.shp
melton.shp
oadby_and_wigston.shp
rutland.shp
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/rutland.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)
print(gdf.crs.to_epsg(), gdf.crs.name)
27700 OSGB36 / British National Grid
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")
Collected 4 frames
Concatenate and wrap as a GeoDataFrame
pandas.concat stacks the frames row-wise into one table. Given GeoDataFrames that share a CRS, GeoPandas 1.1 returns a GeoDataFrame with that CRS; given plain DataFrames β frames that lost their geometry column along the way β it returns a plain DataFrame. Wrapping the result in gpd.GeoDataFrame(..., crs=...) makes the type and the target CRS explicit either way. 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(pd.concat(frames)).__name__, "straight from concat")
print(type(merged).__name__, merged.crs, len(merged))
GeoDataFrame straight from concat
GeoDataFrame EPSG:27700 121194
Setting crs= here is belt-and-braces: because every input already shared that CRS, the value is correct, and if one frame had arrived in another CRS concat would already have refused with a ValueError.
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")
print(pyogrio.read_info(out_path)["fields"])
['gml_id' 'INSPIREID' 'LABEL' 'NATIONALCA' 'VALIDFROM' 'BEGINLIFES']
For more on the format and its read/write options, see read and write GeoPackage files.
Stacking four districts of Land Registry parcels
The data used on this page
HM Land Registry's INSPIRE Index Polygons (Open Government Licence, with Ordnance Survey rights) for four neighbouring districts in the East Midlands, delivered as the land registry publishes them β one GML per district β and saved as one shapefile each, which is how a folder like this usually arrives.
import io
import os
import warnings
import zipfile
from pathlib import Path
import geopandas as gpd
import pandas as pd
import pyogrio
import requests
pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
src_dir = Path("data/parcels")
src_dir.mkdir(parents=True, exist_ok=True)
session = requests.Session()
for district in ["Harborough", "Melton", "Oadby_and_Wigston", "Rutland"]:
shp = src_dir / f"{district.lower()}.shp"
if shp.exists():
continue
reply = session.get(f"https://use-land-property-data.service.gov.uk/datasets/inspire/download/{district}.zip",
timeout=600)
reply.raise_for_status()
zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml", "downloads")
parcels = gpd.read_file("downloads/Land_Registry_Cadastral_Parcels.gml")
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
parcels.to_file(shp)
if district == "Rutland":
for warning in caught:
print("writing the shapefile:", str(warning.message))
for shp in sorted(src_dir.glob("*.shp")):
info = pyogrio.read_info(shp)
print(f"{shp.name:24} {info['features']:>7,} parcels {info['crs']}")
writing the shapefile: Column names longer than 10 characters will be truncated when saved to ESRI Shapefile.
writing the shapefile: Normalized/laundered field name: 'NATIONALCADASTRALREFERENCE' to 'NATIONALCA'
writing the shapefile: Field VALIDFROM created as String field, though DateTime requested.
writing the shapefile: Normalized/laundered field name: 'BEGINLIFESPANVERSION' to 'BEGINLIFES'
writing the shapefile: Field BEGINLIFES created as String field, though DateTime requested.
harborough.shp 49,498 parcels EPSG:27700
melton.shp 27,353 parcels EPSG:27700
oadby_and_wigston.shp 23,090 parcels EPSG:27700
rutland.shp 21,253 parcels EPSG:27700
The shapefile format is already doing damage before any merge: two field names cut to ten characters, and two timestamps turned into text. Example 4 undoes both.
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 wrap the result.
a, b, c = (gpd.read_file(src_dir / f"{name}.shp") for name in ["melton", "oadby_and_wigston", "rutland"])
merged = gpd.GeoDataFrame(
pd.concat([a, b, c], ignore_index=True),
crs=a.crs,
)
print(len(merged), "rows total")
71696 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.
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")
Merged 4 files -> 121194 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.
frames = []
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path).to_crs("EPSG:27700")
gdf["source_file"] = path.stem # e.g. "rutland"
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())
source_file
harborough 49498
melton 27353
oadby_and_wigston 23090
rutland 21253
Name: count, dtype: int64
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. It is also what Example 5 needs to see which districts delivered the same parcel.
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. Here every file has the same six columns, but they are the shapefile's versions of them: two names truncated and two dates stored as text. Reconcile them before writing the merged layer.
RENAME = {"NATIONALCA": "NATIONALCADASTRALREFERENCE", "BEGINLIFES": "BEGINLIFESPANVERSION"}
KEEP = ["INSPIREID", "NATIONALCADASTRALREFERENCE", "VALIDFROM", "BEGINLIFESPANVERSION", "source_file", "geometry"]
frames = []
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path).rename(columns=RENAME) # undo the truncation
for col in ["VALIDFROM", "BEGINLIFESPANVERSION"]:
gdf[col] = pd.to_datetime(gdf[col], format="mixed", utc=True) # text back to timestamps
gdf["source_file"] = path.stem
frames.append(gdf[KEEP]) # same column order
merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:27700")
print(merged.dtypes.astype(str).to_dict())
{'INSPIREID': 'int32', 'NATIONALCADASTRALREFERENCE': 'int32', 'VALIDFROM': 'datetime64[us, UTC]', 'BEGINLIFESPANVERSION': 'datetime64[us, UTC]', 'source_file': 'str', 'geometry': 'geometry'}
The dates need format="mixed" because the shapefile's text is not even in one format: 2009/11/03 10:44:16.884+00 carries milliseconds and 2020/01/04 00:00:00+00 does not, and a single explicit format fails on the second. 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: Find the features delivered twice at the borders
Tiled or district-split data often repeats features along the cuts. Before reaching for dissolve, check what the repeats are:
repeated = merged[merged["INSPIREID"].duplicated(keep=False)]
per_id = repeated.groupby("INSPIREID")
identical = per_id.geometry.apply(lambda g: all(g.iloc[0].equals(other) for other in g.iloc[1:]))
print(f"{repeated['INSPIREID'].nunique()} parcels appear in more than one file ({len(repeated)} rows); "
f"geometry identical in every copy: {bool(identical.all())}")
print(per_id["source_file"].agg(lambda s: " + ".join(sorted(s))).value_counts().to_string())
parcels = merged.drop_duplicates("INSPIREID").reset_index(drop=True)
parcels.to_file("data/merged/parcels.gpkg", driver="GPKG")
print(f"{len(merged):,} rows -> {len(parcels):,} parcels")
257 parcels appear in more than one file (515 rows); geometry identical in every copy: True
source_file
harborough + rutland 91
melton + rutland 77
harborough + melton 49
harborough + oadby_and_wigston 39
harborough + melton + rutland 1
121,194 rows -> 120,936 parcels
Every repeat is an exact copy of a parcel that straddles a district boundary, so the fix is drop_duplicates on the identifier, not a dissolve β there is nothing to fuse. Keep dissolve for features that were genuinely split at the cut and need joining back together, and check which of the two you have before choosing.
Example 6: What concat checks, and what it does not
Two files built to test the CRS guard β stated plainly, since the land registry delivers every district in British National Grid: a copy of Rutland reprojected to WGS 84, as a "for web" export would be, and a copy of Rutland in WGS 84 with its .prj deleted.
rutland = gpd.read_file(src_dir / "rutland.shp")
Path("trap").mkdir(exist_ok=True)
rutland.to_crs(4326).to_file("trap/rutland_wgs84.shp")
rutland.to_crs(4326).to_file("trap/rutland_noprj.shp")
Path("trap/rutland_noprj.prj").unlink()
melton = gpd.read_file(src_dir / "melton.shp")
try:
pd.concat([melton, gpd.read_file("trap/rutland_wgs84.shp")])
except ValueError as exc:
print("different CRS:", str(exc).split(". ")[0])
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
mixed = pd.concat([melton, gpd.read_file("trap/rutland_noprj.shp")], ignore_index=True)
print("no .prj:", mixed.crs.name, "|", str(caught[0].message)[:60], "β¦")
print(" Rutland's bounds, labelled British National Grid:", mixed.tail(1).total_bounds.round(2))
stacked = pd.concat([melton, rutland])
print(f"without ignore_index: {stacked.index.duplicated().sum():,} repeated row labels")
points = rutland.head(3).copy()
points["geometry"] = points.representative_point()
both = pd.concat([rutland.head(3), points], ignore_index=True)
both.to_file("trap/mixed_types.gpkg")
print("polygons + points:", both.geom_type.unique().tolist(), "->",
pyogrio.read_info("trap/mixed_types.gpkg")["geometry_type"], "layer in the GeoPackage")
different CRS: Cannot determine common CRS for concatenation inputs, got ['OSGB36 / British National Grid', 'WGS 84']
no .prj: OSGB36 / British National Grid | CRS not set for some of the concatenation inputs. Setting ou β¦
Rutland's bounds, labelled British National Grid: [-0.79 52.71 -0.79 52.72]
without ignore_index: 21,253 repeated row labels
polygons + points: ['Polygon', 'Point'] -> Unknown layer in the GeoPackage
Of the four hazards, concat catches one: frames in two different CRSs raise a ValueError. A frame with no CRS is adopted into the other's with nothing but a warning, so Rutland's degrees are now labelled as metres and its parcels sit within a metre of the British National Grid's origin, in the sea south-west of the Isles of Scilly. Repeated row labels and a mixed geometry type both write out without complaint.
Example 7: Merge a folder larger than memory by appending
out = Path("data/merged/parcels_appended.gpkg")
out.unlink(missing_ok=True)
for path in sorted(src_dir.glob("*.shp")):
gdf = gpd.read_file(path).to_crs("EPSG:27700")
gdf["source_file"] = path.stem
gdf.to_file(out, layer="parcels", driver="GPKG", mode="a" if out.exists() else "w")
print(pyogrio.read_info(out, layer="parcels")["features"], "rows appended, one file in memory at a time")
121194 rows appended, one file in memory at a time
A merge is a vertical stack
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. GeoPandas stops you when both CRSs are recorded, and cannot when one is missing. Second, the CRS travels with the geometry column: GeoPandas 1.1 carries a shared CRS through concat and refuses to combine two different ones β but it cannot know that a frame without a CRS was really in degrees, and adopts it into the others' CRS with only a warning.
The pattern is therefore always the same three moves: normalise (check a CRS exists, reproject, and align columns), concatenate with ignore_index=True, and 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, confirm the columns are the union you intended, and check the identifier for repeats β on the four districts here, 257 parcels were in two or three files.
CRS, schemas and what concat checks
All inputs must share one CRS before you concat
pd.concat checks this when it can: frames in EPSG:4326 and EPSG:27700 raise ValueError: Cannot determine common CRS for concatenation inputs (Example 6). The case that breaks silently is a frame with no CRS β a shapefile whose .prj went missing β which is adopted into the other frames' CRS with only a UserWarning, so its degrees are read as metres. Refuse frames without a CRS, reproject every frame to one target with to_crs(), 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, and GeoPackage will even store it β as a layer of type Unknown (Example 6) β but many tools reject or mishandle mixed layers, 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.
Internal links
- How to Batch Process GIS Files in Python β the cornerstone guide this task belongs to.
- How to Batch Process Multiple Shapefiles in Python β the general folder-loop pattern behind this merge.
- How to Standardise and Repair CRS Across a Folder of Files β do this before merging if your files disagree on CRS.
- How to Read and Write GeoPackage Files in Python β the format to merge into.
- How to Dissolve Polygons in GeoPandas β dedupe or fuse features at shared borders after a merge.
- How to Batch Convert Shapefiles to GeoPackage β convert first if you would rather merge GeoPackages.
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. Wrap the result in 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 and CRS included. Wrapping the result in gpd.GeoDataFrame(..., crs=...) only makes the type and target CRS explicit.
What happens if the shapefiles are in different CRSs?
If both CRSs are recorded, pd.concat refuses with ValueError: Cannot determine common CRS for concatenation inputs. If one file has no CRS, it is silently adopted into the other's and lands in the wrong place, with only a warning. 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?
Usually because none of the inputs had one β concat carries a shared CRS through, and one CRS'd input is enough for it to label the result. Or the frames lost their geometry column before the merge, in which case concat returns a plain DataFrame. Fix the inputs, then wrap the result explicitly: gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:27700").
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".