GeoDataFrame Schema Reference
A GeoDataFrame is a pandas DataFrame with one or more geometry columns, exactly one of which is “active”, plus a .crs attribute describing how its coordinates map to the Earth. That extra structure is what lets a spatial dashboard reproject, filter by bounding box, and render a map — but it is also fragile. A careless merge silently drops the active-geometry pointer, a read_csv round-trip reduces every polygon to a string, and a wide default dtype turns a 400,000-row point layer into a gigabyte of resident memory. Schema discipline is what keeps a Streamlit or Panel dashboard fast and correct as data flows in from uploads, PostGIS, and Parquet caches.
This page is a working reference for the anatomy of a GeoDataFrame, the per-row memory cost of each column dtype, the full set of geometry types, and canonical schemas for the layer types most dashboards actually render. It then walks a five-step workflow to define, validate, and enforce a schema, covers categorical and pyarrow-backed optimisations, and lists the error messages you will hit when a schema breaks. It sits under Spatial Data Reference alongside the CRS & Coordinate Systems Reference, and the memory numbers below feed directly into Memory Limit Management.
Jump to heading Anatomy of a GeoDataFrame
Four pieces of state define a GeoDataFrame and every schema bug traces back to one of them: the index, the ordinary attribute columns, the geometry column(s), and the metadata that names the active geometry and holds the CRS.
- The index. By default a
RangeIndex(virtual, ~0 bytes). A materialisedint64index costs 8 bytes/row; anobjectindex of string keys costs far more and is a common, invisible source of bloat. - Attribute columns. Ordinary pandas columns. Their dtype is where almost all of the controllable memory lives, and where the table below applies.
- The geometry column(s). A
GeoSeriesof shapely objects. A frame may hold several (e.g.geometryplus acentroidcolumn), but only one is active — the one spatial methods and plots use by default. - Metadata. Two attributes:
_geometry_column_name(which column is active) and.crs(apyproj.CRS). Neither survives amerge, apd.concaton mismatched frames, or a CSV round-trip, which is why so many schema bugs surface as “no active geometry” or “CRS is None”.
Jump to heading Column dtype and per-row memory reference
This is the core table: pick the narrowest dtype that preserves the values you actually need. Byte costs are per row for the pandas storage block; object and category costs depend on cardinality and string length, so the figures are typical values for a dashboard-sized frame.
| Column role | Recommended dtype | Bytes/row | Notes |
|---|---|---|---|
| Row identifier (< 2.1 B) | int32 | 4 | Halves the int64 default; fine for feature IDs below ~2.1 billion |
| Row identifier (large / global) | int64 | 8 | Needed for OSM node IDs and Snowflake-style keys |
| Nullable integer | Int32 (pyarrow or pandas) | 4–5 | Use when the column has genuine missing values; avoids float coercion |
| Continuous metric | float32 | 4 | Halves float64; ~7 significant digits — fine for ratings, populations |
| High-precision metric | float64 | 8 | Keep for money, cumulative sums, anything needing 15 digits |
| Low-cardinality label | category | ~1–2 + dict | POI type, admin level, status — huge win when values repeat |
| Free text / high-cardinality | string[pyarrow] | ~4 + chars | Names, addresses; pyarrow strings avoid per-object Python overhead |
| Legacy text | object | ~50+ | Default for strings; boxed Python str per cell — avoid at scale |
| Boolean flag | bool | 1 | Use boolean (nullable) only if missing values are meaningful |
| Timestamp | datetime64[ns] | 8 | For GPS tracks and time sliders; store UTC, localise at render |
| H3 cell index | uint64 or category | 8 / ~2 | uint64 for math on the index, category if hexbins repeat across rows |
| Active geometry | geometry | ~8 + GEOS | Column reports the pointer; true cost is vertex-driven and out-of-band |
The single highest-leverage change on most frames is turning repeated-string columns into category and downcasting int64/float64 to 32-bit — the full procedure lives in choosing column dtypes to reduce GeoDataFrame memory.
Jump to heading Geometry types and nullability
The active geometry column should hold a single geometry type. Mixed types (some Polygon, some MultiPolygon) are legal in shapely but break many renderers and every strict schema check. Promote to the Multi* variant when in doubt.
| Geometry type | Typical dashboard use | Vertices | Renderer notes |
|---|---|---|---|
Point | POI markers, sensor locations, geocoded addresses | 1 | Cheapest; cluster at high counts |
LineString | GPS tracks, routes, network edges | 2–N | Simplify before rendering long tracks |
Polygon | Admin boundaries, parcels, catchments | ring(s) | Holes stored as interior rings |
MultiPoint | Grouped observations under one feature ID | N | Rare; usually flatten to Point rows |
MultiLineString | Multi-segment routes, rivers with branches | N | Common output of network splits |
MultiPolygon | Countries with islands, multi-part parcels | N | Promote mixed Polygon/MultiPolygon layers to this |
GeometryCollection | Mixed outputs of overlay/clip ops | N | Avoid persisting; explode or filter before storing |
Nullability matters: geopandas allows a missing geometry (None) and an empty geometry (POLYGON EMPTY), and they behave differently. gdf.geometry.isna() catches None; gdf.geometry.is_empty catches the empty case. A production schema should forbid both in the active geometry column unless the dashboard explicitly handles “no location” rows. Attribute columns are a separate question: a rating may legitimately be missing, but a plain float32 column represents that as NaN, which silently coerces integer columns to float. When missing values are meaningful, reach for the nullable Int32/Float32 dtypes so a null stays a null and the integer type survives.
The active-geometry rule also governs derived columns. Computing gdf["centroid"] = gdf.geometry.centroid adds a second geometry column; it does not change which one is active. Plotting or exporting still uses the original geometry column until you call set_geometry("centroid"). Keeping one column canonical — and treating extra geometry columns as scratch — avoids a whole class of “the map rendered the wrong shapes” bugs.
Jump to heading Canonical schemas for common dashboard layers
These are the schemas to reach for when you design a new layer. They balance memory against the attributes a map actually needs.
| Layer | Columns → dtype | Geometry | CRS |
|---|---|---|---|
| Points of interest | poi_id:int32, name:string[pyarrow], category:category, rating:float32 | Point | EPSG:4326 |
| Choropleth polygons | area_id:int32, name:string[pyarrow], metric:float32, pop:int32 | MultiPolygon | EPSG:4326 (display), EPSG:3857 (tiles) |
| GPS tracks | track_id:int32, ts:datetime64[ns], speed:float32 | LineString | EPSG:4326 |
| H3 hexbins | h3:uint64, resolution:int8, count:int32, density:float32 | Polygon | EPSG:4326 |
Choropleth work usually renders in Web Mercator; the reprojection details live in the CRS & Coordinate Systems Reference. Point and polygon layers are almost always shipped to the browser as GeoJSON in EPSG:4326 — see converting shapely geometries to GeoJSON for web maps.
Jump to heading Prerequisites
- Python 3.10+ with
geopandas>=0.14,shapely>=2.0, andpandas>=2.1(pyarrow-backed dtypes are stable from 2.1). pyarrow>=14forstring[pyarrow]columns and Parquet I/O.pandera>=0.18(optional) for declarative schema validation; the manual-assertion path below needs no extra dependency.- Familiarity with EPSG codes and the CRS attribute, covered in the CRS & Coordinate Systems Reference.
pip install "geopandas>=0.14" "shapely>=2.0" "pandas>=2.1" "pyarrow>=14" pandera
Jump to heading Core implementation workflow
Jump to heading Step 1 — Declare the target schema
Write the schema as data before you touch a file. A dictionary is enough and doubles as documentation.
TARGET_SCHEMA = {
"poi_id": "int32",
"name": "string[pyarrow]",
"category": "category",
"rating": "float32",
}
TARGET_GEOM_TYPE = "Point"
TARGET_CRS = "EPSG:4326"
Jump to heading Step 2 — Coerce columns to their declared dtypes
Load, then cast. Casting on load is what stops a read_csv or a PostGIS numeric from silently arriving as object or float64.
import geopandas as gpd
def coerce_dtypes(gdf: gpd.GeoDataFrame, schema: dict) -> gpd.GeoDataFrame:
"""Cast each attribute column to its declared dtype; leave geometry untouched."""
for column, dtype in schema.items():
if column not in gdf.columns:
raise KeyError(f"Missing required column: {column!r}")
gdf[column] = gdf[column].astype(dtype)
return gdf
raw = gpd.read_file("pois.geojson") # arrives as int64/object/float64
gdf = coerce_dtypes(raw, TARGET_SCHEMA)
Jump to heading Step 3 — Set the active geometry and CRS
After any reshape you must re-assert which column is active and what its CRS is. set_geometry fixes the active-geometry pointer; set_crs attaches the CRS without reprojecting (use to_crs when you actually want to transform coordinates).
gdf = gdf.set_geometry("geometry")
if gdf.crs is None:
gdf = gdf.set_crs(TARGET_CRS) # label only — no coordinate change
elif gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(TARGET_CRS) # actual reprojection
Jump to heading Step 4 — Validate with pandera or manual assertions
The manual path needs no dependency and covers the four failure modes that matter: wrong dtype, mixed geometry, null geometry, wrong CRS.
def validate_schema(gdf: gpd.GeoDataFrame, schema: dict,
geom_type: str, crs: str) -> gpd.GeoDataFrame:
for column, dtype in schema.items():
actual = str(gdf[column].dtype)
assert actual == dtype, f"{column}: expected {dtype}, got {actual}"
assert gdf.geometry.notna().all(), "Active geometry contains null rows"
assert not gdf.geometry.is_empty.any(), "Active geometry contains empty rows"
kinds = set(gdf.geom_type.unique())
assert kinds == {geom_type}, f"Mixed geometry types: {kinds}"
assert gdf.crs is not None and gdf.crs.to_epsg() == int(crs.split(":")[1]), \
f"CRS mismatch: {gdf.crs}"
return gdf
gdf = validate_schema(gdf, TARGET_SCHEMA, TARGET_GEOM_TYPE, TARGET_CRS)
The equivalent with pandera reads as a declarative contract and produces richer error reports:
import pandera.pandas as pa
from pandera.typing.geopandas import GeoSeries
class PoiSchema(pa.DataFrameModel):
poi_id: int = pa.Field(ge=0)
name: str
category: str = pa.Field(isin=["cafe", "park", "shop", "museum"])
rating: float = pa.Field(in_range={"min_value": 0.0, "max_value": 5.0})
geometry: GeoSeries
class Config:
coerce = True
validated = PoiSchema.validate(gdf)
Jump to heading Step 5 — Enforce the schema at every ingestion boundary
Wrap validation around every entry point — file upload, PostGIS read, cached Parquet — so a bad frame fails at the door rather than three widgets later.
import streamlit as st
@st.cache_data(ttl=3600, show_spinner="Validating layer…")
def load_poi_layer(path: str) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path)
gdf = coerce_dtypes(gdf, TARGET_SCHEMA)
gdf = gdf.set_geometry("geometry")
if gdf.crs is None:
gdf = gdf.set_crs(TARGET_CRS)
return validate_schema(gdf, TARGET_SCHEMA, TARGET_GEOM_TYPE, TARGET_CRS)
Jump to heading Advanced patterns
Jump to heading Categorical encoding for repeated labels
When a string column has few distinct values relative to its length — a POI category, an admin level, a status — category replaces per-row string storage with a small integer code plus a one-time dictionary.
# 400,000 POIs, 12 distinct categories
gdf["category"] = gdf["category"].astype("category")
# object column ~24 MB -> category ~0.4 MB (int8 codes + tiny dict)
Do not apply this to a name column: high cardinality means the dictionary approaches one entry per row and the code array is pure overhead.
Jump to heading pyarrow-backed strings
For genuinely high-cardinality text, string[pyarrow] stores values in a contiguous Arrow buffer instead of boxed Python objects, cutting both memory and serialization time.
gdf["name"] = gdf["name"].astype("string[pyarrow]")
gdf["address"] = gdf["address"].astype("string[pyarrow]")
Arrow-backed columns also round-trip through to_parquet without conversion, which makes them the right choice for cached layers.
Jump to heading Downcasting numerics
Let pandas pick the narrowest safe integer/float dtype rather than guessing.
import pandas as pd
gdf["pop"] = pd.to_numeric(gdf["pop"], downcast="integer") # int64 -> int32/int16
gdf["metric"] = pd.to_numeric(gdf["metric"], downcast="float") # float64 -> float32
Jump to heading WKB storage for cache payloads
When a frame is a cache artefact rather than a live working set, replace live shapely objects with Well-Known Binary. WKB is compact, version-stable across shapely releases, and stores cleanly in Redis or Parquet.
# Serialize geometry to WKB bytes for a Redis/Parquet cache
gdf["geom_wkb"] = gdf.geometry.to_wkb()
plain = gdf.drop(columns="geometry") # now an ordinary DataFrame
# Rehydrate only when spatial operations are needed
from geopandas import GeoSeries
geom = GeoSeries.from_wkb(plain["geom_wkb"], crs="EPSG:4326")
restored = gpd.GeoDataFrame(plain.drop(columns="geom_wkb"), geometry=geom)
This is the storage form that keeps cached GeoDataFrames small under the ceilings described in Memory Limit Management.
Jump to heading Recovering a schema from different sources
The dtype a column arrives with depends entirely on where it came from, so schema enforcement must adapt to the source:
# GeoJSON / GeoPackage via read_file — geometry and CRS survive,
# but integers arrive as int64 and strings as object.
gdf = gpd.read_file("layer.gpkg")
# PostGIS via read_postgis — geometry and CRS come from the geometry column's
# SRID; numeric precision follows the SQL column type.
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg2://user:pass@db:5432/gis")
with engine.connect() as conn:
gdf = gpd.read_postgis(text("SELECT * FROM pois"), conn, geom_col="geom")
# CSV / Parquet with a WKT or WKB geometry column — geometry is a *string*
# until you rebuild it, and the CSV path loses the CRS entirely.
import pandas as pd
df = pd.read_csv("pois.csv")
gdf = gpd.GeoDataFrame(
df, geometry=gpd.GeoSeries.from_wkt(df["geometry_wkt"]), crs="EPSG:4326"
)
The lesson is that only read_file and read_postgis restore geometry and CRS automatically; every tabular round-trip (CSV, and CSV-backed uploads) drops the CRS and demands an explicit set_crs. Route each source through the same coerce_dtypes + validate_schema pair from the workflow below so the frame is identical downstream regardless of origin.
Jump to heading Schema-stable Parquet caching
GeoParquet is the one on-disk format that preserves the full schema — dtypes, geometry type, and CRS — byte-for-byte across reloads. Writing a validated frame to Parquet and reading it back yields an identical schema without re-coercion, which is why it is the right cache and interchange format:
gdf.to_parquet("pois.parquet") # embeds CRS + dtypes in metadata
restored = gpd.read_parquet("pois.parquet")
assert str(restored["category"].dtype) == "category"
assert restored.crs.to_epsg() == 4326
Jump to heading Verification and testing
Confirm both the schema and the memory profile after loading.
gdf = load_poi_layer("pois.geojson")
# Schema assertions
assert gdf.geometry.name == "geometry"
assert set(gdf.geom_type.unique()) == {"Point"}
assert gdf.crs.to_epsg() == 4326
assert str(gdf["category"].dtype) == "category"
# Memory profile — deep=True walks object/geometry blocks
usage = gdf.memory_usage(deep=True)
print(usage)
total_mb = usage.sum() / 1e6
print(f"Total: {total_mb:.1f} MB across {len(gdf):,} rows")
assert total_mb < 60, "Layer larger than expected — check for object dtypes"
memory_usage(deep=True) is the one measurement that catches a stray object column; a wide layer that “should” be small almost always has one.
Jump to heading Troubleshooting
AttributeError: 'DataFrame' object has no attribute 'crs' : A pandas operation (merge, groupby().apply, pd.concat) returned a plain DataFrame. Re-wrap: gdf = gpd.GeoDataFrame(df, geometry="geometry", crs="EPSG:4326"). The crs argument is required because the metadata was lost in the operation.
ValueError: Cannot set a GeoDataFrame without a geometry (or You are calling a geospatial method on the GeoDataFrame, but the active geometry column ... is not present) : No column is registered as active. This follows a rename or a column selection that dropped geometry. Fix with gdf = gdf.set_geometry("geometry"), and confirm the column actually exists first.
Mixed geometry types: {'Polygon', 'MultiPolygon'} (raised by the validation in Step 4) : The source mixes single and multi parts. Promote everything: from shapely.geometry import MultiPolygon; gdf["geometry"] = [g if g.geom_type == "MultiPolygon" else MultiPolygon([g]) for g in gdf.geometry].
CRS is None after read_file or read_postgis : The source had no CRS metadata. If you know the coordinates are already lon/lat, label them with gdf.set_crs("EPSG:4326"). Never use to_crs here — it would reproject from an unknown source and scramble coordinates.
Memory balloons after a join : The join reintroduced an object string index or an int64 key. Run gdf.memory_usage(deep=True) to find the block, then re-apply category/int32/string[pyarrow] casts from the dtype table above.
TypeError: Cannot interpret 'string[pyarrow]' as a data type from a third-party plotting or analysis call : Some libraries still expect NumPy-backed columns. Cast the offending attribute column back at the boundary with gdf["name"] = gdf["name"].astype("object") (or .astype("float64") for numerics) just before the call; keep the Arrow dtype everywhere else.
Integer column silently became float64 : A missing value (NaN) was introduced — usually by a left join that did not match every row. NaN is a float, so pandas promoted the whole column. Switch to the nullable Int32 dtype, which represents missing values as <NA> without abandoning the integer width.
Jump to heading Performance considerations
Profile before optimising. memory_usage(deep=True) is authoritative for attribute columns; geometry memory is vertex-driven, so simplify with gdf.geometry.simplify(0.0001, preserve_topology=True) for display-only layers rather than chasing dtype savings on the geometry column.
Schema stability beats micro-tuning. A frame whose dtypes are locked and validated at ingestion caches deterministically, because to_parquet output is byte-stable — which is exactly what deterministic query result caching needs for reliable cache keys.
Index dtype is invisible bloat. A materialised object index of string keys can outweigh every other column. Prefer a RangeIndex or an int32 index and set string keys as a normal category column instead.
Validate once, at ingestion — not per render. Schema checks that walk every geometry are not free; running validate_schema on each Streamlit rerun re-scans the whole frame. Do it inside the cached loader (Step 5) so the cost is paid once per TTL, and let downstream widgets trust the frame. This mirrors the session isolation principle: expensive, deterministic work belongs behind the cache boundary, not in the interaction path.
Back to Spatial Data Reference
Related
- CRS & Coordinate Systems Reference — EPSG codes, the CRS attribute, and reprojection that a schema depends on
- Choosing column dtypes to reduce GeoDataFrame memory — the step-by-step downcasting and categorical-encoding procedure
- Converting shapely geometries to GeoJSON for web maps — shipping a validated layer to Folium, Leaflet, or Deck.gl
- Memory Limit Management — container ceilings that the per-row memory figures here feed into