Remark

Please be aware that these lecture notes are accessible online in an ‘early access’ format. They are actively being developed, and certain sections will be further enriched to provide a comprehensive understanding of the subject matter.

3.1. Geospatial File Formats: Foundations and GeoJSON#

3.1.1. Learning Objectives#

By the end of this section, you should be able to:

  • Explain why file format choice affects interoperability, performance, and data fidelity.

  • Distinguish the format used to receive data from the format used for analysis or delivery.

  • Describe the structure of a GeoJSON FeatureCollection, its geometry types, and its fixed coordinate reference system.

  • Read, inspect, map, and write GeoJSON files in Python using GeoPandas.

  • Recognize a file that loads as empty, and compare what a file contains with what was loaded.

  • Write GeoJSON that follows the standard (WGS 84, longitude-latitude order), and avoid common writing pitfalls.

  • Identify common GeoJSON data-quality issues (placeholder coordinates, null geometries, duplicates, outliers) before analysis begins.

Section Goal

Geospatial data arrives in a bewildering variety of file formats. This section opens a structured tour of the formats you will encounter most often in practice — what they look like on disk, how they encode geometry and attributes, where they excel, and where they fall short. It starts with the format-selection mindset and with GeoJSON, the lightweight, web-native format you will meet constantly when working with open-data portals and APIs.

3.1.2. Why Formats Matter#

Every geospatial file format is a design trade-off. The choices made during design—text vs. binary, single file vs. multi-file, open standard vs. proprietary—have downstream consequences for interoperability, performance, file size, and the richness of data that can be stored. Choosing the wrong format for a workflow can introduce subtle errors (attribute field names silently truncated to ten characters by Shapefiles), performance problems (reading an entire file to extract a single polygon), or lock-in to proprietary software.

Understanding formats is therefore not just a technical detail—it is a prerequisite for building robust, reproducible geospatial pipelines. The next four sections each focus on one format family; this section covers the mindset that ties them together and the format you will meet the most on the open web: GeoJSON.

Table 3.1 Format Design Choices and Their Consequences#

Design choice

Typical options

Consequence

Encoding

Text (GeoJSON) or binary (GeoPackage)

Readability and web-friendliness versus file size and speed

Packaging

One file or several (Shapefile)

Easy sharing versus components that can be separated or lost

Standard

Open specification or proprietary

Long-term access and wide tool support versus lock-in

Schema

Enforced (databases, GeoPackage) or free-form (GeoJSON)

Consistent types versus flexibility that can hide surprises

3.1.2.1. Format, Storage, and Delivery Are Different Choices#

The format used to receive data does not have to be the format used for analysis or delivery. A common workflow is to preserve the original download, convert it to a GeoPackage for analysis, and export a filtered GeoJSON layer for a web map. Keeping the original file and recording the conversion steps makes the process auditable.

3.1.3. GeoJSON#

GeoJSON is a lightweight, text-based format that encodes geographic features using JavaScript Object Notation (JSON). Defined by RFC 7946 (2016) [Butler et al., 2016], it has become the de facto standard for web-based geospatial data exchange. Because it is plain JSON, any programming language can read it without a GIS library, and you can open it in a text editor to see exactly what it contains.

3.1.3.1. Structure#

A GeoJSON document is a FeatureCollection that wraps an array of Feature objects. Each Feature has three components:

  1. type: The string "Feature".

  2. geometry: An object specifying the geometry type and its coordinates.

  3. properties: A JSON object holding any number of attribute key-value pairs.

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [-93.2650, 44.9778]
      },
      "properties": {
        "name": "Minneapolis",
        "population": 429954
      }
    }
  ]
}

Three details are worth noticing:

  • Every object declares its kind with a type member. This is not decoration: GIS software relies on it to recognize features, and a file whose features omit it can load as an empty layer (see A GeoJSON File That Loads as Empty below).

  • Properties are free-form. GeoJSON has no schema, so each feature may carry different keys and value types. Software such as GDAL and GeoPandas infers column types by scanning the data.

  • Some members are optional. A feature may carry an id and a bbox (bounding box), and its geometry may be null for records that have attributes but no known location.

The next cell builds this exact example in Python, writes it to disk, and reads it two ways: as plain JSON, and as a GeoDataFrame with GeoPandas [Jordahl et al., 2020]. Notice how properties become columns and geometry becomes the geometry column.

Hide code cell source

import json
from pathlib import Path

import geopandas as gpd

DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)

minneapolis = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "geometry": {"type": "Point", "coordinates": [-93.2650, 44.9778]},
            "properties": {"name": "Minneapolis", "population": 429954},
        }
    ],
}
path = DATA_DIR / "minneapolis.geojson"
path.write_text(json.dumps(minneapolis, indent=2))

