Fixing ST_Read Failures on Spatial Files in DuckDB
Problem statement
ST_Read hands the file to GDAL, so its failures are GDAL's failures wearing a DuckDB message. The most common one says almost nothing:
IOException: IO Error: Could not open GDAL dataset at: /data/provinces.shp
That single message covers a missing file, a missing sidecar, a format GDAL was not built with, a permissions problem, an unreadable remote URL and a corrupt archive.
Worse are the failures that are not failures. A shapefile with no .prj reads perfectly and yields geometry with no CRS and no warning. A shapefile with no .dbf also reads โ measured, 7,342 rows came back with the geometry intact and every attribute silently absent.
Quick answer
Work down the list, cheapest first:
import os
import duckdb
def diagnose_st_read(path, con=None):
con = con or duckdb.connect()
con.execute("load spatial")
if not os.path.exists(path.split("/")[0] if path.startswith("/vsi") else path):
print(f"1. path does not exist: {path}")
return
if path.lower().endswith(".shp"):
stem = path[:-4]
for suffix, needed in ((".shx", "required"), (".dbf", "attributes"),
(".prj", "the CRS"), (".cpg", "the encoding")):
present = os.path.exists(stem + suffix)
print(f"2. {suffix}: {'present' if present else 'MISSING'} โ {needed}")
try:
meta = con.execute(f"select * from st_read_meta('{path}')").df()
print(f"3. GDAL opened it: {len(meta)} layer group(s)")
except Exception as exc:
print(f"3. GDAL cannot open it: {str(exc).splitlines()[0]}")
print(" check the driver list: select * from st_drivers()")
return
n = con.execute(f"select count(*) from st_read('{path}')").fetchone()[0]
t = con.execute(f"select typeof(geom) from st_read('{path}') limit 1").fetchone()[0]
print(f"4. {n:,} features, geometry type {t}")
if t == "GEOMETRY":
print(" ! no CRS โ a missing .prj, or the source never had one")
Step-by-step solution
1. Confirm the path, from DuckDB's point of view
Relative paths resolve against the process's working directory, which in a notebook, a scheduler or a container is frequently not where you think.
import os
print(os.getcwd())
print(os.path.abspath(path), os.path.exists(path))
Use absolute paths in anything scheduled. It is the single most common cause of this error and the least interesting.
2. Check the shapefile's sidecars
A shapefile is a set of files, and different ones are missing in different ways:
| File | Required | What happens without it |
|---|---|---|
.shp |
yes | nothing opens |
.shx |
yes | GDAL may fail or attempt a rebuild |
.dbf |
no, in practice | reads fine, every attribute silently gone |
.prj |
no | reads fine, no CRS, no warning |
.cpg |
no | encoding is guessed; non-ASCII text may be mangled |
The two middle rows are the dangerous ones because they are not errors. Measured, a .shp plus .shx alone returned all 7,342 features with the geometry correct and no attributes at all.
3. Ask GDAL what it can open
select short_name, long_name, can_create from st_drivers() order by short_name;
The bundled GDAL build is not the same as a system GDAL. Formats you use elsewhere โ FileGDB, some database drivers, specific raster formats โ may simply not be in the list. If the driver is absent, no amount of path fixing helps; convert the file with another tool first.
4. Name the layer for multi-layer sources
A GeoPackage or a GDB usually holds several layers, and ST_Read without a layer name reads the first one โ which is rarely the one you want:
select * from st_read_meta('data.gpkg'); -- list the layers
select count(*) from st_read('data.gpkg', layer = 'roads');
5. Use GDAL's virtual filesystems for archives and remote files
select count(*) from st_read('/vsizip/data/archive.zip/inner/file.shp');
select count(*) from st_read('/vsicurl/https://example.org/data.geojson');
select count(*) from st_read('/vsigzip/data/file.geojson.gz');
These are GDAL prefixes, not DuckDB ones, and they are the correct way to read a zipped shapefile without extracting it. Note that /vsicurl/ is GDAL's remote reader, not DuckDB's httpfs โ the two are separate mechanisms with different behaviour.
6. Check the CRS, because nothing else will
select typeof(geom) from st_read('file.shp') limit 1;
-- GEOMETRY('EPSG:4326') <- has a CRS
-- GEOMETRY <- does not
A bare GEOMETRY means the source declared no CRS. The file still reads, joins still run, and a join against data in a different system returns zero rows with no error. Assert on this in any pipeline that reads files it did not create.
Code examples
Example 1 โ a preflight check for a shapefile
import os
REQUIRED = {".shp", ".shx"}
IMPORTANT = {".dbf": "attributes will be missing",
".prj": "the CRS will be unknown",
".cpg": "the text encoding will be guessed"}
def check_shapefile(path):
stem, ext = os.path.splitext(path)
if ext.lower() != ".shp":
return True
problems, warnings = [], []
for suffix in REQUIRED:
if not os.path.exists(stem + suffix):
problems.append(f"{suffix} is missing and is required")
for suffix, consequence in IMPORTANT.items():
if not os.path.exists(stem + suffix):
warnings.append(f"{suffix} is missing โ {consequence}")
for warning in warnings:
print(f" ! {warning}")
if problems:
raise FileNotFoundError(f"{path}:\n " + "\n ".join(problems))
return True
Example 2 โ reading with the CRS asserted
import duckdb
def read_with_crs(con, path, expected_crs=None, layer=None):
"""Read, and refuse to continue if the CRS is unknown or unexpected."""
args = f"'{path}'" + (f", layer='{layer}'" if layer else "")
geom_type = con.execute(
f"select typeof(geom) from st_read({args}) limit 1").fetchone()
if geom_type is None:
raise ValueError(f"{path}: opened, but contains no features")
declared = geom_type[0]
if declared == "GEOMETRY":
message = (f"{path}: no CRS. A shapefile needs its .prj; a join against "
f"data in another CRS will return zero rows with no error.")
if expected_crs:
raise ValueError(message + f" Expected {expected_crs}.")
print(f" ! {message}")
elif expected_crs and expected_crs not in declared:
raise ValueError(f"{path}: CRS is {declared}, expected {expected_crs}")
return con.execute(f"select * from st_read({args})")
Example 3 โ falling back to a conversion when the driver is absent
def read_or_convert(con, path, layer=None):
"""If the bundled GDAL cannot open it, convert with pyogrio and retry."""
try:
return con.execute(f"select * from st_read('{path}')").df()
except duckdb.IOException as exc:
print(f"st_read failed: {str(exc).splitlines()[0]}")
drivers = {row[0] for row in con.execute(
"select short_name from st_drivers()").fetchall()}
print(f" bundled GDAL has {len(drivers)} drivers")
import tempfile
import geopandas as gpd
gdf = gpd.read_file(path, layer=layer) # a different GDAL build
with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as handle:
target = handle.name
gdf.to_parquet(target)
print(f" converted via geopandas to {target} ({len(gdf):,} features)")
return con.execute(f"select * from read_parquet('{target}')").df()
The point of the fallback is that GeoPandas' GDAL โ installed through pyogrio โ is a different build from the one bundled with the DuckDB extension, and frequently supports formats the bundled one does not.
Explanation
Why the error message is so unhelpful
ST_Read calls GDALOpenEx, which returns a null dataset on failure. GDAL's detailed diagnostics go to its own error handler, which the extension does not surface, so DuckDB can only report that the open failed.
That is why the diagnostic sequence in this article works from the outside in: existence, sidecars, drivers, layers. Each step tests one of the things GDAL declined to explain.
Why a missing .prj is worse than a missing .shp
A missing .shp is a hard failure at the first line of the script. A missing .prj produces a working pipeline whose spatial results are wrong in ways no test catches: a join returns zero rows, an area comes back in square degrees, a buffer is an ellipse.
DuckDB does not raise on a CRS mismatch, so the whole class of failure is silent. Asserting on typeof(geom) after every read of an external file is the cheapest defence available.
Why the bundled GDAL differs from your system GDAL
The DuckDB spatial extension ships a statically linked GDAL chosen for portability. It supports the common formats and omits ones with heavy dependencies or restrictive licences.
st_drivers() lists exactly what this build supports. When a format is missing, the fix is a conversion step in another tool โ not an argument with DuckDB.
Why /vsizip/ beats extracting
Extracting a zipped shapefile creates temporary files that have to be cleaned up, doubles the disk usage and adds a step that can fail. GDAL's virtual filesystems read inside the archive directly.
They also compose: /vsizip//vsicurl/https://example.org/data.zip/file.shp reads a shapefile inside a zip file on a remote server without downloading the archive. That is occasionally exactly what you need.
Edge cases or notes
- Absolute paths in anything scheduled. Relative paths resolve against a working directory you did not choose.
.dbfmissing reads fine with no attributes โ measured on a real file..prjmissing reads fine with no CRS and no warning of any kind.st_read_meta()is the cheap probe โ it lists layers without reading features.- Multi-layer sources need
layer=, or you get the first layer. /vsizip/,/vsicurl/,/vsigzip/are GDAL prefixes, unrelated tohttpfs.- Encoding comes from
.cpgwhen present, and is guessed otherwise โ expect mojibake on non-ASCII attributes. - Case sensitivity matters on Linux:
FILE.SHPandfile.shpare different files.
Internal links
- How to read shapefiles, GeoJSON and GeoParquet in DuckDB โ the readers used correctly
- Fixing a DuckDB spatial extension that will not load โ the failure before this one
- CRS in DuckDB: why ST_Transform moves your data to the wrong place โ why a missing CRS is dangerous
- Fixing GeoPandas that is not reading a shapefile โ the same file, the other library
- Fixing an unsupported file format in rasterio โ the raster equivalent
- GDAL and OGR explained โ what is underneath
- Fixing shapefile encoding errors in Python โ the
.cpgproblem - How to open any spatial file in Python โ a general reader
FAQ
What does "Could not open GDAL dataset" mean?
Only that GDAL declined to open the file. Check, in order: the path, the shapefile sidecars, whether the driver exists in st_drivers(), and whether the source is an archive needing a /vsizip/ prefix.
Why does my shapefile read but have no attributes?
The .dbf is missing. Measured, a .shp plus .shx returned all 7,342 features with correct geometry and no attribute columns at all.
Why does my geometry have no CRS?
The source declared none โ usually a missing .prj. The file still reads, and a later join against data in another CRS will return zero rows without an error.
How do I read a specific layer from a GeoPackage?
st_read('data.gpkg', layer = 'roads'). Without the layer name you get the first one, which is rarely the one you want.
Can DuckDB read a zipped shapefile?
Yes, through GDAL's virtual filesystem: st_read('/vsizip/data/archive.zip/inner/file.shp'). No extraction needed.
Why can GeoPandas open a file DuckDB cannot?
They use different GDAL builds. The extension bundles a statically linked GDAL with a smaller driver set; st_drivers() lists exactly what it supports.