Profile with gdf.memory_usage(deep=True), then downcast numerics to 32-bit, turn repeated-string columns into category, and move free text to string[pyarrow] — this routinely cuts a points-of-interest layer by 80% while leaving geometry precision untouched.

Jump to heading Why this matters

A GeoDataFrame loaded straight from GeoJSON or PostGIS uses pandas’ widest default dtypes: int64 for every integer, float64 for every float, and boxed Python object for every string. On a 400,000-row layer that is hundreds of megabytes of resident memory before a single tile renders — and in a multi-user Streamlit or Panel app that cost is paid per session and per cached copy. Narrowing dtypes is the cheapest, safest optimisation available: it changes storage, not values, so maps and spatial joins produce identical results on a fraction of the RAM. This is the concrete procedure behind the dtype table in the GeoDataFrame Schema Reference, and it is what keeps cached layers under the ceilings in Memory Limit Management.

Jump to heading Prerequisites

  • Python 3.10+ with geopandas>=0.14, pandas>=2.1, and pyarrow>=14.
  • A GeoDataFrame you can profile — the example below uses a 400,000-row POI layer in EPSG:4326.
bash
pip install "geopandas>=0.14" "pandas>=2.1" "pyarrow>=14"
Per-column memory before and after dtype optimisationPaired horizontal bars for five columns of a 400,000-row GeoDataFrame. category as object 96 megabytes shrinks to 0.4 megabytes as a category dtype. name as object 88 megabytes shrinks to 26 megabytes as pyarrow string. poi_id int64 3.2 megabytes shrinks to 1.6 megabytes as int32. rating float64 3.2 megabytes shrinks to 1.6 megabytes as float32. geometry stays at 26 megabytes, deliberately unchanged to preserve coordinate precision.Per-column memory: before (outline) vs after (filled)category96 → 0.4 MBname88 → 26 MBpoi_id3.2 → 1.6 MBrating3.2 → 1.6 MBgeometry26 MB (kept)Total 320 MB → 38 MB · geometry left at full precisionBars not to absolute scale; labels carry the exact figures.

Jump to heading Step-by-step solution

Jump to heading Step 1 — Profile with memory_usage(deep=True)

Never optimise blind. deep=True walks object and geometry blocks so you see the real footprint, not the shallow pointer estimate.

python
import geopandas as gpd

gdf = gpd.read_file("pois_global.geojson")   # 400,000 rows, EPSG:4326
print(gdf.dtypes)
# poi_id        int64
# name         object
# category     object
# rating      float64
# geometry   geometry

usage = gdf.memory_usage(deep=True)
print(usage / 1e6)          # megabytes per column
print(f"Total: {usage.sum() / 1e6:.1f} MB")
# Total: 320.4 MB

The two object columns (name, category) dominate — as they almost always do. That tells you where the wins are before you change anything.

Jump to heading Step 2 — Downcast numeric columns

pd.to_numeric(..., downcast=...) picks the narrowest dtype that holds every value without loss.

python
import pandas as pd

gdf["poi_id"] = pd.to_numeric(gdf["poi_id"], downcast="integer")  # int64 -> int32
gdf["rating"] = pd.to_numeric(gdf["rating"], downcast="float")    # float64 -> float32

print(gdf["poi_id"].dtype, gdf["rating"].dtype)   # int32 float32

downcast="integer" also chooses int16 or int8 when the range allows, so a resolution or level column can collapse to a single byte per row.

Jump to heading Step 3 — Convert repeated strings to category

category is the big lever. When distinct values are few relative to row count, each value becomes a small integer code plus one dictionary entry.

python
n_unique = gdf["category"].nunique()
print(f"category cardinality: {n_unique} / {len(gdf)}")   # 14 / 400000

gdf["category"] = gdf["category"].astype("category")
print(gdf["category"].memory_usage(deep=True) / 1e6)      # ~0.4 MB (was ~96 MB)

A rule of thumb: convert when distinct values are below ~50% of the row count. Fourteen categories across 400,000 rows is a 200x reduction.

Jump to heading Step 4 — Use the pyarrow string dtype

