How to Open Any Spatial File in Python When You Do Not Know the Format
On this page
Problem statement
Someone sends you a folder. Inside are files with several extensions, no documentation, and some Norwegian place names (the folder is built from real layers under The data used on this page).
import geopandas as gpd
gdf = gpd.read_file("mystery/data.gdb")
print(f"{len(gdf):,} rows of {list(gdf.columns)[:4]}")
293,729 rows of ['INSPIREID', 'geometry']
It opened β but which of the layers inside did you get? read_file without layer= silently takes the first.
Or it refuses to open, over Norwegian letters:
try:
gpd.read_file("mystery/fylker.shp", encoding="UTF-8")
except UnicodeDecodeError as err:
print(f"UnicodeDecodeError: {err}")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf8 in position 2: invalid start byte
GDAL reads dozens of vector formats β 64 drivers in the pip-installed build this page ran on, more in a full conda or system build. The problem is almost never that a file cannot be read; it is working out what it is and what it contains before reading it.
Quick answer
Inspect before opening:
import pyogrio
print(pyogrio.list_layers("mystery/data.gdb"))
info = pyogrio.read_info("mystery/data.gdb", layer="parcels")
print({k: info[k] for k in ("crs", "encoding", "geometry_type", "features")})
[['parcels' 'MultiPolygon']
['wards' 'MultiPolygon']
['stations' 'Point']
['metadata' None]]
{'crs': 'EPSG:27700', 'encoding': 'UTF-8', 'geometry_type': 'MultiPolygon', 'features': 293729}
read_file. Each one takes milliseconds and prevents a wrong read.| Step | Command |
|---|---|
| what format is it? | file data.dat, or check the extension table below |
| what layers are inside? | pyogrio.list_layers(path) |
| what is in a layer? | pyogrio.read_info(path, layer=...) |
| read the right thing | gpd.read_file(path, layer="parcels") |
gdf = gpd.read_file("mystery/data.gdb", layer="parcels")
Identify the format, list layers, virtual paths, encodings
1. Identify the format from what is on disk
| You see | It is | Open with |
|---|---|---|
.shp plus .shx, .dbf, .prj |
Shapefile β a file set | read_file("x.shp"), keep the others |
.gpkg |
GeoPackage β may hold many layers | read_file(path, layer=...) |
.geojson, .json |
GeoJSON β always WGS 84 | read_file(path) |
.gdb directory |
Esri File Geodatabase | read_file(path, layer=...) |
.mdb, .accdb |
Esri Personal Geodatabase | needs the PGeo driver |
.tab plus .dat, .map, .id |
MapInfo TAB β a file set | read_file("x.tab") |
.mif plus .mid |
MapInfo Interchange | read_file("x.mif") |
.kml, .kmz |
Google Earth | read_file(path); KMZ may need unzipping |
.gml, .xml |
GML β check it really is GML | read_file(path) |
.dxf, .dwg |
CAD β often no CRS | read_file(path); expect a local grid |
.parquet |
GeoParquet | read_parquet(path) |
.fgb |
FlatGeobuf | read_file(path) |
.tif, .tiff |
GeoTIFF raster | rasterio.open(path) |
.csv |
maybe coordinates in columns | see step 5 |
When the extension is uninformative, ask the operating system:
file mystery/export_2026.dat
A SQLite database with a spatial extension is almost certainly a GeoPackage with the wrong extension, and GDAL recognises it from its content rather than its name:
stops = gpd.read_file("mystery/export_2026.dat")
print(f"{len(stops):,} rows, {stops.crs}")
16,450 rows, EPSG:27700
Or read the magic bytes yourself:
from pathlib import Path
MAGIC = {
b"SQLite format 3": "SQLite β probably GeoPackage or SpatiaLite",
b"PK\x03\x04": "ZIP β shapefile bundle, KMZ, or GeoPackage in a zip",
b"\x00\x00\x27\x0a": "Shapefile (.shp)",
b"II*\x00": "TIFF, little-endian β probably GeoTIFF",
b"MM\x00*": "TIFF, big-endian β probably GeoTIFF",
b"PAR1": "Parquet β probably GeoParquet",
b"fgb\x03": "FlatGeobuf",
b"<?xml": "XML β GML, KML or metadata",
b"{": "JSON β probably GeoJSON",
}
def sniff(path):
head = Path(path).read_bytes()[:16]
for magic, description in MAGIC.items():
if head.startswith(magic):
return description
return f"unrecognised: {head[:8]!r}"
for f in ["mystery/export_2026.dat", "mystery/fylker.shp", "mystery/sites.csv", "downloads/boundaries.zip"]:
print(f"{f:<28} {sniff(f)}")
mystery/export_2026.dat SQLite β probably GeoPackage or SpatiaLite
mystery/fylker.shp Shapefile (.shp)
mystery/sites.csv unrecognised: b'id,name,'
downloads/boundaries.zip ZIP β shapefile bundle, KMZ, or GeoPackage in a zip
2. List the layers before reading
Many formats hold several layers, and read_file without layer= silently takes the first:
import pyogrio
for name, geom_type in pyogrio.list_layers("mystery/data.gdb"):
info = pyogrio.read_info("mystery/data.gdb", layer=name)
print(f"{name:<12} {str(geom_type):<14} {info['features']:>9,} features {info['crs']}")
parcels MultiPolygon 293,729 features EPSG:27700
wards MultiPolygon 33 features EPSG:27700
stations Point 155 features None
metadata None 2 features None
geom_type = None means a non-spatial table β perfectly normal in a geodatabase, and it explains a read that returns rows with no geometry.
read_info reads only the header, so this costs milliseconds whatever the size of the layer.
3. Handle GDAL's virtual file systems
GDAL can read inside archives and over the network without unpacking or downloading:
# inside a zip, without extracting
gpd.read_file("/vsizip/data.zip/folder/parcels.shp")
# list what a zip contains, spatially
pyogrio.list_layers("/vsizip/data.zip")
# a gzipped file
gpd.read_file("/vsigzip/data.geojson.gz")
# over HTTP, range requests only
gpd.read_file("/vsicurl/https://example.org/data/parcels.fgb")
# S3
gpd.read_file("/vsis3/bucket/key/parcels.parquet")
GeoPandas also accepts a zip URI directly:
print(pyogrio.list_layers("/vsizip/downloads/boundaries.zip"))
wards = gpd.read_file("zip://downloads/boundaries.zip!wards.shp")
print(f"{len(wards)} wards read straight out of the zip, {wards.crs}")
[['wards' 'Polygon']]
33 wards read straight out of the zip, EPSG:27700
/vsicurl/ with FlatGeobuf or a Cloud-Optimized GeoTIFF is genuinely useful: both support range requests, so a bounding-box read fetches only the relevant bytes rather than the whole file.
4. Fix encoding problems
A UnicodeDecodeError, or mangled text, means the file's bytes were decoded with the wrong codec β and the two directions of the mistake behave differently. Telling GDAL a Latin-1 file is UTF-8 fails loudly, as at the top of the page, because Latin-1's ΓΈ (byte 0xF8) is not valid UTF-8. The reverse never fails:
gpd.read_file("mystery/fylker.shp").to_file("fylker_utf8.shp", encoding="UTF-8")
print(gpd.read_file("fylker_utf8.shp", encoding="ISO-8859-1")["navn"].tolist()[:2])
['Innlandet', 'TrΓΒΈndelag/TrΓΒΆΓΒΆndelage']
ΓΒΈ where ΓΈ should be is UTF-8's two bytes read as two Latin-1 characters, and nothing raises. Shapefiles are the usual source, because they have no reliable encoding declaration:
from pathlib import Path
cpg = Path("mystery/fylker.cpg")
print("declared:", cpg.read_text().strip() if cpg.exists() else "no .cpg")
names = gpd.read_file("mystery/fylker.shp") # let GDAL read the .cpg
print(names["navn"].tolist())
declared: ISO-8859-1
['Innlandet', 'TrΓΈndelag/TrΓΆΓΆndelage', 'Vestlandet', 'Nord-Norge', 'Oslo og Viken', 'Agder og SΓΈr-Γstlandet', 'Svalbard og Jan Mayen']
Try candidates and pick the one that produces sensible text:
def try_encodings(path, column, candidates=("UTF-8", "ISO-8859-1", "cp1252", "cp1250")):
for enc in candidates:
try:
sample = gpd.read_file(path, rows=5, encoding=enc)
values = sample[column].dropna().astype(str).tolist()
odd = sum(v.count("Γ") + v.count("οΏ½") for v in values)
print(f" {'β' if odd == 0 else 'β'} {enc:<12} {values[:2]}")
except Exception as exc:
print(f" β {enc:<12} {type(exc).__name__}")
try_encodings("mystery/fylker.shp", "navn")
β UTF-8 UnicodeDecodeError
β ISO-8859-1 ['Innlandet', 'TrΓΈndelag/TrΓΆΓΆndelage']
β cp1252 ['Innlandet', 'TrΓΈndelag/TrΓΆΓΆndelage']
β cp1250 ['Innlandet', 'TrΕndelag/TrΓΆΓΆndelage']
The full diagnosis is in garbled attribute text and UnicodeDecodeError.
5. Recognise a CSV with coordinates
A CSV is not a spatial format, and one with coordinates is extremely common:
import pandas as pd
df = pd.read_csv("mystery/sites.csv")
print(df.columns.tolist())
print(df.head(2).to_string())
['id', 'name', 'easting', 'northing']
id name easting northing
0 4500LEEDS0 Leeds Rail Station 429932 433280
1 4500LEEDS1 Leeds Rail Station 429831 433330
import geopandas as gpd
CANDIDATES = [("lon", "lat"), ("longitude", "latitude"), ("x", "y"),
("easting", "northing"), ("lng", "lat"), ("long", "lat")]
def csv_to_gdf(path, crs=None, **kwargs):
df = pd.read_csv(path, **kwargs)
lower = {c.lower(): c for c in df.columns}
for xcol, ycol in CANDIDATES:
if xcol in lower and ycol in lower:
x, y = df[lower[xcol]], df[lower[ycol]]
if crs is None:
crs = 4326 if x.abs().max() <= 180 and y.abs().max() <= 90 else None
if crs is None:
raise ValueError(
f"found {xcol}/{ycol} but the values are not degrees "
f"({x.min():.1f}β{x.max():.1f}) β pass crs= explicitly")
print(f" using {lower[xcol]}/{lower[ycol]} as x/y in EPSG:{crs}")
return gpd.GeoDataFrame(
df, geometry=gpd.points_from_xy(x, y), crs=crs)
wkt_cols = [c for c in df.columns if c.lower() in ("wkt", "geometry", "geom")]
if wkt_cols:
from shapely import wkt as shapely_wkt
print(f" parsing WKT from '{wkt_cols[0]}'")
return gpd.GeoDataFrame(
df, geometry=df[wkt_cols[0]].apply(shapely_wkt.loads), crs=crs)
raise ValueError(f"no coordinate columns found in {df.columns.tolist()}")
try:
csv_to_gdf("mystery/sites.csv")
except ValueError as err:
print(f"ValueError: {err}")
sites = csv_to_gdf("mystery/sites.csv", crs=27700)
ValueError: found easting/northing but the values are not degrees (393150.0β449013.0) β pass crs= explicitly
using easting/northing as x/y in EPSG:27700
Refusing to guess the CRS when the values are not degrees is deliberate. Eastings in the hundreds of thousands could be British National Grid, a UTM zone, or a local survey grid β see how to clean messy CSV coordinates.
6. Know what to do when GDAL genuinely cannot read it
import pyogrio
drivers = pyogrio.list_drivers()
print(f"{len(drivers)} drivers in GDAL {pyogrio.__gdal_version_string__}")
print({k: drivers[k] for k in ("ESRI Shapefile", "GPKG", "OpenFileGDB", "FlatGeobuf") if k in drivers})
print("FileGDB (Esri's own SDK driver):", "FileGDB" in drivers)
64 drivers in GDAL 3.12.4
{'ESRI Shapefile': 'raw', 'GPKG': 'raw', 'OpenFileGDB': 'raw', 'FlatGeobuf': 'rw'}
FileGDB (Esri's own SDK driver): False
A driver that is absent was not compiled into your GDAL β that is a build-time property, not something a Python package can add. FileGDB, NetCDF and several others are commonly missing from minimal builds. See GDAL and GeoPandas fail inside Docker.
If the format is genuinely unsupported, the options are:
- Convert with QGIS, which bundles a fuller GDAL.
- Ask for a different export. GeoPackage is a reasonable request of any supplier.
- Proprietary CAD β
.dwgneeds a licensed driver;.dxfusually works. - An Esri Personal Geodatabase (
.mdb) needs the PGeo driver and often Windows.
An inspector and a resilient opener for a messy handover
The data used on this page
The files are real data dressed up as a supplier's handover, with the usual problems put in deliberately. mystery/data.gdb is an Esri File Geodatabase holding HM Land Registry's INSPIRE polygons for Leeds as parcels, Leeds' wards as wards, West Yorkshire's railway station entrances from NaPTAN as stations (with no CRS), and a plain metadata table. mystery/export_2026.dat is a GeoPackage of West Yorkshire's bus stops with the wrong extension. mystery/fylker.shp is Norway's NUTS 2 regions from Eurostat's GISCO, with the names written in ISO-8859-1. mystery/sites.csv is the station entrances as eastings and northings. downloads/ mixes the same things with a zipped shapefile of the wards. Land Registry data Β© Crown copyright and database right 2026, OGL v3; NUTS Β© EuroGeographics.
import io
import shutil
import zipfile
from pathlib import Path
import geopandas as gpd
import pandas as pd
import pyogrio
import requests
for folder in ("mystery", "downloads"):
shutil.rmtree(folder, ignore_errors=True)
Path(folder).mkdir()
reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/Leeds.zip",
timeout=600) # sets a cookie, then redirects to itself
reply.raise_for_status()
Path("leeds_inspire.zip").write_bytes(reply.content)
pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
parcels = gpd.read_file("zip://leeds_inspire.zip!Land_Registry_Cadastral_Parcels.gml", columns=["INSPIREID"])
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"
wards = gpd.read_file(f"{ONS}/Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query"
"?where=LAD24NM%3D%27Leeds%27&outFields=WD24CD,WD24NM&outSR=27700&f=geojson"
).set_crs(27700, allow_override=True)
# NaPTAN area 450 as saved on 9 October 2026 (the register changes weekly); live:
# naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv&atcoAreaCodes=450
naptan = pd.read_csv(io.StringIO(requests.get("https://data.spatialworkflow.io/naptan-area-450-2026-10-09.csv", timeout=300).text),
low_memory=False)
naptan = naptan[naptan["Status"] == "active"]
as_points = lambda df, crs: gpd.GeoDataFrame(df[["ATCOCode", "CommonName"]].reset_index(drop=True), crs=crs,
geometry=gpd.points_from_xy(df["Easting"], df["Northing"]))
stations = as_points(naptan[naptan["StopType"] == "RSE"], None) # the CRS goes missing
stops = as_points(naptan[naptan["StopType"].isin(["BCT", "BCS"])], 27700)
gdb = "mystery/data.gdb"
pyogrio.write_dataframe(parcels, gdb, layer="parcels", driver="OpenFileGDB")
pyogrio.write_dataframe(wards, gdb, layer="wards", driver="OpenFileGDB")
pyogrio.write_dataframe(stations, gdb, layer="stations", driver="OpenFileGDB")
pyogrio.write_dataframe(pd.DataFrame({"key": ["source", "supplied"], "value": ["various", "2026-09"]}),
gdb, layer="metadata", driver="OpenFileGDB")
stops.to_file("mystery/export_2026.gpkg")
Path("mystery/export_2026.gpkg").rename("mystery/export_2026.dat") # the wrong extension
nuts = gpd.read_file("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
"NUTS_RG_20M_2024_4326_LEVL_2.geojson")
norway = nuts[nuts["CNTR_CODE"] == "NO"][["NUTS_NAME", "geometry"]].rename(columns={"NUTS_NAME": "navn"})
norway.to_file("mystery/fylker.shp", encoding="ISO-8859-1") # Latin-1 bytes, and a .cpg saying so
naptan[naptan["StopType"] == "RSE"][["ATCOCode", "CommonName", "Easting", "Northing"]].rename(
columns={"ATCOCode": "id", "CommonName": "name", "Easting": "easting", "Northing": "northing"}
).to_csv("mystery/sites.csv", index=False)
wards.to_file("wards.shp")
with zipfile.ZipFile("downloads/boundaries.zip", "w") as z:
for part in Path(".").glob("wards.*"):
z.write(part, part.name)
shutil.copytree(gdb, "downloads/data.gdb")
shutil.copy("mystery/export_2026.dat", "downloads/export_2026.dat")
stations.set_crs(27700).to_crs(4326).to_file("downloads/sites.geojson")
print(sorted(p.name for p in Path("mystery").iterdir()))
['data.gdb', 'export_2026.dat', 'fylker.cpg', 'fylker.dbf', 'fylker.prj', 'fylker.shp', 'fylker.shx', 'sites.csv']
Example 1: an inspector that reports before you read
from pathlib import Path
import pyogrio
MAGIC = {
b"SQLite format 3": "GeoPackage or SpatiaLite",
b"PK\x03\x04": "ZIP archive",
b"\x00\x00\x27\x0a": "Shapefile",
b"II*\x00": "TIFF (GeoTIFF?)",
b"MM\x00*": "TIFF (GeoTIFF?)",
b"PAR1": "Parquet (GeoParquet?)",
b"fgb\x03": "FlatGeobuf",
b"<?xml": "XML (GML/KML?)",
}
SIDECARS = {".shp": [".shx", ".dbf", ".prj", ".cpg"],
".tab": [".dat", ".map", ".id"],
".mif": [".mid"]}
def inspect(path):
path = Path(path)
print(f"{path}")
if path.is_dir():
print(f" directory β {'File Geodatabase' if path.suffix == '.gdb' else 'folder'}")
else:
print(f" {path.stat().st_size / 1e6:,.1f} MB")
head = path.read_bytes()[:16]
magic = next((v for k, v in MAGIC.items() if head.startswith(k)),
f"unrecognised {head[:8]!r}")
print(f" magic: {magic}")
for suffix in SIDECARS.get(path.suffix.lower(), []):
mate = path.with_suffix(suffix)
print(f" {'β' if mate.exists() else 'β'} {suffix}"
+ ("" if mate.exists() else " β missing"))
if suffix == ".cpg" and mate.exists():
print(f" encoding: {mate.read_text().strip()}")
if suffix == ".prj" and not mate.exists():
print(f" no CRS will be read β the .prj is missing")
try:
layers = pyogrio.list_layers(str(path))
except Exception as exc:
print(f" β GDAL cannot open it: {type(exc).__name__}: {exc}")
return None
print(f" {len(layers)} layer(s):")
out = []
for name, geom_type in layers:
try:
info = pyogrio.read_info(str(path), layer=name)
fields = [f for f in info["fields"]]
print(f" {str(name):<22} {str(geom_type) or 'no geometry':<18} "
f"{info['features']:>9,} rows {info['crs'] or 'no CRS'}")
print(f" encoding {info.get('encoding')} "
f"{len(fields)} fields: {list(fields)[:5]}")
if info["features"] == 0:
print(f" β empty layer")
if info["crs"] is None and geom_type is not None:
print(f" β no CRS β identify it before analysis")
out.append({"layer": name, "geom": geom_type, **info})
except Exception as exc:
print(f" {name}: β {type(exc).__name__}")
return out
report = inspect("mystery/data.gdb")
mystery/data.gdb
directory β File Geodatabase
4 layer(s):
parcels MultiPolygon 293,729 rows EPSG:27700
encoding UTF-8 1 fields: ['INSPIREID']
wards MultiPolygon 33 rows EPSG:27700
encoding UTF-8 2 fields: ['WD24CD', 'WD24NM']
stations Point 155 rows no CRS
encoding UTF-8 2 fields: ['ATCOCode', 'CommonName']
β no CRS β identify it before analysis
metadata None 2 rows no CRS
encoding UTF-8 2 fields: ['key', 'value']
The whole report takes a fraction of a second, because read_info reads headers only. Three findings are already visible: which layer holds what, one layer has no CRS, and metadata is a plain table rather than a mistake.
The sidecar check earns its place with shapefiles. A missing .prj explains a crs = None before you go looking for a bug, and a missing .dbf explains an open that fails with a message about the geometry.
Example 2: a resilient opener
from pathlib import Path
import zipfile
import geopandas as gpd
import pyogrio
def open_spatial(path, *, layer=None, encoding=None, crs=None, verbose=True):
"""Open almost anything spatial, choosing the layer and format sensibly."""
path = Path(path)
# Parquet has its own reader
if path.suffix.lower() in (".parquet", ".pq"):
return gpd.read_parquet(path)
# CSV is not a spatial format
if path.suffix.lower() in (".csv", ".txt", ".tsv"):
return csv_to_gdf(path, crs=crs)
# a zip: look inside for something spatial
target = str(path)
if path.suffix.lower() == ".zip":
with zipfile.ZipFile(path) as zf:
names = zf.namelist()
spatial = [n for n in names
if Path(n).suffix.lower() in (".shp", ".gpkg", ".geojson",
".fgb", ".gml", ".tab")]
if not spatial:
raise ValueError(f"no spatial file inside {path}: {names[:8]}")
if verbose and len(spatial) > 1:
print(f" {len(spatial)} candidates in the zip, using {spatial[0]}")
target = f"/vsizip/{path}/{spatial[0]}"
layers = pyogrio.list_layers(target)
if layer is None:
spatial_layers = [(n, g) for n, g in layers if g is not None]
if not spatial_layers:
raise ValueError(f"no layer with geometry in {path}: {list(layers)}")
if len(spatial_layers) > 1:
counts = {n: pyogrio.read_info(target, layer=n)["features"]
for n, _ in spatial_layers}
layer = max(counts, key=counts.get)
if verbose:
print(f" {len(spatial_layers)} spatial layers {counts} β "
f"using '{layer}'. Pass layer= to choose.")
else:
layer = spatial_layers[0][0]
info = pyogrio.read_info(target, layer=layer)
read_kwargs = {"layer": layer}
if encoding:
read_kwargs["encoding"] = encoding
elif info.get("encoding") in (None, ""):
read_kwargs["encoding"] = "UTF-8"
gdf = gpd.read_file(target, **read_kwargs)
if gdf.crs is None:
if crs is not None:
gdf = gdf.set_crs(crs)
if verbose:
print(f" no CRS in the file β labelled EPSG:{crs} as instructed")
elif verbose:
print(f" β no CRS. Bounds {[round(v, 2) for v in gdf.total_bounds]} β "
f"identify it and pass crs=, do not guess")
if verbose:
print(f" {len(gdf):,} rows, {len(gdf.columns)} columns, "
f"{gdf.geometry.geom_type.value_counts().to_dict()}, {gdf.crs}")
return gdf
largest = open_spatial("mystery/data.gdb")
bundle = open_spatial("downloads/boundaries.zip")
3 spatial layers {'parcels': 293729, 'wards': 33, 'stations': 155} β using 'parcels'. Pass layer= to choose.
293,729 rows, 2 columns, {'MultiPolygon': 293729}, EPSG:27700
33 rows, 3 columns, {'Polygon': 33}, EPSG:27700
Two behaviours are deliberate. When several layers exist it picks the largest and says so, with the counts β a guess you can see is far better than a silent one. And a missing CRS is reported with the bounds so you can reason about it, but never inferred, because assigning a wrong CRS produces data that is confidently in the wrong place.
/vsizip/ means the archive is never extracted, which matters for a multi-gigabyte download.
Example 3: cataloguing a folder of unknown files
from pathlib import Path
import pandas as pd
import pyogrio
def catalogue(folder, *, recursive=True):
"""Everything spatial under `folder`, with what it contains."""
folder = Path(folder)
paths = (folder.rglob("*") if recursive else folder.glob("*"))
rows = []
for path in sorted(paths):
# a .gdb is a directory; skip everything inside one
if path.is_dir() and path.suffix.lower() != ".gdb":
continue
if any(p.suffix.lower() == ".gdb" for p in path.parents):
continue
if path.suffix.lower() in (".shx", ".dbf", ".prj", ".cpg", ".qix", ".sbn", ".sbx"):
continue
# .dat/.map/.id are MapInfo sidecars only when a .tab of the same name exists
if path.suffix.lower() in (".dat", ".map", ".id") and path.with_suffix(".tab").exists():
continue
if path.suffix.lower() == ".mid" and path.with_suffix(".mif").exists():
continue
try:
layers = pyogrio.list_layers(str(path))
except Exception:
continue
for name, geom_type in layers:
try:
info = pyogrio.read_info(str(path), layer=name)
except Exception as exc:
rows.append({"path": str(path.relative_to(folder)), "layer": name,
"error": type(exc).__name__})
continue
rows.append({
"path": str(path.relative_to(folder)),
"layer": name if len(layers) > 1 else "",
"geometry": str(geom_type) if geom_type else "table",
"features": info["features"],
"crs": info["crs"] or "β",
"fields": len(info["fields"]),
"mb": round(path.stat().st_size / 1e6, 1) if path.is_file() else None,
})
df = pd.DataFrame(rows)
if df.empty:
print("nothing spatial found")
return df
print(df.to_string(index=False))
print(f"\n{len(df)} layer(s), {df['features'].sum():,} features total")
crs_counts = df["crs"].value_counts().to_dict()
print(f"CRS: {crs_counts}")
missing = df[df["crs"] == "β"]
if len(missing):
print(f"β {len(missing)} layer(s) with no CRS: "
f"{missing['path'].tolist()[:4]}")
return df
listing = catalogue("downloads/")
path layer geometry features crs fields mb
boundaries.zip Polygon 33 EPSG:27700 2 0.8
data.gdb parcels MultiPolygon 293729 EPSG:27700 1 NaN
data.gdb wards MultiPolygon 33 EPSG:27700 2 NaN
data.gdb stations Point 155 β 2 NaN
data.gdb metadata table 2 β 2 NaN
export_2026.dat Point 16450 EPSG:27700 2 2.1
sites.geojson Point 155 EPSG:4326 2 0.0
7 layer(s), 310,557 features total
CRS: {'EPSG:27700': 4, 'β': 2, 'EPSG:4326': 1}
β 2 layer(s) with no CRS: ['data.gdb', 'data.gdb']
Note what the catalogue found without opening anything properly: export_2026.dat is spatial despite its extension, data.gdb has a layer with no CRS, and the folder mixes coordinate systems. All three would otherwise surface as confusing errors partway through a pipeline.
Skipping shapefile sidecars matters, or the catalogue reports the same layer four times. Skipping them carefully matters too: an earlier version of this function skipped every .dat file as a MapInfo sidecar, and so never saw export_2026.dat β the one file in the folder most in need of finding. A .dat is only a sidecar when a .tab of the same name sits next to it. Skipping the contents of a .gdb matters too, since the directory itself is the dataset.
GDAL is a format abstraction layer
GDAL is a format abstraction layer, and it is unusually good at its job β dozens of vector and raster formats behind one interface. So "Python cannot read this file" is rarely true. The real problems are identification and selection.
Identification is hard because the extension is a convention, not a guarantee. A file called .dat may be a GeoPackage; a .json may be GeoJSON or a configuration file; a .gdb is a directory rather than a file. GDAL opens a candidate by trying its drivers in order until one recognises the content, which is why read_file on an unrecognised file reports "unsupported format" rather than naming what it saw. Checking the magic bytes yourself takes three lines and often answers the question outright.
Selection is the second problem, and it is the one that produces silently wrong results. GeoPackage, File Geodatabase, GML and SpatiaLite are all containers holding several layers. read_file without layer= takes the first, which may be an empty table, a lookup table, or simply the wrong one. Nothing warns you, because reading the first layer is a perfectly valid thing to have asked for. Listing layers first turns a silent wrong answer into a deliberate choice.
File sets are a related trap. A shapefile is at minimum three files and usually five: .shp holds geometry, .shx an index, .dbf the attributes, .prj the CRS, .cpg the encoding. Copying only the .shp produces a file that opens with no attributes, or fails outright. A missing .prj produces crs = None β not a bug, but genuine information loss at the point the file was copied. This is one of the strongest arguments for GeoPackage, which puts everything in one file.
Encoding is a shapefile-specific wound. The format predates Unicode and has no reliable way to declare its encoding. The .cpg sidecar was retrofitted and is frequently absent, so GDAL guesses β usually UTF-8, sometimes the system default β and a wrong guess produces mojibake rather than an error. Trying candidate encodings on a five-row sample and looking at the text is the fastest resolution.
GDAL's virtual file systems are underused and genuinely powerful. /vsizip/ reads inside archives without extracting; /vsicurl/ reads over HTTP with range requests; /vsis3/ reads from object storage. Combined with a format supporting spatial indexes β FlatGeobuf, or a Cloud-Optimized GeoTIFF β a bounding-box read over /vsicurl/ fetches only the relevant bytes of a remote file. That turns "download 40 GB then filter" into "fetch 2 MB".
Finally, driver availability is a build-time property. pyogrio.list_drivers() reports what your GDAL was compiled with. A missing FileGDB or NetCDF driver cannot be added by installing a Python package; it needs a different GDAL build. That surprises people because everything else in Python is a pip install away β but the driver list is part of the C library, not the wrapper.
Directories as files, first layers and CRS-less DXF
- A
.gdbis a directory. Pass the directory path, not a file inside it. read_filewithoutlayer=takes the first layer, which may be empty or wrong.pyogrio.read_inforeads headers only β milliseconds even on multi-gigabyte files.- A shapefile is a file set. Missing
.dbfloses attributes; missing.prjloses the CRS. /vsizip/path.zip/inner.shpreads inside an archive without extracting it./vsicurl/plus FlatGeobuf or COG fetches only the bytes a bounding box needs.- GeoJSON is always WGS 84 by specification, whatever the source CRS was.
- DXF and DWG usually have no CRS β they are drawings on a local grid.
pyogrio.list_drivers()shows what your GDAL supports; absent drivers need a different build.rows=5onread_filesamples a file quickly without reading it all.
Internal links
- How to explore a spatial dataset you have never seen before β what to do once it opens
- GIS vector file formats compared β what each format can and cannot hold
- GeoPandas not reading shapefile: common causes and fixes β the commonest failures
- Garbled attribute text and UnicodeDecodeError from a shapefile β the encoding fix in depth
- How to read a CSV with coordinates as a GeoDataFrame β the CSV case
- How to clean messy CSV coordinates into a reliable GeoDataFrame β when the columns are a mess
- What GDAL and OGR actually are β the layer doing all this work
- How to build an inventory of a GIS data folder in Python β cataloguing at scale
FAQ
How do I find out what format a file is?
Check the magic bytes β file path on the command line, or read the first sixteen bytes in Python. A .dat file starting with SQLite format 3 is almost certainly a GeoPackage.
Why does my GeoPackage open with zero rows?
It probably has several layers and read_file took the first, which is empty or non-spatial. List them with pyogrio.list_layers and pass layer=.
How do I read a shapefile inside a zip?
gpd.read_file("/vsizip/data.zip/folder/parcels.shp"), or gpd.read_file("data.zip") when the archive holds one obvious layer. Nothing is extracted.
Why is my text showing as ΓΛ instead of Γ?
The wrong encoding. Check for a .cpg sidecar, then try encoding="ISO-8859-1" or "cp1252" on a five-row sample and look at the result.
GDAL says the format is unsupported. Now what?
Check pyogrio.list_drivers(). Driver support is compiled into GDAL, so a missing one needs a different build, not a different Python package. QGIS bundles a fuller GDAL and can convert.
Can I read a file over HTTP without downloading it?
Yes β /vsicurl/https://β¦. With FlatGeobuf or a Cloud-Optimized GeoTIFF, a bounding-box read fetches only the bytes it needs.