How to Read GeoJSON in Python with GeoPandas

On this page

Problem statement

read_file loads nearly any GeoJSON in one call, and its failures are silent. Ward boundaries in metres load as EPSG:4326 with "longitudes" of 480,000 once the file loses its legacy crs member. A filter for Residential selects none of 2,405 land-use features. And 21,253 parcels take 0.62 s to read as GeoJSON against 0.08 s as GeoPackage.

Quick answer

To read a GeoJSON file in Python with GeoPandas, use geopandas.read_file():

import geopandas as gpd

gdf = gpd.read_file("data/rutland-wards.geojson")
print(type(gdf).__name__, len(gdf), "features,", gdf.crs)
GeoDataFrame 15 features, EPSG:4326
The structure of a GeoJSON file and how it maps onto a GeoDataFrame. GeoJSON → GeoDataFrameFeatureCollectionthe file→ the GeoDataFramefeatures[]the list→ the rowsproperties {}attributes→ ordinary columnsgeometry {}type + coordinates→ the geometry columncoordinates[lon, lat]always EPSG:4326GeoJSON stores longitude first — the opposite of how latitude is usually spoken spatialworkflow.io
Each level of the JSON corresponds to something familiar in the frame.

Read, check types and CRS, filter

GeoJSON geometry types and their Shapely equivalents. the seven geometry typesGeoJSON typeShapely classgeom_type showsPointPointPointLineStringLineStringLineStringPolygonPolygonPolygonMultiPolygonMultiPolygonMultiPolygonGeometryCollectionGeometryCollectionoften a problemA mixed-type file loads fine but breaks on write to shapefileGeoPandas reads them all β€” the question is what your later steps assume spatialworkflow.io
GeometryCollection is the one to watch; most operations do not handle it.

Read the GeoJSON file and preview it

Use read_file() to load the file into a GeoDataFrame, then check the first rows and the columns:

gdf = gpd.read_file("data/rutland-wards.geojson")
print(gdf.head(3))
print(list(gdf.columns))
      WD24CD  ...                                           geometry
