My Points Plot in the Ocean: Fixing Swapped Latitude and Longitude

Problem statement

You build points from a CSV, plot them, and the whole dataset is in the sea β€” or in Somalia, or in Antarctica, or stacked on a single dot off the coast of Ghana.

gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df["lat"], df["lon"]), crs="EPSG:4326")
gdf.plot()      # everything in the wrong hemisphere

Four failure patterns cover nearly all of it:

  • Mirrored across the diagonal: latitude and longitude were passed in the wrong order
  • A single point at (0, 0): "Null Island" β€” missing coordinates parsed as zero
  • Everything in one tiny cluster near the equator: decimal commas parsed as thousands separators, or a degrees-minutes-seconds string truncated
  • Points offset by tens or hundreds of metres: the wrong CRS, not a swap

Common causes:

  • points_from_xy() takes x first β€” that is longitude, not latitude
  • the source columns are labelled X/Y but hold latitude/longitude
  • a European CSV uses , as the decimal separator and the values became integers
  • missing values were filled with 0 rather than dropped
  • coordinates in a projected CRS were declared as EPSG:4326
  • a GeoJSON produced by a tool that wrote [lat, lon] instead of the specified [lon, lat]

Quick answer

Build points with longitude first, then sanity-check the range and the extent:

  1. points_from_xy(lon, lat) β€” x is longitude, y is latitude
  2. assert latitude is within Β±90 and longitude within Β±180
  3. drop rows where either coordinate is null or exactly zero
  4. print the extent and compare it against where the data should be
  5. set the CRS to match the numbers, and reproject rather than relabel
import geopandas as gpd
import pandas as pd

df = pd.read_csv("data/raw/stations.csv")
df["lon"] = pd.to_numeric(df["lon"], errors="coerce")
df["lat"] = pd.to_numeric(df["lat"], errors="coerce")

bad = df["lat"].abs().gt(90) | df["lon"].abs().gt(180)
print(f"{bad.sum()} rows out of range β€” likely swapped")
if bad.any():
    df.loc[bad, ["lon", "lat"]] = df.loc[bad, ["lat", "lon"]].to_numpy()

df = df.dropna(subset=["lon", "lat"])
df = df[~((df["lon"] == 0) & (df["lat"] == 0))]

gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df["lon"], df["lat"]), crs="EPSG:4326")
print(gdf.total_bounds)      # [minx, miny, maxx, maxy] β€” longitude first

A latitude above 90 is impossible, so any row failing that test is definitively swapped. The rows that remain need a different check, below.

The four patterns

Three map panels showing correctly placed points, mirrored points from a lat/lon swap, and a cluster at Null Island.
Mirrored across the diagonal means swapped axes; a dot at (0, 0) means missing data.

Step-by-step solution

Checklist of coordinate sanity checks before building geometry.
Six checks that run in milliseconds and catch every pattern above.

Remember the axis order

import geopandas as gpd

# correct: x = longitude (east–west), y = latitude (north–south)
gpd.points_from_xy(df["lon"], df["lat"])

# wrong: mirrors every point across the y = x diagonal
gpd.points_from_xy(df["lat"], df["lon"])

The rule is easy to lose because everyday speech says "latitude and longitude" and the GeoJSON specification says [longitude, latitude]. Naming the variables lon/lat rather than x/y removes most of the risk, because the call then reads wrongly when it is wrong.

Note also that some standards genuinely do use latitude-first ordering β€” EPSG:4326 as defined by the authority is (lat, lon), which is why WMS 1.3.0 and some GML feeds surprise people. GeoPandas, Shapely and GeoJSON are consistently x-first.

Test the ranges

def coordinate_report(df, lon="lon", lat="lat"):
    out = {
        "rows": len(df),
        "lon_null": int(df[lon].isna().sum()),
        "lat_null": int(df[lat].isna().sum()),
        "lat_out_of_range": int(df[lat].abs().gt(90).sum()),
        "lon_out_of_range": int(df[lon].abs().gt(180).sum()),
        "null_island": int(((df[lon] == 0) & (df[lat] == 0)).sum()),
        "lon_range": (float(df[lon].min()), float(df[lon].max())),
        "lat_range": (float(df[lat].min()), float(df[lat].max())),
    }
    return out

for k, v in coordinate_report(df).items():
    print(f"{k:18} {v}")

