GeoPandas to_file() Fails on a Column Type: How to Fix It
On this page
Problem statement
The analysis finished, the frame looks right, and the write fails:
NotImplementedError: field type is not supported timedelta64[s] (field index: 1)
ValueError: GeoDataFrame contains multiple geometry columns but GeoDataFrame.to_file supports only a single geometry column.
Or β far more often β it succeeds and quietly changes your data. GeoPandas holds pandas dtypes, which are rich; file formats hold a small, fixed set of field types. Writing means mapping one onto the other, and anything that does not map has to be converted, dropped, or refused. With GeoPandas 1.1 and pyogrio 0.13 (GDAL 3.12), refusing is the exception. Measured on a real catalogue of earthquakes:
- a column of lists or dicts is written to GeoPackage and shapefile as Python's printed form,
"['us']"β text that is not JSON and does not parse back - bytes are written as the text
"b'ab'" - a column mixing integers and strings comes back as all strings
- a datetime in a shapefile becomes a string field, with a one-line warning
- a string over 254 characters in a shapefile is truncated, with a warning
- a 64-bit integer in a shapefile becomes a float and loses its last digits β 773 distinct IDs came back as 2
- with the older Fiona engine, lists and bytes are written as
Noneβ the data is simply gone
Common causes of trouble:
- an
objectcolumn containing lists, dicts, sets or bytes - a column of mixed types after a merge or a CSV read
- a
timedelta64column, which no vector format has a field for - a second geometry column, from merging two GeoDataFrames
- writing to a shapefile at all
Quick answer
Before writing, normalise the schema, then read the file back:
- print
gdf.dtypesand the Python types inside everyobjectcolumn - serialise structured values (lists, dicts) to JSON strings yourself
- convert
timedeltato a number with a unit in the column name - keep one geometry column
- choose a format that supports what you have: GeoPackage over shapefile, GeoParquet for everything
import json
import pandas as pd
def prepare_for_write(gdf):
out = gdf.copy()
out = out.drop(columns=[c for c in out.columns if c != out.geometry.name and out[c].dtype.name == "geometry"])
for col in out.columns.drop(out.geometry.name):
s = out[col]
if pd.api.types.is_timedelta64_dtype(s):
out[col] = s.dt.total_seconds()
out = out.rename(columns={col: col + "_s"})
elif s.dtype == object:
kinds = set(s.dropna().map(type))
if kinds & {list, dict, tuple, set}:
out[col] = s.map(lambda v: json.dumps(list(v) if isinstance(v, (set, tuple)) else v) if v is not None else None)
elif len(kinds) > 1:
out[col] = s.map(lambda v: None if v is None else str(v))
return out
safe = prepare_for_write(quakes)
safe.to_file("output/quakes.gpkg", driver="GPKG")
back = gpd.read_file("output/quakes.gpkg")
print(back.dtypes.astype(str).to_dict())
print(back["networks"].head(3).map(json.loads).tolist())
{'id': 'str', 'mag': 'float64', 'place': 'str', 'time': 'datetime64[ms, UTC]', 'felt': 'float64', 'tsunami': 'bool', 'alert': 'str', 'networks': 'str', 'revision_delay_s': 'float64', 'felt_mixed': 'str', 'geometry': 'geometry'}
[['us'], ['us'], ['us']]
The earthquakes, their derived columns and the quakes frame are set up in The data used on this page. Running a preparation step like this before every write turns a class of silent conversions into ones you chose.
What each format can store
Find the column, then convert it to what the format stores
Find the offending column
An error message names a field index at best, and a silent conversion names nothing. Look at the Python types inside each object column:
print(quakes.dtypes.astype(str).to_dict())
for col in quakes.columns.drop(quakes.geometry.name):
if quakes[col].dtype == object:
kinds = quakes[col].dropna().map(lambda v: type(v).__name__).value_counts().to_dict()
print(f"{col:10} {kinds}{' <- mixed' if len(kinds) > 1 else ''}")
{'id': 'str', 'mag': 'float64', 'place': 'str', 'geometry': 'geometry', 'time': 'datetime64[ms, UTC]', 'felt': 'Int64', 'tsunami': 'boolean', 'alert': 'string', 'networks': 'object', 'revision_delay': 'timedelta64[ms]', 'felt_mixed': 'object'}
networks {'list': 773}
felt_mixed {'str': 636, 'int': 137} <- mixed
An object column whose contents are all str is fine. One holding lists, dicts, bytes or a mixture is what gets mangled.
Serialise structured values
Lists and dicts have no field type in GeoPackage or shapefile, and pyogrio does not refuse them β it writes their printed form:
quakes[["id", "networks", "geometry"]].to_file("output/networks_raw.gpkg")
raw = gpd.read_file("output/networks_raw.gpkg")["networks"]
print(repr(raw.iloc[0]), "->", type(raw.iloc[0]).__name__)
try:
json.loads(raw.iloc[0])
except json.JSONDecodeError as err:
print("json.loads fails:", err)
quakes.assign(networks=quakes["networks"].map(json.dumps))[["id", "networks", "geometry"]].to_file("output/networks_json.gpkg")
print(gpd.read_file("output/networks_json.gpkg")["networks"].map(json.loads).iloc[0])
"['us']" -> str
json.loads fails: Expecting value: line 1 column 2 (char 1)
['us']
"['us']" uses single quotes, so it is not JSON; recovering it needs ast.literal_eval, and for anything but simple values not even that. Serialise with json.dumps() yourself and parse with json.loads() on the way back. GeoJSON is the exception: it stores the list as a real JSON array, and reads it back as one.
Fix mixed-type columns
A column holding both numbers and strings is written as text:
quakes[["id", "felt_mixed", "geometry"]].to_file("output/mixed.gpkg")
back = gpd.read_file("output/mixed.gpkg")["felt_mixed"]
print(quakes["felt_mixed"].dropna().map(type).value_counts().to_dict(), "->", back.dtype,
back[back != "not reported"].head(3).tolist())
{<class 'str'>: 636, <class 'int'>: 137} -> str ['1', '2', '2']
Every number is now a string. Decide one representation and enforce it: pd.to_numeric(col, errors="coerce") turns the text into NaN β check how many before accepting that β or .astype("string") keeps everything as text on purpose.
Convert datetimes and durations to what the format supports
GeoPackage stores datetimes, including the timezone; a shapefile's attribute table has no datetime field at all:
import warnings
for ext in ["gpkg", "shp"]:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
quakes[["id", "time", "geometry"]].to_file(f"output/time.{ext}")
back = gpd.read_file(f"output/time.{ext}")["time"]
print(f"{ext}: {back.dtype} {back.iloc[0]!r}", [str(w.message) for w in caught if "DateTime" in str(w.message)])
gpkg: datetime64[ms, UTC] Timestamp('2026-08-31 21:45:56.991000+0000', tz='UTC') []
shp: str '2026/08/31 21:45:56.991+00' ['Field time created as String field, though DateTime requested.']
In the shapefile the timestamp survives only as text in GDAL's own format. If a shapefile is unavoidable, write an explicit ISO 8601 string (.dt.strftime("%Y-%m-%dT%H:%M:%SZ")) so every reader parses it the same way.
A duration is the one type that is refused outright, in every format pyogrio writes:
try:
quakes[["id", "revision_delay", "geometry"]].to_file("output/delay.gpkg")
except NotImplementedError as err:
print("NotImplementedError:", err)
hours = quakes["revision_delay"].dt.total_seconds() / 3600
quakes.assign(revision_delay_h=hours)[["id", "revision_delay_h", "geometry"]].to_file("output/delay.gpkg")
print(gpd.read_file("output/delay.gpkg")["revision_delay_h"].describe()[["min", "50%", "max"]].round(1).to_dict())
NotImplementedError: field type is not supported timedelta64[ms] (field index: 1)
{'min': 115.9, '50%': 526.7, 'max': 1657.1}
Put the unit in the column name β once it is a plain number, nothing else records it.
Handle nullable dtypes and large integers
Pandas' nullable types (Int64, boolean, string) write without error; what comes back depends on the data:
quakes[["id", "felt", "tsunami", "alert", "geometry"]].to_file("output/nullable.gpkg")
back = gpd.read_file("output/nullable.gpkg")
print({c: f"{quakes[c].dtype} -> {back[c].dtype}" for c in ["felt", "tsunami", "alert"]})
{'felt': 'Int64 -> float64', 'tsunami': 'boolean -> bool', 'alert': 'string -> str'}
An Int64 column with missing values comes back as float64, because NumPy integers cannot hold NaN; the whole numbers are still exact. The integer precision problem is elsewhere β a shapefile has no 64-bit integer field:
big = quakes[["id", "geometry"]].assign(event_key=(2**62 + quakes.index).astype("int64"))
for ext in ["shp", "geojson", "gpkg", "parquet"]:
if ext == "parquet":
big.to_parquet("output/big.parquet")
back = gpd.read_parquet("output/big.parquet")["event_key"]
else:
big.to_file(f"output/big.{ext}")
back = gpd.read_file(f"output/big.{ext}")["event_key"]
off = max(abs(int(read) - int(wrote)) for read, wrote in zip(back, big["event_key"]))
print(f"{ext:8} {str(back.dtype):8} off by up to {off:>4}; distinct values {back.nunique()} of {len(big)}")
shp float64 off by up to 512; distinct values 2 of 773
geojson int64 off by up to 0; distinct values 773 of 773
gpkg int64 off by up to 0; distinct values 773 of 773
parquet int64 off by up to 0; distinct values 773 of 773
The shapefile stores the column as a floating-point number, which near 2βΆΒ² can only represent every 1,024th integer: the 773 different IDs came back as 2 distinct values, each up to 512 away from the original. A join on that column afterwards would match the wrong events. Write large IDs as strings when a shapefile is required.
Drop extra geometry columns
Merging two GeoDataFrames leaves a second geometry column, and to_file() refuses it with a clear message (sjoin() does not do this: it keeps only the left geometry):
epicentres = quakes[["id", "geometry"]]
with_buffer = epicentres.merge(epicentres.assign(geometry=epicentres.to_crs(3857).buffer(50_000).to_crs(4326)),
on="id", suffixes=("", "_zone"))
geometry_columns = [c for c in with_buffer.columns if with_buffer[c].dtype.name == "geometry"]
print("geometry columns:", geometry_columns)
try:
with_buffer.to_file("output/two.gpkg")
except ValueError as err:
print("ValueError:", str(err).split(". ")[0])
with_buffer.assign(geometry_zone=with_buffer["geometry_zone"].to_wkt()).to_file("output/two.gpkg")
print(gpd.read_file("output/two.gpkg").columns.tolist())
geometry columns: ['geometry', 'geometry_zone']
ValueError: GeoDataFrame contains multiple geometry columns but GeoDataFrame.to_file supports only a single geometry column
['id', 'geometry_zone', 'geometry']
Store the second geometry as WKT text, or write it as a second layer of the same GeoPackage.
Choose a format that fits the data
| Need | Format |
|---|---|
| Long text, datetimes, booleans, 64-bit integers, many layers | GeoPackage |
| Web delivery, nested attributes | GeoJSON |
| Large tables, lists, durations, pandas dtypes kept exactly | GeoParquet |
| A recipient who insists | Shapefile β expect truncation and conversions |
quakes.drop(columns="felt_mixed").to_parquet("output/quakes.parquet")
back = gpd.read_parquet("output/quakes.parquet")
for col in ["time", "felt", "networks", "revision_delay"]:
print(f"{col:15} {quakes[col].dtype} -> {back[col].dtype} {back[col].iloc[0]!r}")
time datetime64[ms, UTC] -> datetime64[ms, UTC] Timestamp('2026-08-31 21:45:56.991000+0000', tz='UTC')
felt Int64 -> Int64 <NA>
networks object -> object array(['us'], dtype=object)
revision_delay timedelta64[ms] -> timedelta64[ms] Timedelta('23 days 02:32:35.049000')
GeoParquet keeps the timezone, the nullable integer, the list and even the duration as they were, which makes it the right intermediate format inside a pipeline even when the final deliverable is something else. (A mixed-type column is the one thing it will not take β pyarrow refuses it.)
Writing a USGS earthquake catalogue to file
The data used on this page
Every earthquake of magnitude 4.5 or more in August 2026 from the USGS earthquake catalogue (the FDSN event service), as GeoJSON. The catalogue is revised as events are reviewed, so the setup reads a copy saved on 9 October 2026, and derives the kinds of columns an analysis typically produces:
timeβ the origin time as a timezone-aware UTC timestampfeltβ the number of "Did You Feel It?" reports, as a nullableInt64(most events have none)tsunamiβ the tsunami flag as a nullableboolean;alertβ the PAGER alert level asstringnetworksβ the contributing seismic networks, split from USGS's comma-separatedsourcesinto a listrevision_delayβ the time from the event to its latest catalogue update, atimedeltafelt_mixedβfeltwith gaps filled by the text"not reported", which mixes numbers and strings in one column
import json
import os
import geopandas as gpd
import pandas as pd
import requests
os.makedirs("data", exist_ok=True)
os.makedirs("output", exist_ok=True)
if not os.path.exists("data/quakes_2026_08.geojson"):
# the catalogue as USGS served it on 9 October 2026 (events are revised as they are reviewed); live:
# requests.get("https://earthquake.usgs.gov/fdsnws/event/1/query", timeout=120,
# params={"format": "geojson", "starttime": "2026-08-01", "endtime": "2026-09-01",
# "minmagnitude": 4.5})
reply = requests.get("https://data.spatialworkflow.io/usgs-quakes-m4.5-2026-08-2026-10-09.geojson",
timeout=120)
reply.raise_for_status()
open("data/quakes_2026_08.geojson", "wb").write(reply.content)
raw = gpd.read_file("data/quakes_2026_08.geojson")
quakes = raw[["id", "mag", "place", "geometry"]].copy()
quakes["time"] = pd.to_datetime(raw["time"], unit="ms", utc=True)
quakes["felt"] = raw["felt"].astype("Int64")
quakes["tsunami"] = raw["tsunami"].astype("boolean")
quakes["alert"] = raw["alert"].astype("string")
quakes["networks"] = raw["sources"].str.strip(",").str.split(",")
quakes["revision_delay"] = pd.to_datetime(raw["updated"], unit="ms", utc=True) - quakes["time"]
quakes["felt_mixed"] = quakes["felt"].astype(object).where(quakes["felt"].notna(), "not reported")
print(len(quakes), "events;", quakes["felt"].notna().sum(), "with felt reports;",
quakes["networks"].map(len).max(), "networks at most")
773 events; 137 with felt reports; 4 networks at most
Example 1: A schema report before the write
def schema_report(gdf):
rows = []
for col in gdf.columns.drop(gdf.geometry.name):
s = gdf[col]
kinds = sorted({type(v).__name__ for v in s.dropna()})
issue = ""
if s.dtype.name == "geometry":
issue = "second geometry column: to_file refuses"
elif pd.api.types.is_timedelta64_dtype(s):
issue = "timedelta: to_file refuses"
elif len(kinds) > 1:
issue = "mixed types: written as text"
elif kinds and kinds[0] in {"list", "dict", "set", "tuple", "bytes"}:
issue = f"{kinds[0]}: written as its repr β serialise it"
if len(col) > 10:
issue += ("; " if issue else "") + "name > 10 chars (shapefile truncates)"
rows.append({"column": col, "dtype": str(s.dtype), "issue": issue})
return pd.DataFrame(rows)
print(schema_report(quakes).to_string(index=False))
column dtype issue
id str
mag float64
place str
time datetime64[ms, UTC]
felt Int64
tsunami boolean
alert string
networks object list: written as its repr β serialise it
revision_delay timedelta64[ms] timedelta: to_file refuses; name > 10 chars (shapefile truncates)
felt_mixed object mixed types: written as text
Example 2: Audit the round trip
def round_trip(gdf, path):
gdf.to_file(path)
back = gpd.read_file(path)
changes = {}
for col in gdf.columns.drop(gdf.geometry.name):
if col not in back.columns:
changes[col] = "missing (renamed?)"
elif str(gdf[col].dtype) != str(back[col].dtype):
changes[col] = f"{gdf[col].dtype} -> {back[col].dtype}"
return changes
ready = prepare_for_write(quakes)
for path in ["output/audit.gpkg", "output/audit.shp"]:
print(path, round_trip(ready, path))
output/audit.gpkg {'felt': 'Int64 -> float64', 'tsunami': 'boolean -> bool', 'alert': 'string -> str'}
output/audit.shp {'time': 'datetime64[ms, UTC] -> str', 'felt': 'Int64 -> float64', 'tsunami': 'boolean -> bool', 'alert': 'string -> str', 'revision_delay_s': 'missing (renamed?)'}
Reading back what you wrote is the only way to see what a driver actually did with your schema β the write itself will not tell you, and the shapefile's column renames appear here as missing columns.
pandas dtypes versus file field types
A GeoDataFrame is a pandas DataFrame with a geometry column, so its columns carry pandas dtypes: NumPy types, extension types like Int64 and string, categoricals, timezone-aware datetimes, and the catch-all object, which can hold literally any Python value. A file format has nothing of the kind. OGR defines a small set of field types β integer, integer64, real, string, date, datetime, binary, and list variants that most drivers do not support β and each driver supports a subset of those.
Writing therefore means mapping a rich type system onto a poor one. Where the mapping is obvious β int64 to Integer64, float64 to Real, str to String β it happens silently and correctly. Where it is not, pyogrio mostly chooses a conversion rather than an error: text for lists, dicts, bytes and mixed columns, text for a shapefile's datetimes, a float for a shapefile's 64-bit integers, a cut-off string for a shapefile's long text. Only a duration and a second geometry column were refused in the tests on this page. That is the bad case, because your data changes without a message.
object columns are the biggest source of surprises, because pandas uses that dtype for anything it cannot represent natively. A column of strings is object; so is a column of lists, a column of dicts, and a column that got mixed types from a merge. The dtype alone tells you nothing, which is why the useful check is on the Python types inside the column rather than on the dtype itself.
The strategic answer is to raise the ceiling. Shapefile is the most constrained format in common use β 10-character field names, 254-character strings, no datetime or 64-bit integer field, one geometry type per file β and most of these conversions are really "you are writing a shapefile". GeoPackage removes almost all of those limits, and GeoParquet removes the rest while preserving pandas dtypes, lists and durations. Converting once at the pipeline boundary is far cheaper than teaching every step to work around the oldest format in the stack.
Integers with gaps, and other round-trip losses
NaNin an integer column: NumPy integers cannot holdNaN, so anInt64column with gaps comes back asfloat64. The whole numbers are exact up to 2β΅Β³; fill the gaps or cast back after reading if the type matters.- Field names over 10 characters: Shapefiles truncate them (
revision_delay_sbecomesrevision_d). Rename before writing, so you choose the short names. pyogrioandfionadiffer: The older Fiona engine wrote lists and bytes asNoneand the integers of a mixed column asNaNin the same tests β worse than pyogrio's text. Note which engine you use.- GeoJSON numbers: GDAL reads 64-bit integers back from GeoJSON exactly, but JavaScript readers hold numbers as doubles and lose precision above 2β΅Β³. Write large IDs as strings for the web.
- Categoricals: Drivers write the values as text, and they read back as plain strings.
bytescolumns: pyogrio writes them as their printed form,"b'β¦'". Base64-encode them, or use GeoParquet.- Empty frames: Writing a GeoDataFrame with zero rows creates a layer, but with no rows to infer from, object columns may get a different type than usual. Guard with a row-count check.
Internal links
- Why Are My Shapefile Column Names Truncated? How to Fix It
- How to Clean and Normalise Attribute Columns in a GeoDataFrame
- How to Read and Write GeoPackage Files in Python
- How to Validate a GeoDataFrame Against a Schema Before Analysis
- Mixed Geometry Types Error When Saving a GeoDataFrame: How to Fix It
- Garbled Attribute Text and UnicodeDecodeError from a Shapefile: How to Fix Encoding
FAQ
Which column is causing the error?
An error names at most a field index β (field index: 1) counts from the first attribute column. For the silent conversions, inspect the Python types inside each object column: gdf[col].dropna().map(type).value_counts(). Columns holding lists, dicts, bytes or a mixture of types are the usual culprits, and the dtype alone will not reveal them.
How do I store a list or dict attribute?
Serialise it to a JSON string with json.dumps() and parse it back after reading. Left alone, pyogrio writes Python's printed form ("['us']"), which is not JSON. GeoJSON stores lists natively and GeoParquet keeps them as lists; shapefiles truncate text at 254 characters and will corrupt a long payload.
Why did my timezone disappear?
A shapefile has no datetime field, so GDAL writes the timestamp as a text field (with a warning) and it reads back as a string. GeoPackage and GeoParquet keep the timezone. For a shapefile, write an explicit ISO 8601 string.
Can I write pandas nullable dtypes like Int64?
Yes β pyogrio writes Int64, boolean and string without complaint. An Int64 column with missing values reads back as float64, exact for whole numbers up to 2β΅Β³. GeoParquet reads it back as Int64.
Why does my write fail after a spatial join?
Usually it does not: sjoin() keeps only the left geometry. A merge() of two GeoDataFrames does leave a second geometry column, and to_file() refuses it with a ValueError. Drop it, or convert it to WKT text.
Should I still be writing shapefiles?
Only when a recipient requires them. GeoPackage removes the field-name, string-length, datetime, 64-bit integer and single-geometry-type limits at no cost, and GeoParquet preserves pandas dtypes, lists and durations.
How do I know what a write actually changed?
Read the file back and compare dtypes and column sets against the original frame. Drivers convert silently, so a round-trip check is the only reliable audit.