name is high-cardinality, so category would not help. string[pyarrow] stores the text in one contiguous Arrow buffer instead of one boxed Python str per cell.

python
gdf["name"] = gdf["name"].astype("string[pyarrow]")
print(gdf["name"].memory_usage(deep=True) / 1e6)          # ~26 MB (was ~88 MB)

Arrow-backed columns also serialize straight into Parquet, so a layer optimised this way is already in the ideal form for a query result cache.

Downcasting is a claim about the data, not a compression trickThree columns are drawn against the range each candidate dtype can represent. A population count peaks in the tens of millions, well inside int32's two-billion ceiling, so int32 halves its memory and the values are unchanged. A rating between zero and five with one decimal place is represented exactly enough by float32, whose roughly seven significant digits far exceed what the column carries. A national identifier that already exceeds three billion does not fit int32 at all, and downcasting it silently wraps to a negative number rather than raising. The distinction the diagram is making is that a downcast is an assertion about the range the column will ever hold — not merely about the values it holds today — which is why it belongs in the schema next to the validation that enforces it, rather than in a one-off optimisation pass.WHAT THE COLUMN HOLDS AGAINST WHAT THE DTYPE CAN HOLDpopulation — peaks near 37,000,000int32 ceiling — 2.1 billionfits with room to spare — int32 halves the columnrating — 0.0 to 5.0, one decimalfloat32 carries ≈ 7 significant digitstwo of them are used — float64 is storing zerosnational_id — already above 3,000,000,000past the ceiling — the downcast wraps to a negativeand does not raise while doing itA downcast is a claim about the range the column will ever hold — not just today's values — so it belongs in the schema beside the check that enforces it. ### Step 5 — Keep geometry compact and precise

Do not touch coordinate precision to save memory. Reduce geometry cost only by simplifying display-only layers — which removes vertices, not decimal digits.

python
# Optional: for display layers only, drop vertices without losing CRS-level accuracy
gdf["geometry"] = gdf.geometry.simplify(0.0001, preserve_topology=True)

final = gdf.memory_usage(deep=True)
print(f"Total: {final.sum() / 1e6:.1f} MB")   # Total: 38.2 MB

Jump to heading Conversion savings table

The full before/after for the example layer:

ColumnBeforeAfterSavingsTechnique
category96.0 MB (object)0.4 MB (category)99.6%Categorical encoding
name88.0 MB (object)26.0 MB (string[pyarrow])70%pyarrow string
poi_id3.2 MB (int64)1.6 MB (int32)50%Integer downcast
rating3.2 MB (float64)1.6 MB (float32)50%Float downcast
geometry26.0 MB26.0 MB0% (intentional)Precision preserved
Total320.4 MB38.2 MB~88%

Jump to heading Applying it in one pass

Once you know the target per column, wrap the whole thing in a reusable function so every ingestion path produces the same slim frame. This is the function to call inside a cached loader:

python
import geopandas as gpd
import pandas as pd

def slim_dtypes(gdf: gpd.GeoDataFrame,
                categoricals: list[str],
                text_cols: list[str],
                cardinality_ratio: float = 0.5) -> gpd.GeoDataFrame:
    """Downcast numerics, categorize low-cardinality labels, Arrow-back text."""
    # Numerics: let pandas pick the narrowest safe width
    for col in gdf.select_dtypes("integer").columns:
        gdf[col] = pd.to_numeric(gdf[col], downcast="integer")
    for col in gdf.select_dtypes("float").columns:
        gdf[col] = pd.to_numeric(gdf[col], downcast="float")
    # Labels: only categorize when values actually repeat
    for col in categoricals:
        if gdf[col].nunique() < len(gdf) * cardinality_ratio:
            gdf[col] = gdf[col].astype("category")
    # Free text: contiguous Arrow buffer instead of boxed objects
    for col in text_cols:
        gdf[col] = gdf[col].astype("string[pyarrow]")
    return gdf   # geometry column deliberately untouched

gdf = slim_dtypes(gdf, categoricals=["category"], text_cols=["name"])

The cardinality_ratio guard is what stops the function from categorizing a name column by mistake — it falls back to leaving high-cardinality columns for the pyarrow pass.