lat_out_of_range > 0 is proof of a swap. Zero out-of-range rows does not prove the opposite β€” a swap between two values that are both under 90 is perfectly possible, which is what the extent check below is for.

Compare the extent with where the data belongs

EXPECTED = {          # rough bounding boxes, lon/lat
    "uk":      (-8.7, 49.8, 1.8, 60.9),
    "germany": (5.9, 47.3, 15.0, 55.1),
    "kenya":   (33.9, -4.7, 41.9, 5.5),
}

def within_expected(gdf, key):
    minx, miny, maxx, maxy = gdf.total_bounds
    exp = EXPECTED[key]
    ok = (exp[0] - 1 <= minx and maxx <= exp[2] + 1
          and exp[1] - 1 <= miny and maxy <= exp[3] + 1)
    print(f"data bounds : {minx:.2f} {miny:.2f} {maxx:.2f} {maxy:.2f}")
    print(f"expected    : {exp}")
    print("within expected area:", ok)
    return ok

within_expected(gdf, "uk")

For data that should be in the UK, longitudes near 51 and latitudes near -0.1 are the signature of a swap β€” both values are in range individually, and only the geography reveals it.

A swap test that needs no reference box: check whether swapping improves containment in the data's own country polygon.

import geopandas as gpd

world = gpd.read_file("data/ref/countries.gpkg")
target = world[world["iso_a3"] == "GBR"].geometry.union_all()

as_is    = gpd.points_from_xy(df["lon"], df["lat"])
swapped  = gpd.points_from_xy(df["lat"], df["lon"])

hits_as_is   = gpd.GeoSeries(as_is,   crs=4326).within(target).sum()
hits_swapped = gpd.GeoSeries(swapped, crs=4326).within(target).sum()
print(f"as-is: {hits_as_is}, swapped: {hits_swapped}")

Deal with Null Island

Coordinates of exactly (0, 0) are almost always missing values that were filled with zero somewhere upstream. The point is real β€” it is in the Gulf of Guinea β€” so nothing errors.

null_island = (df["lon"] == 0) & (df["lat"] == 0)
print(f"{null_island.sum()} rows at (0, 0) β€” dropping")
df = df.loc[~null_island].copy()

# also catch the near-zero variants from truncated values
suspicious = (df["lon"].abs() < 0.0001) & (df["lat"].abs() < 0.0001)

Drop them, or keep them flagged in a separate column β€” but never let them into a map, where they pull the extent across the entire Atlantic and make every other point invisible.

Fix decimal separators and DMS strings

A European CSV with 52,3702 in a lat column parses as a string, or as 523702 if the thousands separator was assumed.

import pandas as pd

df["lat"] = pd.to_numeric(df["lat"].astype(str).str.replace(",", ".", regex=False), errors="coerce")

Degrees-minutes-seconds text needs converting, not coercing:

import re

DMS = re.compile(r"""(?P<deg>\d+)[Β°\s]+(?P<min>\d+)['\s]+(?P<sec>[\d.]+)["\s]*(?P<hemi>[NSEW])""",
                 re.VERBOSE)

def dms_to_decimal(value):
    m = DMS.match(str(value).strip())
    if not m:
        return None
    dec = int(m["deg"]) + int(m["min"]) / 60 + float(m["sec"]) / 3600
    return -dec if m["hemi"] in ("S", "W") else dec

print(dms_to_decimal("51Β° 30' 26.0\" N"))     # 51.507222…

Distinguish a swap from a wrong CRS

If the points are only slightly displaced β€” tens or hundreds of metres β€” nothing was swapped and the CRS is wrong.

print("declared CRS:", gdf.crs)
print("bounds      :", gdf.total_bounds)

Bounds in the hundreds of thousands with a declared CRS of EPSG:4326 mean the numbers are projected coordinates that were labelled as degrees. Fix by setting the true CRS, then reprojecting:

# the numbers are British National Grid eastings/northings
gdf = gdf.set_crs("EPSG:27700", allow_override=True)   # declare the truth
gdf = gdf.to_crs("EPSG:4326")                           # then convert

set_crs relabels without moving anything; to_crs moves the coordinates. Using the first where you needed the second is the other half of this family of bugs.

Make the checks part of the loader

import geopandas as gpd
import pandas as pd