# 1. As plain JSON: GeoJSON is just text, so any JSON tool can read it
raw = json.loads(path.read_text())
print(raw["type"], "with", len(raw["features"]), "feature(s)")
print("Geometry  :", raw["features"][0]["geometry"])
print("Properties:", raw["features"][0]["properties"])
print()

# 2. As a GeoDataFrame: properties become columns, geometry becomes the geometry column
gdf = gpd.read_file(path)
display(gdf)
FeatureCollection with 1 feature(s)
Geometry  : {'type': 'Point', 'coordinates': [-93.265, 44.9778]}
Properties: {'name': 'Minneapolis', 'population': 429954}
name population geometry
0 Minneapolis 429954 POINT (-93.265 44.9778)

3.1.3.2. Geometry Types#

GeoJSON supports seven geometry types. The nesting depth of the coordinates array tells you which one you are looking at: a single position for a point, an array of positions for a line, an array of rings for a polygon, and so on.

Table 3.2 GeoJSON Geometry Types and Their Coordinate Structure#

Type

Coordinates

Typical use

Point

One position: [lon, lat]

A city

MultiPoint

Array of positions

A set of monitoring stations

LineString

Array of two or more positions

A road

MultiLineString

Array of LineStrings

A river with its branches

Polygon

Array of rings: the exterior ring first, then any holes

A county

MultiPolygon

Array of Polygons

An island chain

GeometryCollection

A mixed array of geometries

Rarely used; usually avoided

A few rules, set out in RFC 7946 [Butler et al., 2016], govern polygons and positions:

  • Rings are closed: the first and last positions of a ring are identical.

  • Winding order: RFC 7946 says exterior rings should run counterclockwise and holes clockwise (the right-hand rule), although readers are expected to accept either.

  • Positions may carry a third value, an elevation in meters.

  • The antimeridian: the standard recommends cutting geometries that cross the 180° meridian into separate parts, so they do not stretch across the whole map.

Unlike a Shapefile, one GeoJSON layer may mix types, for example Polygon and MultiPolygon features side by side; we will see this in the country boundaries below. The cell shows how a polygon with a hole is encoded: an outer ring followed by an inner ring.

Hide code cell source

from shapely.geometry import Polygon, mapping

square_with_hole = Polygon(
    shell=[(0, 0), (4, 0), (4, 4), (0, 4)],
    holes=[[(1, 1), (1, 2), (2, 2), (2, 1)]],
)

print(json.dumps(mapping(square_with_hole)))          # the GeoJSON geometry object
print("Exterior ring counterclockwise:", square_with_hole.exterior.is_ccw)
print("Hole ring counterclockwise    :", square_with_hole.interiors[0].is_ccw)
{"type": "Polygon", "coordinates": [[[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]], [[1.0, 1.0], [1.0, 2.0], [2.0, 2.0], [2.0, 1.0], [1.0, 1.0]]]}
Exterior ring counterclockwise: True
Hole ring counterclockwise    : False

The output shows the nesting directly: the first inner array is the exterior ring and the second is the hole, and each ring repeats its first position at the end to close it. We built the rings to follow the right-hand rule, and is_ccw confirms it: counterclockwise for the exterior (True), clockwise for the hole (False).

3.1.3.3. Coordinate Reference System#

GeoJSON always uses WGS 84 (EPSG:4326) with coordinates in decimal degrees ordered as [longitude, latitude]. This is a deliberate simplification to maximize interoperability—but it means you must reproject to a metric CRS before performing distance or area calculations.

Two consequences follow:

  • Longitude comes first. Positions are [x, y], which is the reverse of the “latitude, longitude” order people usually say aloud. Swapping them produces valid numbers in the wrong place, with no error message.

  • Coordinates are angles, not lengths. Distances and areas computed directly on GeoJSON coordinates are in degrees and are not meaningful.

An earlier version of the specification (2008) allowed a crs member to declare other systems. RFC 7946 removed it [Butler et al., 2016], but older files, and some software, still write one. We will see this when we write files later in this section.

3.1.3.4. Reading GeoJSON in Python#

To keep the examples reproducible, we use two public-domain layers from Natural Earth [Natural Earth contributors, 2026], published as GeoJSON in the project’s GitHub repository.

Table 3.3 Natural Earth Layers Used in This Section#

Layer

Geometry

Features

Fields used

ne_110m_populated_places_simple

Point

243

name, adm0name, adm0cap (1 if a national capital), pop_max

ne_110m_admin_0_countries

Polygon and MultiPolygon