0  E05012632  ...  POLYGON ((-0.72525 52.68542, -0.72494 52.68536...
1  E05012633  ...  POLYGON ((-0.75976 52.65781, -0.75971 52.6577,...
2  E05012634  ...  POLYGON ((-0.67132 52.75641, -0.67115 52.75611...

[3 rows x 3 columns]
['WD24CD', 'WD24NM', 'geometry']

Check geometry type and CRS

After reading the file, inspect geometry types and coordinate reference system:

print(gdf.geom_type.value_counts().to_dict())
print(gdf.crs)
print(gdf.total_bounds.round(4))
{'Polygon': 15}
EPSG:4326
[-0.8218 52.5247 -0.4284 52.7598]

The bounds, 0.43Β° to 0.82Β° W and 52.52Β° to 52.76Β° N, are where Rutland is, so the label is right. The last section reads a file where label and coordinates disagree.

Filter features after loading

A common next step is selecting records by attribute:

oakham = gdf[gdf["WD24NM"].str.startswith("Oakham")]
print(oakham[["WD24CD", "WD24NM"]].to_string(index=False))
   WD24CD            WD24NM
E05012641 Oakham North East
E05012642 Oakham North West
E05012643      Oakham South

Rutland wards from files, URLs, strings and lines

The data used on this page

Rutland's wards from the ONS Wards (December 2024) Boundaries UK BFC feature service (Office for National Statistics licensed under the Open Government Licence; contains OS data Β© Crown copyright and database right 2024), its land use from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL), and HM Land Registry's INSPIRE parcels (Β© Crown copyright and database rights; OGL), each written out as GeoJSON.

import json
import time
from pathlib import Path

import geopandas as gpd
import requests
from osgeo import gdal

gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
WARDS_URL = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
             "Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query"
             "?where=LAD24CD%3D%27E06000017%27&outFields=WD24CD,WD24NM&outSR=4326&f=geojson")
session = requests.Session()
if not Path("data/rutland-parcels.geojson").exists():
    Path("data/rutland-wards.geojson").write_bytes(session.get(WARDS_URL, timeout=120).content)
    Path("data/rutland.shp.zip").write_bytes(session.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
    gdal.VectorTranslate("data/land-use.geojson", "/vsizip/data/rutland.shp.zip/gis_osm_landuse_a_free_1.shp",
                         format="GeoJSON", SQLStatement="SELECT fclass AS use_type, name FROM gis_osm_landuse_a_free_1")
    Path("data/Rutland.zip").write_bytes(session.get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
    gdal.VectorTranslate("data/rutland-parcels.geojson", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
                         format="GeoJSON", dstSRS="EPSG:4326", reproject=True, SQLStatement="SELECT INSPIREID FROM PREDEFINED")
print({p.name: f"{p.stat().st_size / 1e6:.1f} MB" for p in sorted(Path("data").glob("*.geojson"))})
{'land-use.geojson': '1.8 MB', 'rutland-parcels.geojson': '39.8 MB', 'rutland-wards.geojson': '1.0 MB'}

Example 1: Check columns, geometry types, and CRS

land_use = gpd.read_file("data/land-use.geojson")

print("Columns:", list(land_use.columns))
print("Geometry types:", land_use.geom_type.unique())
print("CRS:", land_use.crs)
print("Missing geometries:", int(land_use.geometry.isna().sum()),
      "| invalid:", int((~land_use.is_valid).sum()))
Columns: ['use_type', 'name', 'geometry']
Geometry types: <ArrowStringArray>
['Polygon', 'MultiPolygon']
Length: 2, dtype: str
CRS: EPSG:4326
Missing geometries: 0 | invalid: 0

Unlike the wards, the land use mixes Polygon and MultiPolygon in one file: a feature in several pieces is a multipolygon, and GeoJSON keeps the distinction.

Example 2: Filter features after reading

residential = land_use[land_use["use_type"] == "residential"]
print(f"Selected {len(residential)} of {len(land_use)} features")
print(f"{residential.to_crs(27700).area.sum() / 1e6:.2f} kmΒ² of residential land")
print("'Residential' with a capital R matches", int((land_use["use_type"] == "Residential").sum()))
Selected 159 of 2405 features
15.94 kmΒ² of residential land
'Residential' with a capital R matches 0

Attribute values in GeoJSON are whatever the exporter wrote, and OpenStreetMap's are lower case; a filter copied from someone else's data silently selects nothing. The area is measured after reprojecting to British National Grid β€” in the file's degrees it would be meaningless.

Example 3: Read GeoJSON from a URL

start = time.perf_counter()
remote = gpd.read_file(WARDS_URL)
print(len(remote), "wards read straight from the ONS service in", f"{time.perf_counter() - start:.1f} s |",
      "same as the file:", remote.sort_values("WD24CD").geometry.reset_index(drop=True)
      .geom_equals_exact(gdf.sort_values("WD24CD").geometry.reset_index(drop=True), 1e-9).all())
15 wards read straight from the ONS service in 1.9 s | same as the file: True

This works when the URL returns GeoJSON directly β€” here an API query rather than a file β€” because GDAL fetches it over HTTP. A URL that returns an HTML page or an error message fails in the reader, not in the network.

Example 4: A raw GeoJSON string, and newline-delimited GeoJSON

raw = Path("data/rutland-wards.geojson").read_text()
from_string = gpd.GeoDataFrame.from_features(json.loads(raw)["features"], crs="EPSG:4326")
print("from a string:", len(from_string), "features; crs must be given:",
      gpd.GeoDataFrame.from_features(json.loads(raw)["features"]).crs)

gdal.VectorTranslate("data/rutland-wards.geojsonl", "data/rutland-wards.geojson", format="GeoJSONSeq")
print("GeoJSONSeq lines:", sum(1 for _ in open("data/rutland-wards.geojsonl")),
      "| read back:", len(gpd.read_file("data/rutland-wards.geojsonl")), "features")
from a string: 15 features; crs must be given: None
GeoJSONSeq lines: 15 | read back: 15 features

from_features builds a frame from parsed features, but it does not know the CRS unless you say so. Newline-delimited GeoJSON β€” one feature per line β€” is read by the same read_file; GDAL recognised the .geojsonl extension without a driver argument.

Example 5: How much slower is GeoJSON?

parcels = gpd.read_file("data/rutland-parcels.geojson")
parcels.to_file("data/rutland-parcels.gpkg")
parcels.to_parquet("data/rutland-parcels.parquet")

for path, reader in (("data/rutland-parcels.geojson", gpd.read_file),
                     ("data/rutland-parcels.gpkg", gpd.read_file),
                     ("data/rutland-parcels.parquet", gpd.read_parquet)):
    start = time.perf_counter()
    rows = len(reader(path))
    print(f"{Path(path).suffix:9} {Path(path).stat().st_size / 1e6:5.1f} MB  {time.perf_counter() - start:5.2f} s  {rows:,} parcels")
.geojson   39.8 MB   0.45 s  21,253 parcels
.gpkg      18.4 MB   0.04 s  21,253 parcels
.parquet   11.7 MB   0.05 s  21,253 parcels

For a file you read once, GeoJSON's cost is negligible. For one you read in every run of a pipeline, converting it once to GeoPackage or Parquet pays for itself.

Projected GeoJSON and the legacy crs member

Common problems reading GeoJSON and their fixes. when the read goes wrongSymptomFixJSONDecodeError on a valid-lookingfileIt is line-delimited β€” useGeoJSONSeqThe file is huge and slow to readConvert once to GeoPackage orParquetCoordinates are not lon/latNon-standard CRS β€” set_crs() whatit truly isNested properties become dictcolumnsFlatten with json_normalize first spatialworkflow.io
The third breaks the specification, but plenty of exporters do it anyway.

Ask the ONS service for British National Grid and it writes metres, with a pre-RFC 7946 crs member:

bng = requests.get(WARDS_URL.replace("outSR=4326", "outSR=27700"), timeout=120).json()
print("crs member in the file:", bng.get("crs"))
Path("data/wards-bng.geojson").write_text(json.dumps(bng))
print("read with its crs member:", gpd.read_file("data/wards-bng.geojson").crs)

bng.pop("crs", None)
Path("data/wards-bng-nocrs.geojson").write_text(json.dumps(bng))
unlabelled = gpd.read_file("data/wards-bng-nocrs.geojson")
print("same coordinates, no crs member:", unlabelled.crs, "| x range", unlabelled.total_bounds[[0, 2]].round(0))
crs member in the file: {'type': 'name', 'properties': {'name': 'EPSG:27700'}}
read with its crs member: EPSG:27700
same coordinates, no crs member: EPSG:4326 | x range [479688. 506296.]

GDAL honoured the member. Without it, the same coordinates were labelled EPSG:4326, and nothing objects until a map comes out empty. Correct the label with set_crs("EPSG:27700", allow_override=True) once the source confirms the true CRS.

If you are new to vector data in Python, start with GeoPandas basics for spatial vector data in Python.

For a similar file-reading workflow, see How to Read a Shapefile in Python with GeoPandas.

If you need the next step after loading, see How to Reproject Spatial Data in Python (GeoPandas).

If you need to save edited data back out, read How to Export GeoJSON in Python with GeoPandas.

FAQ

Can I read GeoJSON from a URL instead of a local file?

Yes, when the URL returns GeoJSON itself: Example 3 read the 15 wards straight from the ONS query URL in 0.4 s, identical to the saved file. A URL that answers with an HTML page or an error message fails in the reader, not in the network.

How do I read a single GeoJSON feature or a raw GeoJSON string in Python?

For an in-memory string, parse it with json.loads() and pass the result to gpd.GeoDataFrame.from_features(data["features"], crs="EPSG:4326") β€” without crs= the frame has no CRS at all. For a file on disk, gpd.read_file remains the simpler choice.

Why does my GeoJSON load with the CRS as EPSG:4326 even though my coordinates are projected?

The GeoJSON specification (RFC 7946) defines all coordinates as WGS84 longitude/latitude, so readers default to EPSG:4326 when the file declares nothing. Rutland's wards in British National Grid, with the legacy crs member removed, were read as EPSG:4326 with "longitudes" of 480,000. If the file actually holds projected coordinates, override the label with gdf.set_crs("EPSG:27700", allow_override=True) β€” only after confirming the true CRS.

How do I read GeoJSON that has mixed geometry types like points and polygons together?

gpd.read_file loads them into one GeoDataFrame, and gdf.geom_type.unique() will show several types. Split them with a filter such as gdf[gdf.geom_type == "Polygon"] before running operations that expect a single geometry type.

Can GeoPandas read newline-delimited GeoJSON (GeoJSONSeq) files?

Yes, through GDAL's GeoJSONSeq driver: gpd.read_file("data.geojsonl") recognised the extension without a driver argument. This format stores one feature per line and is common in streaming or large-export workflows.