def load_points(csv_path, lon="lon", lat="lat", crs="EPSG:4326", expected_bounds=None):
    df = pd.read_csv(csv_path)
    for col in (lon, lat):
        df[col] = pd.to_numeric(df[col].astype(str).str.replace(",", ".", regex=False),
                                errors="coerce")

    n0 = len(df)
    df = df.dropna(subset=[lon, lat])
    df = df[~((df[lon] == 0) & (df[lat] == 0))]

    if df[lat].abs().gt(90).any():
        n_bad = int(df[lat].abs().gt(90).sum())
        raise ValueError(f"{n_bad} rows have |lat| > 90 β€” columns look swapped")

    gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df[lon], df[lat]), crs=crs)

    if expected_bounds:
        minx, miny, maxx, maxy = gdf.total_bounds
        exp = expected_bounds
        if not (exp[0] <= minx and maxx <= exp[2] and exp[1] <= miny and maxy <= exp[3]):
            raise ValueError(f"points outside expected area: {gdf.total_bounds} vs {exp}")

    print(f"loaded {len(gdf)} of {n0} rows")
    return gdf

Code examples

Example 1: automatic swap detection and repair

import geopandas as gpd
import pandas as pd

def fix_swapped(df, lon="lon", lat="lat", reference=None):
    """Swap per-row where impossible, and globally where a reference says so."""
    df = df.copy()
    impossible = df[lat].abs().gt(90) & df[lon].abs().le(90)
    if impossible.any():
        df.loc[impossible, [lon, lat]] = df.loc[impossible, [lat, lon]].to_numpy()
        print(f"row-level swap applied to {impossible.sum()} rows")

    if reference is not None:
        as_is = gpd.GeoSeries(gpd.points_from_xy(df[lon], df[lat]), crs=4326)
        swapped = gpd.GeoSeries(gpd.points_from_xy(df[lat], df[lon]), crs=4326)
        if swapped.within(reference).sum() > as_is.within(reference).sum():
            df[[lon, lat]] = df[[lat, lon]].to_numpy()
            print("global swap applied β€” swapped coordinates fit the reference better")
    return df

Example 2: a quick visual check that always works

import matplotlib.pyplot as plt
import geopandas as gpd

world = gpd.read_file("data/ref/countries.gpkg")

fig, ax = plt.subplots(figsize=(9, 5))
world.boundary.plot(ax=ax, linewidth=0.4, color="#94a3b8")
gdf.plot(ax=ax, markersize=6, color="#0ea5e9")
ax.set_title(f"{len(gdf)} points β€” bounds {[round(v, 2) for v in gdf.total_bounds]}")
fig.savefig("data/out/qa_points.png", dpi=120, bbox_inches="tight")
plt.close(fig)

Rendering the points over a world outline takes seconds and catches every one of the four patterns at a glance. Save it as a build artefact so an unattended run leaves visual evidence.

Example 3: count points that fall on land

import geopandas as gpd

land = gpd.read_file("data/ref/land.gpkg").to_crs(4326).union_all()
on_land = gdf.within(land)
print(f"{on_land.sum()} of {len(gdf)} points on land ({on_land.mean():.1%})")

if on_land.mean() < 0.9:
    gdf.loc[~on_land].to_file("data/out/points_in_water.gpkg", driver="GPKG")
    raise ValueError("more than 10% of points fall in water β€” check coordinate order")

For terrestrial datasets this is a strong, cheap automated test β€” and it catches subtler damage than a range check can.

Example 4: reading GeoJSON that has the order backwards

import json
from shapely.geometry import shape, mapping
import geopandas as gpd

raw = json.loads(open("data/raw/stations.geojson", encoding="utf-8").read())

def swap_coords(obj):
    if isinstance(obj, list):
        if len(obj) == 2 and all(isinstance(v, (int, float)) for v in obj):
            return [obj[1], obj[0]]
        return [swap_coords(v) for v in obj]
    return obj

for feature in raw["features"]:
    feature["geometry"]["coordinates"] = swap_coords(feature["geometry"]["coordinates"])

gdf = gpd.GeoDataFrame.from_features(raw["features"], crs="EPSG:4326")
print(gdf.total_bounds)

The GeoJSON specification requires [longitude, latitude], but exports that violate it are common enough to be worth a one-off repair function.

Explanation