177

NAME, CONTINENT, POP_EST

The first cell downloads the files (only if they are not already on disk) and reads them with read_file, which detects the format automatically. It then prints the checks you should always make after reading a GeoJSON file: how many features loaded, which CRS, and which geometry types.

Hide code cell source

import urllib.request

BASE = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/"
LAYERS = ["ne_110m_populated_places_simple", "ne_110m_admin_0_countries"]


def download(url, target):
    """Download a file once; reuse the local copy afterwards."""
    if not target.exists():
        request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(request) as response:
            target.write_bytes(response.read())
    return target


for name in LAYERS:
    download(BASE + name + ".geojson", DATA_DIR / f"{name}.geojson")

places = gpd.read_file(DATA_DIR / "ne_110m_populated_places_simple.geojson")
countries = gpd.read_file(DATA_DIR / "ne_110m_admin_0_countries.geojson")

print(f"places   : {len(places):>4} features, CRS {places.crs}, geometry {places.geom_type.unique().tolist()}")
print(f"countries: {len(countries):>4} features, CRS {countries.crs}, geometry {countries.geom_type.value_counts().to_dict()}")
places   :  243 features, CRS EPSG:4326, geometry ['Point']
countries:  177 features, CRS EPSG:4326, geometry {'Polygon': 148, 'MultiPolygon': 29}

Hide code cell source

# GeoJSON has no schema: column types were inferred from the values
print(places[["name", "adm0cap", "pop_max"]].dtypes)
print()
places[["name", "adm0name", "adm0cap", "pop_max", "geometry"]].head()
name         str
adm0cap    int32
pop_max    int32
dtype: object
name adm0name adm0cap pop_max geometry
0 Vatican City Vatican 1 832 POINT (12.45339 41.90328)
1 San Marino San Marino 1 29579 POINT (12.44177 43.9361)
2 Vaduz Liechtenstein 1 36281 POINT (9.51667 47.13372)
3 Lobamba eSwatini 0 9782 POINT (31.2 -26.46667)
4 Luxembourg Luxembourg 1 107260 POINT (6.13 49.61166)

Both layers report EPSG:4326, as GeoJSON requires. The countries mix 148 Polygon and 29 MultiPolygon features (countries made of several parts, such as island nations), the mixed geometry types noted earlier. The column types of places were inferred from the values: text for names, integers for the capital flag and population. The preview follows file order rather than rank, which is why it opens with Vatican City (832 people): it is a peek at the structure, not a summary.

A first map is the quickest quality check: it confirms that the data cover the expected area and that nothing is wildly out of place. Here the country polygons form the backdrop, and each place is drawn with a point size proportional to its population.

../_images/geojson_places_map.png

Fig. 3.1 Natural Earth populated places over country polygons; point size is proportional to population.#

Fig. 3.1 shows the points lining up with the land masses (the isolated dots in the oceans are small island states, too small for the generalized polygons), which is what a correctly ordered [longitude, latitude] file should look like. The densest clusters are in Europe, South and East Asia, and eastern North America, and the largest symbols mark a handful of very large urban areas. Read the density with care: this is a generalized 1:110 million layer of only 243 places, so the pattern reflects the selection of places as much as where people live.

3.1.3.5. A GeoJSON File That Loads as Empty#

The earlier version of this section used a layer of world capitals, capitals.geojson, and it makes a useful cautionary example. When we loaded it with GeoPandas, the CRS printed as EPSG:4326 but the list of geometry types was empty ([]): zero features, and no error message.

The JSON itself contains 241 capitals. The cause is that none of the features declare "type": "Feature". The standard requires that member, and GDAL, the library underneath GeoPandas, does not recognize features without it. (If the maintainers have since corrected the file, the counts below will agree and the repair step becomes a harmless no-op.)

The lesson generalizes: compare what is in the file with what you loaded. The first cell counts the features in the raw JSON and compares them with the rows GeoPandas returned. Because GeoJSON is plain text, repair is easy: add the missing member and save a repaired copy, leaving the original untouched.

Hide code cell source

CAPITALS_URL = "https://raw.githubusercontent.com/Stefie/geojson-world/master/capitals.geojson"
capitals_path = download(CAPITALS_URL, DATA_DIR / "capitals.geojson")

raw = json.loads(capitals_path.read_text())
features = raw["features"]
loaded = gpd.read_file(capitals_path)

print(f"Features in the JSON text       : {len(features)}")
print(f"... of which declare 'Feature'  : {sum(f.get('type') == 'Feature' for f in features)}")
print(f"Rows GeoPandas actually loaded  : {len(loaded)}")
print(f"Geometry types in loaded layer  : {loaded.geom_type.unique().tolist()}")
Features in the JSON text       : 241
... of which declare 'Feature'  : 0
Rows GeoPandas actually loaded  : 0
Geometry types in loaded layer  : []

