How to Open Any Spatial File in Python When You Do Not Know the Format
Problem statement
Someone sends you data.zip. Inside are seventeen files with nine extensions, no documentation, and a filename in Norwegian.
import geopandas as gpd
gpd.read_file("mystery/export_2026.dat")
DriverError: mystery/export_2026.dat: No such file or directory,
or unsupported format
Or it opens and looks wrong:
gdf = gpd.read_file("mystery/data.gdb")
print(len(gdf)) # 0
Zero rows, no error. There are four layers inside and you got the first, which is empty.
Or it opens and the text is mangled:
print(gdf["navn"].head(2).tolist()) # ['ΓΛstfold', 'MΓΒΈre og Romsdal']
GDAL supports around 200 vector formats and a hundred raster ones. 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"))
print(pyogrio.read_info("mystery/data.gdb", layer=0))
[['parcels' 'MultiPolygon']
['roads' 'MultiLineString']
['sites' 'Point']
['metadata' None]]
{'crs': 'EPSG:25833', 'encoding': 'UTF-8', 'fields': array([...]),
'geometry_type': 'MultiPolygon', 'features': 412884, ...}
| 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")
Step-by-step solution
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
mystery/export_2026.dat: SQLite 3.x database, last written using SQLite version 3045000
A SQLite database with a spatial extension is almost certainly a GeoPackage with the wrong extension:
gdf = gpd.read_file("mystery/export_2026.dat", driver="GPKG")
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}"
print(sniff("mystery/export_2026.dat"))
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("data.gdb"):
info = pyogrio.read_info("data.gdb", layer=name)
print(f"{name:<20} {str(geom_type):<20} {info['features']:>9,} features "
f"{info['crs']}")
parcels MultiPolygon 412,884 features EPSG:25833
roads MultiLineString 188,204 features EPSG:25833
sites Point 2,411 features EPSG:25833
metadata None 12 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 even on a 6 GB file.
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:
gpd.read_file("zip://data.zip!folder/parcels.shp")
gpd.read_file("data.zip") # works when the zip holds one obvious layer
/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
Mangled text means the file's bytes were decoded with the wrong codec:
print(gdf["navn"].head(2).tolist()) # ['ΓΛstfold', 'MΓΒΈre og Romsdal']
ΓΛ is UTF-8 bytes read as Latin-1, or the reverse. Shapefiles are the usual source, because they have no reliable encoding declaration:
from pathlib import Path
cpg = Path("data.cpg")
if cpg.exists():
print(cpg.read_text().strip()) # 'ISO-8859-1'
gdf = gpd.read_file("data.shp", encoding="ISO-8859-1")
print(gdf["navn"].head(2).tolist()) # ['Γstfold', 'MΓΈre og Romsdal']
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("data.shp", "navn")
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("sites.csv")
print(df.columns.tolist())
print(df.head(2))
['id', 'name', 'easting', 'northing', 'value']
id name easting northing value
0 1 Site One 383618.5 398050.4 41.2
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()}")
sites = csv_to_gdf("sites.csv", crs=27700)
Refusing to guess the CRS when the values are not degrees is deliberate. Eastings of 383,618 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 available")
print({k: v for k, v in list(drivers.items())[:6]})
78 drivers available
{'ESRI Shapefile': 'rw', 'GPKG': 'rw', 'GeoJSON': 'rw', 'FlatGeobuf': 'rw', ...}
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.
Code examples
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
inspect("mystery/data.gdb")
mystery/data.gdb
directory β File Geodatabase
4 layer(s):
parcels MultiPolygon 412,884 rows EPSG:25833
encoding UTF-8 28 fields: ['objectid', 'gid', 'kommune', 'areal', 'type']
roads MultiLineString 188,204 rows EPSG:25833
encoding UTF-8 14 fields: ['objectid', 'vegnummer', 'navn', 'lengde']
sites Point 2,411 rows no CRS
encoding UTF-8 9 fields: ['id', 'navn', 'x', 'y', 'verdi']
β no CRS β identify it before analysis
metadata no geometry 12 rows no CRS
encoding UTF-8 4 fields: ['key', 'value', 'updated', 'source']
The whole report takes under a second on a multi-gigabyte geodatabase, because read_info reads headers only. Three findings are already visible: the layer you want is parcels, 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
parcels = open_spatial("mystery/data.gdb", layer="parcels")
bundle = open_spatial("downloads/boundaries.zip")
4 spatial layers {'parcels': 412884, 'roads': 188204, 'sites': 2411} β using 'parcels'. Pass layer= to choose.
412,884 rows, 29 columns, {'MultiPolygon': 412884}, EPSG:25833
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 4 GB 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", ".dat", ".map", ".id", ".mid"):
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
catalogue("downloads/")
path layer geometry features crs fields mb
boundaries.zip MultiPolygon 412 EPSG:27700 12 4.1
data.gdb parcels MultiPolygon 412884 EPSG:25833 28 NaN
data.gdb roads MultiLineString 188204 EPSG:25833 14 NaN
data.gdb sites Point 2411 β 9 NaN
export.dat Point 8412 EPSG:27700 6 12.8
sites.geojson Point 204 EPSG:4326 4 0.2
6 layer(s), 612,527 features total
CRS: {'EPSG:25833': 2, 'EPSG:27700': 2, 'EPSG:4326': 1, 'β': 1}
Note what the catalogue found without opening anything properly: export.dat is spatial despite its extension, data.gdb has one layer with no CRS, and the folder mixes three 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 the contents of a .gdb matters too, since the directory itself is the dataset.
Explanation
GDAL is a format abstraction layer, and it is unusually good at its job β around 200 vector formats and a hundred raster ones 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.
Edge cases or notes
- 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.
The file has no CRS. What do I use?
Nothing, until you know. Print the bounds β six-figure numbers are metres in some projected system β and ask the supplier. Assigning a wrong CRS is worse than having none.
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.