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 object column containing lists, dicts, sets or bytes
  • a column of mixed types after a merge or a CSV read
  • a timedelta64 column, 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:

  1. print gdf.dtypes and the Python types inside every object column
  2. serialise structured values (lists, dicts) to JSON strings yourself
  3. convert timedelta to a number with a unit in the column name
  4. keep one geometry column
  5. 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

What shapefile, GeoPackage and GeoParquet did with five kinds of column, measured with pyogrio. the format sets the ceilingShapefileGeoPackageGeoParquetlong textβœ•cut at 254βœ“keptβœ“keptdatetime + tzβœ•text fieldβœ“kept, tz tooβœ“kept, tz too64-bit integerβœ•float: 774 β†’ 2βœ“exactβœ“exactlist!repr text!repr textβœ“kept as listtimedeltaβœ•refusedβœ•refusedβœ“keptUSGS earthquakes, August 2026: most of the damage is "you are writing a shapefile" spatialworkflow.io
Measured on the earthquake catalogue: shapefile is the lowest ceiling, GeoParquet the highest.

Find the column, then convert it to what the format stores

Normalising a GeoDataFrame schema before writing it to a file. convert in your code, not in the driver1print gdf.dtypesand object contents2serialise list and dictjson.dumps3cast nullable dtypesInt64 β†’ float644drop extra geometry columnsafter merging two GeoDataFrames5write, then read backaudit the round tripA driver that converts silently will not tell you what it changed spatialworkflow.io
Inspect, classify, convert, verify β€” the conversion belongs in your code, not in the driver.

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.

Distinct values left after writing 773 distinct 64-bit IDs near 2 to the 62nd to four formats and reading them back. 773 distinct IDs near 2⁢², after a round tripGeoPackage773GeoJSON (read by GDAL)773GeoParquet773Shapefile20386773The shapefile stored them as doubles, 1,024 apart at this size spatialworkflow.io
No error and no warning: the shapefile write turned 773 different IDs into two values.

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 timestamp
  • felt β€” the number of "Did You Feel It?" reports, as a nullable Int64 (most events have none)
  • tsunami β€” the tsunami flag as a nullable boolean; alert β€” the PAGER alert level as string
  • networks β€” the contributing seismic networks, split from USGS's comma-separated sources into a list
  • revision_delay β€” the time from the event to its latest catalogue update, a timedelta
  • felt_mixed β€” felt with 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.

Six things to_file did with awkward columns, from the message or the file, with their conversions. six write problemsWhat you seeConversionNotImplementedError: timedelta64total_seconds(), unit in the nameValueError: multiple geometrycolumnsDrop one, or store it as WKT"['us']" in the file, no errorjson.dumps() the column yourselfNumbers read back as stringsto_numeric, or astype("string")Field created as String, notDateTimeISO 8601 text, or use GeoPackageIDs changed in a shapefileWrite 64-bit IDs as stringsOnly the first two raise; the others at most warn, and show when you read back spatialworkflow.io
Two of these raise; four only show when you read the file back.

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

  • NaN in an integer column: NumPy integers cannot hold NaN, so an Int64 column with gaps comes back as float64. 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_s becomes revision_d). Rename before writing, so you choose the short names.
  • pyogrio and fiona differ: The older Fiona engine wrote lists and bytes as None and the integers of a mixed column as NaN in 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.
  • bytes columns: 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.

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.