Hide code cell source

# Repair: add the member the standard requires, and save a new copy
for feature in raw["features"]:
    feature.setdefault("type", "Feature")

repaired_path = DATA_DIR / "capitals_repaired.geojson"
repaired_path.write_text(json.dumps(raw))

capitals = gpd.read_file(repaired_path)
print(f"{len(capitals)} capitals loaded, CRS {capitals.crs}, geometry {capitals.geom_type.unique().tolist()}")
capitals[["id", "city", "country", "iso2", "geometry"]].head()
241 capitals loaded, CRS EPSG:4326, geometry ['Point']
id city country iso2 geometry
0 BD Dhaka Bangladesh BD POINT (90.24 23.43)
1 BE Brussels Belgium BE POINT (4.2 50.5)
2 BF Ouagadougou Burkina Faso BF POINT (-1.31 12.22)
3 BG Sofia Bulgaria BG POINT (23.19 42.41)
4 BA Sarajevo Bosnia and Herzegovina BA POINT (18.25 43.52)

The mismatch is the whole diagnosis: the JSON holds 241 features, none declares "type": "Feature", and GeoPandas loaded zero rows without raising an error. Once the member is restored, all 241 capitals load as WGS 84 points, so the coordinates were fine and the file’s structure was not.

3.1.3.5.1. Valid Does Not Mean Accurate#

A file can load correctly and still be imprecise. These capitals are listed with only two decimal places, and some positions look approximate. A cheap and effective test is to cross-check against a second source. The cell below compares the repaired layer with the Natural Earth capitals, matching on country code and measuring each offset along the Earth’s surface in kilometers. To avoid ambiguous matches, it compares only countries that have exactly one capital in the reference layer.

Hide code cell source

from pyproj import Geod

reference = places[(places["adm0cap"] == 1) & (places["iso_a2"] != "-99")]
reference = reference[~reference["iso_a2"].duplicated(keep=False)]       # countries with one capital only

pairs = capitals.merge(
    reference[["iso_a2", "name", "geometry"]],
    left_on="iso2", right_on="iso_a2", suffixes=("", "_ref"),
)

geod = Geod(ellps="WGS84")
ref_geom = gpd.GeoSeries(pairs["geometry_ref"], crs="EPSG:4326")
_, _, meters = geod.inv(pairs.geometry.x.values, pairs.geometry.y.values,
                        ref_geom.x.values, ref_geom.y.values)
pairs["offset_km"] = meters / 1000

print(f"Compared {len(pairs)} capitals against Natural Earth")
print(pairs["offset_km"].describe(percentiles=[0.5, 0.9]).round(1).to_string())
print()
pairs.nlargest(3, "offset_km")[["city", "name", "country", "offset_km"]].round(0)
Compared 190 capitals against Natural Earth
count    190.0
mean      32.9
std       27.0
min        0.8
50%       32.1
90%       46.1
max      364.0
city name country offset_km
76 Rangoon Naypyidaw Myanmar 364.0
6 Bandar Seri Begawan Bandar Seri Begawan Brunei 57.0
156 Colombo Colombo Sri Lanka 56.0

Across the 190 comparable capitals the median offset is about 32 km and 90% fall within 46 km: invisible on a world map, but significant in any distance calculation. The largest offset in our run is a different kind of problem: the file lists the former capital of Myanmar (Rangoon) while the reference layer lists the current one (Naypyidaw). Positional error and outdated attributes both survive a perfectly valid file, which is why the metadata checklist from the data-source discussion (when was this last updated, and how was it made?) applies to GeoJSON as much as to any other format.

3.1.3.6. Longitude Comes First: Swapped Coordinates#

The most common GeoJSON mistake is writing positions as latitude, longitude. Nothing raises an error, because swapped coordinates are usually still valid numbers. Nairobi, at roughly 36.8° E and 1.3° S, written the wrong way round becomes a perfectly valid point in northern Algeria. The cell plots the correct and the swapped position side by side on the country map.

../_images/geojson_swapped_nairobi.png

Fig. 3.2 Nairobi written as (longitude, latitude) and with the two values swapped.#

Both points in Fig. 3.2 are perfectly valid positions. The correct one, (36.81, −1.28), falls in Kenya; the swapped one, (−1.28, 36.81), lands near the northern coast of Algeria, thousands of kilometers away. A range check would not catch it: a swap breaks the bounds only when the value moved into the latitude slot exceeds 90° in magnitude, and a longitude within ±90° never does. The defences are to plot the data and to compare it with a trusted reference.

