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}
The four steps before calling read_file on an unknown file. four steps, each a few milliseconds1What format is it?magic bytes2What layers does it hold?list_layers3What is in the one you want?read_info4Read that layer, with an encodingread_fileread_file without layer= silently takes the first spatialworkflow.io
Four steps before 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

Common spatial file extensions and whether they are single files. half of these are not a single filewhat it issingle file?.shpShapefileβœ•no β€” 3 to 5 files.gpkgGeoPackageβœ“yes, many layers.gdbFile Geodatabase!no β€” a directory.tabMapInfo TABβœ•no β€” 4 files.geojsonGeoJSONβœ“yes, always 4326.parquetGeoParquetβœ“yes, or a directoryCopying only the .shp loses the attributes and the CRS spatialworkflow.io
Half of these are directories or file sets, which is why "open the .shp" is not always the answer.
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 β€” .dwg needs a licensed driver; .dxf usually works.
  • An Esri Personal Geodatabase (.mdb) needs the PGeo driver and often Windows.

An inspector and a resilient opener for a messy handover

Four results from a file that will not read properly, and what each one actually means. three of these four raise nothing at allwhat comes backwhat it meansΓƒΛ˜stfold instead of ØstfoldRight bytes, wrong codec β€” readthe .cpgA GeoPackage with zero rowsIt has several layers; you got thewrong oneA DataFrame with no geometryA CSV β€” build points from thecolumnsDriverError: unsupportedCheck ogrinfo --formats beforeblaming the fileOnly the last one is an exception; the other three are quietly wrong output spatialworkflow.io
The inspector below reports all four before you commit to a read.

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 sitting between many formats and one Python interface. GDAL solves reading; two problems remainread_file / read_parquetone interfacewhat you callGDAL / OGRdozens of formatsthe abstractionidentificationwhich format?the extension is a conventionselectionwhich layer?the default is the first"Python cannot read this" is almost never true spatialworkflow.io
GDAL solves reading. What is left is identification and selection.

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.

The five shapefile sidecar files and what is lost when one is not copied. a shapefile is five files pretending to be onefileholdscopied without it.shpgeometrynothing opens.shxthe record indexnothing opens.dbfthe attributesshapes, no columns.prjthe CRScrs = None.cpgthe encodingmojibake, silentlythe last two lose information at the moment of copying, and no reader can recover itGeoPackage puts all five in one file, which is most of the reason it exists spatialworkflow.io
Only the first two announce themselves; the last two fail quietly.

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 .gdb is a directory. Pass the directory path, not a file inside it.
  • read_file without layer= takes the first layer, which may be empty or wrong.
  • pyogrio.read_info reads headers only β€” milliseconds even on multi-gigabyte files.
  • A shapefile is a file set. Missing .dbf loses attributes; missing .prj loses the CRS.
  • /vsizip/path.zip/inner.shp reads 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=5 on read_file samples a file quickly without reading it all.

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.