Latitude and longitude are the two halves of a geographic coordinate, and everything about how people talk about them puts latitude first. Every computational geometry library puts it second, because the pair is really a point in a plane: x eastwards, y northwards. points_from_xy, Shapely's Point(x, y), WKT's POINT (lon lat) and the GeoJSON specification are all consistent on this. The mismatch between spoken convention and machine convention is the entire cause of the swap.

Triage table mapping coordinate symptoms to causes and fixes.
Six symptoms, six causes β€” the map itself tells you which one you have.

The reason it is so often invisible is that a swap produces perfectly plausible numbers. Latitude 51.5 and longitude -0.13 becomes latitude -0.13 and longitude 51.5: still a valid coordinate, still on Earth, just in Somalia rather than London. Only when a latitude exceeds 90 does arithmetic alone reveal the problem, and that happens only for data outside the Β±90 longitude band. Everything in Europe, Africa and the Americas' eastern edge can swap silently.

Null Island is a different failure with a similar look. Databases and spreadsheets fill blanks with zero, and (0, 0) is a real location off West Africa. A single dot there is the classic signature of missing data, and its practical damage is out of proportion: because it sits thousands of kilometres from everything else, it stretches the map extent so that the actual data collapses into a speck.

The last pattern β€” points close to right but displaced β€” is not about ordering at all. It means the numbers belong to a different CRS than the one declared. set_crs() changes the label without touching the numbers; to_crs() transforms the numbers. Using set_crs when the data needed to_crs leaves coordinates that are internally consistent and geographically wrong, and it is the most common CRS error in the whole stack.

The general defence is the same for all four: validate ranges, check the extent against where the data is supposed to be, drop zeros, and render a thumbnail. Four checks, a few milliseconds, and they run in an automated pipeline where nobody is looking at a map.

Edge cases or notes

  • Some standards really are lat-first: EPSG:4326's authority definition and WMS 1.3.0 use latitude first. GeoPandas, Shapely and GeoJSON do not β€” know which side of the boundary you are on.
  • set_crs vs to_crs: The first relabels, the second transforms. Use set_crs(..., allow_override=True) only when the current label is wrong.
  • Web Mercator bounds: Latitude is clipped at about Β±85.06 in EPSG:3857. Points beyond that cannot be represented.
  • Antimeridian crossings: Data spanning Β±180 produces an extent covering the whole globe. Check for longitudes of both signs near 180 before trusting bounds.
  • Zero is a valid coordinate: Greenwich is longitude 0 and the equator is latitude 0. Only the pair being exactly zero is suspicious.
  • Rounded coordinates cluster: Values rounded to whole degrees put many points on a grid. That is a precision problem, not a swap.
  • total_bounds order is [minx, miny, maxx, maxy]: Longitude first, matching the x/y convention rather than the spoken one.

FAQ

Which order does points_from_xy expect?

Longitude first, latitude second β€” x then y. The same applies to Point(x, y), WKT, and GeoJSON coordinates. Naming your variables lon and lat makes a wrong call read wrongly.

How do I detect a swap when both values are under 90?

Range checks cannot help, so compare the extent against where the data should be, or count how many points fall inside the expected country polygon as-is versus swapped. Whichever wins is the correct order.

What is Null Island and why do my points end up there?

It is the point at latitude 0, longitude 0 in the Gulf of Guinea. Points land there when missing coordinates were filled with zero. Drop rows where both values are exactly zero.

Why are my points close to the right place but offset?

That is a CRS problem, not a swap. The numbers belong to a different reference system than the one declared. Set the true CRS with set_crs(..., allow_override=True), then transform with to_crs().

My CSV has commas as decimal separators β€” what happens?

The column parses as text, or as a much larger integer if the comma was read as a thousands separator. Replace , with . before pd.to_numeric, and check the resulting ranges.

Does EPSG:4326 not officially use latitude first?

Yes β€” the EPSG authority defines the axis order as latitude, longitude, and services like WMS 1.3.0 honour that. The Python geospatial stack and GeoJSON use longitude first, which is the convention that matters in this code.

What is a good automated check for a scheduled job?

Assert that latitudes are within Β±90 and longitudes within Β±180, that no rows sit at exactly (0, 0), and that the extent lies inside an expected bounding box. Saving a small PNG of the points over a country outline gives you visual evidence too.