3.1.3.7. Measuring Distance Requires a Metric CRS#

Because GeoJSON coordinates are degrees, a distance computed directly on them is in degrees, which is not a length. For example, one degree of longitude spans very different ground distances at the equator and near the poles.

To measure properly, reproject first. Here we use an azimuthal equidistant projection [Snyder, 1987] centered on Washington, D.C.: in this CRS, the distance from the center to any other point is the true ground distance in meters. The first cell compares the naive and projected distances for three capitals. The second maps every capital by its distance from Washington, and checks the projected result against an independent geodesic calculation on the WGS 84 ellipsoid [Karney, 2013].

Hide code cell source

import numpy as np

capitals_ne = places[places["adm0cap"] == 1].copy()
washington = capitals_ne[capitals_ne["adm0name"] == "United States of America"].geometry.iloc[0]

# CRS centred on Washington: distances from the centre are true ground distances
aeqd = f"+proj=aeqd +lat_0={washington.y} +lon_0={washington.x} +datum=WGS84 +units=m"
projected = capitals_ne.to_crs(aeqd)

capitals_ne["km_from_dc"] = np.hypot(projected.geometry.x, projected.geometry.y) / 1000
capitals_ne["degrees_from_dc"] = capitals_ne.geometry.distance(washington)    # naive: degrees (GeoPandas warns, and rightly so)

sample = capitals_ne[capitals_ne["name"].isin(["London", "Nairobi", "Canberra"])]
print(sample[["name", "degrees_from_dc", "km_from_dc"]].round(1).to_string(index=False))
    name  degrees_from_dc  km_from_dc
Canberra            238.0     15945.7
  London             77.9      5912.5
 Nairobi            120.7     12147.2
/var/folders/fd/9ctr8dys3tv3jyfrxn6_8y5m0000gp/T/ipykernel_21293/2769944042.py:11: UserWarning: Geometry is in a geographic CRS. Results from 'distance' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.

  capitals_ne["degrees_from_dc"] = capitals_ne.geometry.distance(washington)    # naive: degrees (GeoPandas warns, and rightly so)

The degree values are not distances. Canberra comes out at 238 “degrees”, more than half a circle can hold, because the naive calculation treats longitude as a flat axis and does not wrap across the antimeridian. The ratios disagree too: Nairobi is about 1.6 times London’s degree value but 2.1 times its true distance (12,147 km versus 5,913 km).

../_images/geojson_capitals_distance.png

Fig. 3.3 National capitals colored by ground distance from Washington, D.C., measured in an azimuthal equidistant CRS.#

Fig. 3.3 shows a smooth gradient radiating outward from Washington: Caribbean and Central American capitals lie within about 5,000 km, Europe and western Africa at roughly 6,000 to 9,000 km, and East and South Asia and Oceania beyond 12,000 km, up to Canberra at 15,946 km. Unlike the degree values above, these numbers are real lengths, and no value can exceed half the Earth’s circumference (about 20,000 km).

Hide code cell source

# Independent check: geodesic distance on the WGS 84 ellipsoid
_, _, geodesic_m = geod.inv(
    np.full(len(capitals_ne), washington.x), np.full(len(capitals_ne), washington.y),
    capitals_ne.geometry.x.values, capitals_ne.geometry.y.values,
)
difference = (capitals_ne["km_from_dc"] - geodesic_m / 1000).abs().max()
print(f"Largest difference between the two methods: {difference:.3f} km")
Largest difference between the two methods: 0.000 km

The projected distances agree with the independent ellipsoidal calculation to well under a meter. That makes an azimuthal equidistant CRS a sound way to measure distances from its center, but only from its center: distances between other pairs of points in the same CRS are distorted.

3.1.4. Writing GeoJSON#

Writing a GeoJSON file is a single call (to_file with driver="GeoJSON"), but the result is only standard-conforming if the layer is in WGS 84 and the coordinates are in longitude-latitude order. Several pitfalls follow from the rules above and from how the GDAL writer behaves [GDAL/OGR contributors, 2026]:

Table 3.4 Writing GeoJSON: Common Pitfalls and Fixes#

Pitfall

What happens

Fix

Writing a projected layer

The default writer keeps the projected coordinates (meters) and adds a legacy crs member; web tools that assume degrees misplace the data

Reproject to EPSG:4326 first, or use the RFC7946="YES" option

Legacy crs member

Even WGS 84 files may carry a crs member that RFC 7946 removed

Use RFC7946="YES" for a standard file

Swapped longitude and latitude