What dtype work can and cannot reachThe same frame is drawn before and after a full dtype pass. Attribute columns fall from four hundred and ten megabytes to about a hundred and fifty-five — a sixty-two percent reduction, and everything the techniques on this page can achieve. Geometry stays at one thousand one hundred and forty megabytes in both bars, because no dtype exists that makes a coordinate array smaller. As a share of the frame the improvement is about eighteen percent. The conclusion drawn underneath is not that the dtype work is not worth doing — it costs one pass and removes a quarter of a gigabyte — but that a frame which is still too large after it needs a different lever entirely: fewer features through a bounding-box filter, or fewer vertices through simplification, both of which act on the band this chart shows to be untouched.THE SAME FRAME, BEFORE AND AFTER A FULL DTYPE PASSbefore410 MBgeometry — 1,140 MBafter155geometry — 1,140 MB, unchangedattribute columns: −62% · the whole frame: −18%Worth doing — it is one pass and it removes a quarter of a gigabyte. But a frame that is still too large afterwards needs adifferent lever, because no dtype makes a coordinate array smaller.Fewer features, via a bounding-box filter — or fewer vertices, via simplification. Both act on the band this chart shows untouched. ### Nullable integers when data is missing

A plain int32 column cannot hold a missing value; the moment a NaN appears pandas silently promotes it to float64, undoing your downcast. When a count or ID genuinely has gaps, use the nullable Int32 dtype (capital I) so the integer width — and the saving — survives the null:

python
gdf["visits"] = gdf["visits"].astype("Int32")   # holds <NA> without float coercion

Jump to heading Verification

Assert both the memory target and that values are unchanged.

python
# Capture originals BEFORE the casts above to compare against
original_categories = gpd.read_file("pois_global.geojson")["category"].tolist()

# Memory target met
total_mb = gdf.memory_usage(deep=True).sum() / 1e6
assert total_mb < 45, f"Still {total_mb:.1f} MB — check for stray object columns"

# Values preserved (dtype change must not alter data)
assert gdf["category"].astype("string").tolist() == original_categories
assert gdf.crs.to_epsg() == 4326          # CRS untouched
assert set(gdf.geom_type.unique()) == {"Point"}
print(f"Optimised to {total_mb:.1f} MB with values intact")

If a supposedly-optimised frame is still large, memory_usage(deep=True) will point straight at the remaining object column.

Jump to heading Edge cases and gotchas

  • category on high-cardinality columns backfires. If distinct values approach the row count, the dictionary plus the integer-code array is larger than a plain string[pyarrow] column. Always check nunique() first; reserve category for labels with genuine repetition.
  • float32 loses coordinate precision — never downcast geometry. float32 carries ~7 significant digits; at global longitude that is roughly metre-level, and reprojection amplifies the drift into visible misalignment. Downcast attribute floats only, and keep the geometry column at native double precision.
  • pyarrow interop is attribute-only. Arrow-backed attribute columns coexist with a normal geopandas GeoSeries, so spatial predicates and to_parquet keep working — but some third-party libraries still expect NumPy-backed columns, so cast back with .astype("float64") at the boundary of any library that errors on Arrow input.

Jump to heading FAQ

Does converting every string column to category save memory?

No. category only helps when values repeat. On a high-cardinality column such as name or address, the category dictionary approaches one entry per row and adds an integer-code array on top, so the column ends up larger than a plain string[pyarrow] column. Use category for low-cardinality labels and pyarrow strings for unique text — check nunique() before deciding.

Can I downcast coordinate columns to float32 to save memory?

Not for geometry coordinates. float32 holds about 7 significant digits, which at global scale limits longitude precision to roughly a metre and introduces visible drift after reprojection. Keep the geometry column at its native double precision and downcast only non-coordinate attribute columns such as ratings or counts.

Do pyarrow-backed dtypes interoperate with shapely and geopandas methods?

Yes for attribute columns. string[pyarrow] and Arrow-backed numeric columns work with pandas filtering, grouping, and to_parquet round-trips. The geometry column stays a native geopandas GeoSeries of shapely objects, so all spatial predicates keep working; only the surrounding attribute columns change dtype.


Back to GeoDataFrame Schema Reference

Related