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
Read, check types and CRS, filter
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
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.
Internal links
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.