Valid numbers in the wrong place

Check bounds and plot the result

Needless precision

Long decimals inflate the file without adding real accuracy (six decimals is about 10 cm)

Limit digits with the COORDINATE_PRECISION option

Large layers

The whole file must be parsed, and there is no spatial index

Use a binary format such as GeoPackage, or GeoJSON Text Sequences

The first cell writes the same three places three ways and inspects the resulting JSON: with the default writer from a projected layer, after reprojecting to WGS 84, and with the RFC7946 option. The second cell writes a newline-delimited version, and compares the size of the countries layer as GeoJSON and as a GeoPackage.

Hide code cell source

projected_sample = places[["name", "pop_max", "geometry"]].head(3).to_crs(epsg=5070)    # a projected CRS, in meters


def describe_geojson(label, path):
    doc = json.loads(path.read_text())
    crs_member = doc.get("crs", {}).get("properties", {}).get("name", "none")
    first = doc["features"][0]["geometry"]["coordinates"]
    print(f"{label}")
    print(f"  members : {list(doc.keys())}")
    print(f"  crs     : {crs_member}")
    print(f"  first position: {[round(v, 4) for v in first]}")


path_a = DATA_DIR / "sample_projected.geojson"
projected_sample.to_file(path_a, driver="GeoJSON")
describe_geojson("1. Default writer, projected layer", path_a)

path_b = DATA_DIR / "sample_wgs84.geojson"
projected_sample.to_crs(epsg=4326).to_file(path_b, driver="GeoJSON")
describe_geojson("2. Reprojected to EPSG:4326 first", path_b)

path_c = DATA_DIR / "sample_rfc7946.geojson"
projected_sample.to_file(path_c, driver="GeoJSON", RFC7946="YES")
describe_geojson("3. RFC7946='YES' option", path_c)
1. Default writer, projected layer
  members : ['type', 'name', 'crs', 'features']
  crs     : urn:ogc:def:crs:EPSG::5070
  first position: [7117519.654, 6668305.5146]
2. Reprojected to EPSG:4326 first
  members : ['type', 'name', 'crs', 'features']
  crs     : urn:ogc:def:crs:OGC:1.3:CRS84
  first position: [12.4534, 41.9033]
3. RFC7946='YES' option
  members : ['type', 'name', 'features']
  crs     : none
  first position: [12.4534, 41.9033]

Only the third file is standard-conforming. The first keeps projected coordinates in meters ([7117519.7, 6668305.5]) and declares EPSG:5070 in a legacy crs member, so a web tool that assumes degrees would place the data far off the map. The second has degrees, but still writes a crs member, which RFC 7946 removed. The RFC7946 option writes neither problem.

Hide code cell source

# GeoJSON Text Sequence: one feature per line, so it can be streamed or appended to
seq_path = DATA_DIR / "places.geojsonl"
places.to_file(seq_path, driver="GeoJSONSeq")
with seq_path.open() as handle:
    print("GeoJSONSeq lines:", sum(1 for _ in handle), "(one feature per line)")

# Text versus binary: the same countries layer in two formats
geojson_out = DATA_DIR / "countries_out.geojson"
gpkg_out = DATA_DIR / "countries_out.gpkg"
countries.to_file(geojson_out, driver="GeoJSON")
countries.to_file(gpkg_out, driver="GPKG")
print(f"countries as GeoJSON   : {geojson_out.stat().st_size / 1024:7.0f} KB")
print(f"countries as GeoPackage: {gpkg_out.stat().st_size / 1024:7.0f} KB")
GeoJSONSeq lines: 243 (one feature per line)
countries as GeoJSON   :     812 KB
countries as GeoPackage:     544 KB

The binary GeoPackage is about a third smaller than the GeoJSON (544 KB versus 812 KB), even for this small, generalized layer, and the sequence file holds one line per feature. Two related formats are worth knowing by name. GeoJSON Text Sequences [Gillies, 2017] (written above) store one feature per line, which lets programs stream or append to very large layers instead of parsing one giant document. TopoJSON [Bostock and Metcalf, 2016] extends GeoJSON by storing shared boundaries once, which makes layers of adjacent polygons, such as counties or countries, considerably smaller; it is common in web visualization libraries.

3.1.4.1. When to Use GeoJSON#

  • Web mapping and APIs (natively supported by Leaflet, Mapbox, OpenLayers).

  • Sharing small to medium datasets where human readability is useful.

  • Quick prototyping and data exchange between tools.

