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, ...}
Vertical steps from file identification through layer listing to a targeted read.
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")

Step-by-step solution

1. Identify the format from what is on disk

Grid mapping common spatial file extensions to their format and whether they are single files or file sets.
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
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 β€” .dwg needs a licensed driver; .dxf usually 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

Stack showing GDAL between many formats and one Python interface, with the two remaining problems named.
GDAL solves reading. What is left is identification and selection.

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 .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.

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.