Limitations:

  • Size: text encoding makes GeoJSON files larger than equivalent binary formats.

  • No index: the whole file must be parsed, even to extract a single feature, so it is not ideal for very large datasets.

  • Fixed CRS: everything is WGS 84 degrees, so analysis requires reprojection.

  • No schema: field types are inferred, and nothing prevents inconsistent values.

3.1.4.2. GeoJSON Is Only as Good as Its Source Data#

Because GeoJSON is so easy to produce from a database export or a web service, it also readily inherits that source’s data-quality problems. Point layers exported from administrative systems commonly contain:

  • Placeholder coordinates, such as (0, 0), used when a location was never recorded.

  • Null or missing geometries for records that exist only as attribute rows.

  • Duplicate features from repeated exports or overlapping data pulls.

  • Out-of-bounds outliers — a single digitizing mistake that places a feature oceans away from the rest of the dataset.

None of these problems are visible from the JSON structure alone; they surface only once you inspect counts, bounds, and geometry validity after loading the file.

You can get real-world incident-style point data for this example from the Atlanta Police Open Data portal — choose a crime dataset and export it as GeoJSON, or use any similar point-based 311/crime portal (e.g. OpenDataPhilly or a city’s ArcGIS Open Data site). These exports are real production data, so the exact quality issues present (zero-coordinate rows, nulls, duplicates) will vary by download — that’s the point of running the checks below rather than assuming. Save the file as incidents.geojson in the data folder next to this notebook, or adjust the filename/path in the last cell of this section.

Because the problems in a real download vary, the cells below first build a small synthetic layer of 400 incidents around Atlanta with known defects injected: placeholder coordinates, null geometries, repeated records, and two points with coordinate mistakes. The layer is written to GeoJSON and read back, as if it had come from a portal, so that every check has something to find.

Table 3.5 Point-Layer Screening Checks and What They Catch#

Check

Method

What it catches

Null geometries

Test for missing geometry

Attribute-only records that cannot be mapped

Zero coordinates

x and y both equal to 0

Placeholders for “location unknown” (a point off the coast of Africa)

Duplicates

Compare all attributes plus the geometry

Repeated exports or overlapping data pulls

Outliers

Distance from the median position

Sign errors, swapped coordinates, digitizing mistakes

Hide code cell source

import pandas as pd

rng = np.random.default_rng(42)
n = 400
base = pd.DataFrame({
    "incident_id": np.arange(1, n + 1),
    "offense": rng.choice(["theft", "burglary", "assault", "vandalism"], n),
})
lon = rng.normal(-84.39, 0.04, n)            # a cluster of points around Atlanta, GA
lat = rng.normal(33.75, 0.03, n)
geoms = [Point(x, y) for x, y in zip(lon, lat)]

# Inject known defects
for i in range(0, 10):
    geoms[i] = Point(0, 0)                   # placeholder coordinates
for i in range(10, 16):
    geoms[i] = None                          # null geometry
geoms[16] = Point(84.39, 33.75)              # sign error: longitude should be negative
geoms[17] = Point(33.75, -84.39)             # latitude and longitude swapped

incidents = gpd.GeoDataFrame(base, geometry=geoms, crs="EPSG:4326")
incidents = pd.concat([incidents, incidents.iloc[20:35]], ignore_index=True)    # repeated records

# Round-trip through GeoJSON, as if downloaded from a portal
incidents_path = DATA_DIR / "incidents_synthetic.geojson"
incidents.to_file(incidents_path, driver="GeoJSON")
incidents = gpd.read_file(incidents_path)
print(f"{len(incidents)} features loaded from {incidents_path.name}")
415 features loaded from incidents_synthetic.geojson

Hide code cell source

def screen_points(gdf):
    """Count common problems in a point layer and return a boolean mask for each."""
    x, y = gdf.geometry.x, gdf.geometry.y
    masks = {}
    masks["null geometry"] = gdf.geometry.isna()
    masks["zero coordinates"] = (x == 0) & (y == 0)
    attributes = gdf.drop(columns=gdf.geometry.name).assign(_wkt=gdf.geometry.to_wkt())
    masks["duplicate row"] = attributes.duplicated()

    usable = ~masks["null geometry"] & ~masks["zero coordinates"]
    median_x, median_y = x[usable].median(), y[usable].median()
    far = ((x - median_x).abs() > 1) | ((y - median_y).abs() > 1)
    masks["far from the rest (> 1 degree)"] = usable & far

    for label, mask in masks.items():
        print(f"{label:<32}: {int(mask.sum()):>4}")
    return masks


masks = screen_points(incidents)
null geometry                   :    6
zero coordinates                :   10
duplicate row                   :   15
far from the rest (> 1 degree)  :    2

The counts match the defects we injected: 10 placeholders, 6 null geometries, 15 repeated records, and 2 far-away points (the 415 rows are the original 400 plus the 15 repeats). The numbers show that problems exist; a map shows how they distort the data. The left panel plots the layer as loaded: the few suspect points sit thousands of kilometers from the real cluster, which collapses into a dot. The right panel shows the plausible points only.

../_images/geojson_screening_map.png

Fig. 3.4 Synthetic incident layer as loaded (left) and after setting aside the suspect points (right).#

Fig. 3.4 shows why screening comes before mapping. In the left panel, the 12 suspect points (ten placeholders stacked on (0, 0), the sign error, and the swapped pair) stretch the axes across the globe, and every plausible point collapses into a dot. With them removed (right), the incidents form a roughly symmetric cloud about 20 km across. The map cannot show two things: null geometries have no location to draw, and repeated records sit exactly on top of their originals, so only the attribute check finds them.

Finally, apply the screening step by step and keep a log, so the difference between the input and the cleaned layer can be explained. The last cell runs the same check on a real data/incidents.geojson if you have downloaded one.

Hide code cell source

remaining = incidents
steps = [("loaded", len(remaining))]
for label, mask in [
    ("null geometries removed", masks["null geometry"]),
    ("placeholder and far-away points removed", suspect),
    ("duplicates removed", masks["duplicate row"]),
]:
    remaining = remaining[~mask.loc[remaining.index]]
    steps.append((label, len(remaining)))

pd.DataFrame(steps, columns=["step", "features remaining"])
step features remaining
0 loaded 415
1 null geometries removed 409
2 placeholder and far-away points removed 397
3 duplicates removed 382

Hide code cell source

real_path = DATA_DIR / "incidents.geojson"
if real_path.exists():
    print("Screening the downloaded incidents file:")
    _ = screen_points(gpd.read_file(real_path))
else:
    print("No downloaded data/incidents.geojson found; skipping the real-data check.")
No downloaded data/incidents.geojson found; skipping the real-data check.

The log accounts for every dropped row (6 + 12 + 15 = 33, taking 415 features to 382), so the difference between the input and the cleaned layer can be explained to a reader.

Treat this kind of check as a required step before any spatial join, buffer, or map — not an optional afterthought.

Practice

Download a point layer as GeoJSON from an open-data portal. Compare the number of features in the raw JSON with the number GeoPandas loads, run screen_points on it, and map the result. Then write a cleaned copy with the RFC7946="YES" option and confirm, by reading the JSON, that it has no crs member and that its first coordinate pair is in longitude-latitude order.

3.1.5. Key Terms#

Table 3.6 Key Terms for GeoJSON Concepts#

Term

Meaning

GeoJSON

A JSON-based text format for geographic features (RFC 7946).

FeatureCollection / Feature

The wrapper object and the per-record objects, each with geometry and properties.

Position

A coordinate array, ordered [longitude, latitude] with optional elevation.

Ring

A closed line forming a polygon boundary; the first ring is the exterior, later rings are holes.

WGS 84 (EPSG:4326)

The only CRS in the current GeoJSON standard.

crs member

A legacy member from the 2008 specification, removed in RFC 7946 but still written by some tools.

Null geometry

A feature with attributes but no location; allowed by the standard.

GeoJSON Text Sequence

A variant with one feature per line, suited to streaming.

TopoJSON

A GeoJSON extension that stores shared boundaries once.

3.1.6. Review Questions#

  1. Name the three members of a GeoJSON Feature, and say what each one holds.

  2. A GeoJSON file loads in GeoPandas with zero rows and no error. How would you find out why?

  3. Why is the distance between two GeoJSON points not meaningful if computed directly on the coordinates, and what would you do instead?

  4. A point meant to be in Nairobi appears in Algeria. What is the likely cause?

  5. Why might to_file(..., driver="GeoJSON") produce a non-standard file from a projected layer, and how do you fix it?

  6. List four data-quality problems that a valid GeoJSON file can still contain, and one check for each.

3.1.7. Summary#

File format choice is a trade-off between interoperability, performance, and fidelity, and the format used to receive data need not be the format used to analyze or deliver it. GeoJSON’s readability and universal web support make it the default choice for small-to-medium datasets and APIs, provided its fixed WGS 84 CRS and text-encoding overhead fit the task. A GeoJSON file can be valid and still be empty to your software, imprecise, outdated, or full of placeholder coordinates, null geometries, and duplicates, so compare what you loaded with what the file contains, and screen every layer before analysis. When writing, reproject to WGS 84 first and remember that longitude comes first.

The next section turns to the format you are most likely to receive from government and institutional sources: the ESRI Shapefile.