How to Clean Messy CSV Coordinates into a Reliable GeoDataFrame

On this page

Raw coordinate CSVs almost never arrive analysis-ready. They come out of spreadsheets, web forms, GPS loggers, and legacy databases, and they carry every defect those systems can produce: numbers stored as text, European comma decimals, stray degree symbols, blank cells, and swapped latitude/longitude columns. If you hand that straight to GeoPandas, you either get an exception or — worse — a GeoDataFrame full of points that silently land in the ocean.

This guide is the cleaning-focused companion to reading a CSV with coordinates as a GeoDataFrame. Here you will take a genuinely messy CSV — the Bundesnetzagentur's register of Germany's 116,443 public EV charging devices, which is published as a spreadsheet export with a preamble, semicolons and German decimal commas — and turn it into a clean, valid, correctly-projected GeoDataFrame with a defensible, repeatable pipeline.

Problem statement

You have a CSV with point coordinates and you want a GeoDataFrame you can trust. The trouble is the data. In the real world, a coordinate CSV typically contains some mix of:

  • Coordinates stored as strings — pandas reads the column as object (text), not float, so arithmetic and geometry construction fail or behave strangely.
  • Comma decimal separators — "48,8566" instead of 48.8566, common in European locales and Excel exports.
  • Stray characters and whitespace — leading/trailing spaces, degree symbols (°), N/S/E/W suffixes, thousands separators.
  • Blank or missing values — empty cells, NaN, "null", "NA", "-", or "None" as text.
  • Non-numeric junk — typos like "12.3.4", "unknown", or a header row that got duplicated into the data.
  • Swapped latitude and longitude — the columns are mislabeled or the source put them in the wrong order.
  • Out-of-range values — latitude beyond ±90 or longitude beyond ±180, from corrupt data or a units mistake.
  • Duplicate points — the same location repeated, sometimes exactly, sometimes with trivial precision differences.
  • No CRS information — the file just has numbers; nothing tells you the coordinate reference system.

The goal is a pipeline that handles all of these without crashing, drops what is unsalvageable, flags what is suspicious, and produces a GeoDataFrame with an explicitly set CRS.

Quick answer

Read the CSV with pandas, coerce the coordinate columns to numeric with errors="coerce" (turning junk into NaN), drop or filter the bad rows, then build geometry with gpd.points_from_xy — passing longitude first — and set crs="EPSG:4326". Export to a GeoPackage.

points_from_xy with latitude first puts points in the wrong place; longitude first (x) is correct. points_from_xy(lat, lon) Wrong — point lands in the ocean points_from_xy(lon, lat) Correct — x = longitude, first spatialworkflow.io
points_from_xy expects x first — longitude, then latitude. Reverse them and points land in the ocean.
import pandas as pd
import geopandas as gpd

# 1. Read everything as text; the file has a 10-line preamble and uses semicolons
df = pd.read_csv("data/stations.csv", sep=";", skiprows=10, dtype=str, encoding="utf-8-sig")
df = df.rename(columns={"Breitengrad": "lat", "Längengrad": "lon"})

# 2. Clean common string mess, then coerce to numeric (junk -> NaN)
for col in ["lat", "lon"]:
    df[col] = (
        df[col]
        .astype(str)
        .str.strip()
        .str.replace("°", "", regex=False)
        .str.replace(",", ".", regex=False)   # comma decimals -> dots
    )
    df[col] = pd.to_numeric(df[col], errors="coerce")

# 3. Drop rows with missing coordinates
df = df.dropna(subset=["lat", "lon"])

# 4. Range-check against the study area, not just the globe
valid = df["lat"].between(47.2, 55.1) & df["lon"].between(5.8, 15.1)   # Germany
df = df[valid].copy()

# 5. Build geometry — LONGITUDE (x) FIRST, latitude (y) second
gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["lon"], df["lat"]),
    crs="EPSG:4326",
)

# 6. Drop duplicate records — by their ID, not by their coordinates
gdf = gdf.drop_duplicates(subset=["Ladeeinrichtungs-ID"]).reset_index(drop=True)

# 7. Export
gdf.to_file("data/stations_clean.gpkg", driver="GPKG")
print(f"{len(gdf):,} charging devices kept")
116,440 charging devices kept

The rest of this guide breaks each step apart so you can inspect, log, and tune it for your data.

From coordinate strings to a GeoDataFrame

Common defects in coordinate columns delivered as CSV text. what arrives in a coordinate columnvalue in the CSVproblemrepair"59,9139"comma decimalreplace , with ."59° 54' N"DMS textparse to decimal" 10.75 "padded stringstrip, to_numeric"n/a"missing markercoerce → NaN591.39out of rangeflag, do not fixlat/lon swappedwrong orderrange-check bothEvery one of these produces points that land in the sea rather than an error spatialworkflow.io
Only the last two are visible on a map; the rest fail quietly during conversion.

Load and inspect the CSV

Always read first, look second. Do not assume the dtypes. Load the file and check what pandas actually inferred — if a coordinate column shows up as object, you have string mess to clean.

import pandas as pd

try:
    pd.read_csv("data/stations.csv")
except pd.errors.ParserError as exc:
    print("default read:", exc)

df = pd.read_csv("data/stations.csv", sep=";", skiprows=10, encoding="utf-8-sig", low_memory=False)
df = df.rename(columns={"Breitengrad": "lat", "Längengrad": "lon"})
raw = df[["lat", "lon"]].copy()           # kept to show what the cleaning changes

print(df.shape)
print(df[["lat", "lon"]].dtypes.to_dict())        # are lat/lon float64 or object?
print(df[["lat", "lon"]].head(3))
default read: Error tokenizing data. C error: Expected 1 fields in line 4, saw 2

(116443, 47)
{'lat': <StringDtype(na_value=nan)>, 'lon': <StringDtype(na_value=nan)>}
         lat       lon
0  48,442398  9,659075
1  48,449424  9,672201
2  48,449424  9,672201

The default read fails on line 4: the export starts with ten lines of notes, and pandas decides the file has one column from the first. Once it reads, both coordinate columns are text — StringDtype in pandas 3, object in older versions — because nearly every value uses a decimal comma. If they were float64 already, you would only need the range and duplicate checks later.

Clean coordinate strings

Strip whitespace, remove degree symbols and direction letters, and convert comma decimals to dots. Work on the string representation so the operations are safe even for already-numeric columns.

What shapes do the values actually take? Replacing every run of digits with 9 shows the formats at a glance:

import re

for col in ("lat", "lon"):
    print(col, raw[col].astype(str).map(lambda v: re.sub(r"\d+", "9", v)).value_counts().to_dict())
lat {'9,9': 116431, '9': 12}
lon {'9,9': 116435, '9': 8}

Two shapes: a decimal comma, and — in a handful of rows — a bare whole number. The second is not a formatting problem; it comes back in the range check.

def clean_coord_strings(s: pd.Series) -> pd.Series:
    return (
        s.astype(str)
        .str.strip()
        .str.replace("°", "", regex=False)
        .str.replace(r"[NSEW]", "", regex=True)   # drop direction letters
        .str.replace(r"\s+", "", regex=True)      # remove inner whitespace
        .str.replace(",", ".", regex=False)       # comma decimal -> dot
    )

df["lat"] = clean_coord_strings(df["lat"])
df["lon"] = clean_coord_strings(df["lon"])

Be careful with the comma replacement if your numbers use commas as thousands separators (e.g. "1,234.5") — see the locale note in Edge cases. For lat/lon in degrees, thousands separators almost never appear, so a straight comma-to-dot swap is safe.

Convert to numeric with pd.to_numeric(errors="coerce")

This is the single most important step. errors="coerce" turns anything that cannot be parsed — "unknown", "12.3.4", empty strings, "NA" — into NaN instead of raising. That converts a fragile crash into a clean, countable signal.

df["lat"] = pd.to_numeric(df["lat"], errors="coerce")
df["lon"] = pd.to_numeric(df["lon"], errors="coerce")

# How much did we lose to unparseable junk?
bad = df["lat"].isna() | df["lon"].isna()
print(f"{bad.sum()} of {len(df)} rows have unparseable coordinates")
print(f"without the comma fix it would have been {pd.to_numeric(raw['lat'], errors='coerce').isna().sum():,}")
0 of 116443 rows have unparseable coordinates
without the comma fix it would have been 116,431

That second line is why errors="coerce" needs a count beside it. Coercion never fails, so a file whose decimal separator you missed does not raise — 116,431 of its 116,443 latitudes quietly become NaN, and the next step drops those rows. The twelve that survive are the whole numbers, which is its own warning sign.

Drop rows with missing coordinates

A point needs both an x and a y. Drop any row where either coordinate is NaN after coercion. Keep a record of how many you removed.

before = len(df)
df = df.dropna(subset=["lat", "lon"]).copy()
print(f"Dropped {before - len(df)} rows with missing coordinates")
Dropped 0 rows with missing coordinates

Range-check latitude/longitude and flag bad rows

Latitude must fall within ±90 and longitude within ±180. Values outside those bounds are corrupt, swapped, or in different units. Flag them so you can inspect before discarding.

df["lat_ok"] = df["lat"].between(-90, 90)
df["lon_ok"] = df["lon"].between(-180, 180)
df["coords_ok"] = df["lat_ok"] & df["lon_ok"]
print(f"outside the globe: {(~df['coords_ok']).sum()}")

# The globe is not the study area: every point here should be in Germany
in_germany = df["lat"].between(47.2, 55.1) & df["lon"].between(5.8, 15.1)
print(df.loc[~in_germany, ["lat", "lon", "Ort", "Postleitzahl"]])

# Whole-degree values are truncations, not locations
whole = (df["lat"] % 1 == 0) | (df["lon"] % 1 == 0)
print(f"{whole.sum()} rows with a whole-degree coordinate, e.g.",
      df.loc[whole, ["lat", "lon", "Ort"]].head(3).to_dict("records"))

clean = df[df["coords_ok"] & in_germany & ~whole].copy()
outside the globe: 0
             lat      lon      Ort  Postleitzahl
78649  55.122778  6.21601  Wegberg         41844
78650  55.122778  6.21601  Wegberg         41844
80005  52.023038  4.00000    Lemgo         32657
19 rows with a whole-degree coordinate, e.g. [{'lat': 48.0, 'lon': 7.700324, 'Ort': 'Bad Krozingen'}, {'lat': 52.24914, 'lon': 13.0, 'Ort': 'Bad Saarow'}, {'lat': 50.649507, 'lon': 8.0, 'Ort': 'Lollar'}]

The global check passes every row. The study-area box finds the real errors: a site in Wegberg, on the Dutch border at about 51.1°N, typed as 55.1 — which puts it in the North Sea — and one in Lemgo whose longitude is a bare 4. The whole-degree check finds the same kind of damage inside Germany, where no range test can: Lollar's four charging devices sit at longitude 8.000000, 50 km west of the town, still well inside the country.

Detect and fix swapped lat/lon

When a swapped coordinate pair can be detected from the numbers, and when it cannot. the heuristic only repairs the obvious halfCan the numbers tell you?lat above 90yes — swap the pairunambiguousboth within ±90no, not from numbersmost of the populated worlda known point existscheck against itor plot and lookRepair the detectable rows rather than dropping them; flag the rest for a human spatialworkflow.io
Example 4 is the first branch; the second is why the article has an edge-cases section.

A classic symptom: a lat value above 90 (impossible for latitude) that would be perfectly valid as a longitude. If a row's lat is out of latitude range but in longitude range — and vice versa for lon — the two are probably swapped. Apply a targeted fix rather than dropping the row.

# Rows where lat looks like a longitude and lon looks like a latitude
swapped = (
    ~df["lat"].between(-90, 90)
    & df["lat"].between(-180, 180)
    & df["lon"].between(-90, 90)
)

df.loc[swapped, ["lat", "lon"]] = df.loc[swapped, ["lon", "lat"]].values
print(f"Repaired {swapped.sum()} swapped lat/lon rows")
Repaired 0 swapped lat/lon rows

This heuristic only catches the unambiguous cases, and in this register it finds none — nor could it: every German latitude and longitude is within ±90, so a swap would pass. A swapped German row would put a point at 10°N, 52°E in the Arabian Sea, which the study-area box above catches and the heuristic does not. You need a known location, a study-area test or a sanity plot. See Edge cases.

Build the GeoDataFrame and set the CRS

Now build the geometry. gpd.points_from_xy expects x first, then y — for geographic coordinates that means longitude, then latitude. Set the CRS explicitly to EPSG:4326, which is plain lon/lat degrees on the WGS 84 datum.

import geopandas as gpd

gdf = gpd.GeoDataFrame(
    clean,
    geometry=gpd.points_from_xy(clean["lon"], clean["lat"]),
    crs="EPSG:4326",
)

print(gdf.crs)          # EPSG:4326
print(len(gdf), gdf.geometry.head(2).to_wkt().tolist())
EPSG:4326
116422 ['POINT (9.659075 48.442398)', 'POINT (9.672201 48.449424)']

Never skip the crs argument. A GeoDataFrame with crs=None cannot be reprojected, plotted on a basemap, or joined with other layers reliably. If you later discover the data was in a different system, see how to fix a CRS mismatch in GeoPandas.

Remove duplicate points

Decide what a duplicate is before deleting anything. Two rows at the same coordinates are duplicates only if they describe the same thing:

same_place = gdf.duplicated(subset=["lon", "lat"], keep=False)
sites = gdf.groupby(["lat", "lon"]).size()
print(f"{same_place.sum():,} rows share their coordinates with another row;"
      f" {len(sites):,} distinct sites, up to {sites.max()} devices at one")
print("IDs unique:", gdf["Ladeeinrichtungs-ID"].is_unique)

gdf = gdf.drop_duplicates(subset=["Ladeeinrichtungs-ID"]).reset_index(drop=True)
print(f"{len(gdf):,} rows after removing duplicate IDs")
55,676 rows share their coordinates with another row; 75,046 distinct sites, up to 513 devices at one
IDs unique: True
116,422 rows after removing duplicate IDs

Deduplicating on coordinates would have deleted a third of the register: a car park with twelve chargers is twelve charging devices with one address, not one device typed twelve times. Here the record has its own ID, and it is unique, so there is nothing to remove. Where there is no ID and coordinate duplicates really are repeats — a GPS logger re-sending a fix — round to your real precision first (5 decimals is about a metre) and deduplicate on the rounded values.

Export

Write the result to a GeoPackage. It is a single-file, open OGC format that preserves the CRS, column types, and geometry far better than a shapefile (no 10-character field limit, no .dbf/.shx sidecar sprawl).

gdf.to_file("data/stations_clean.gpkg", driver="GPKG", layer="stations")
print(gpd.list_layers("data/stations_clean.gpkg").to_dict("records"))
[{'name': 'stations_clean', 'geometry_type': 'Point'}, {'name': 'stations', 'geometry_type': 'Point'}]

Cleaning Germany's charging-point register

The data used on this page

The Bundesnetzagentur's Ladesäulenregister, the register of public EV charging devices in Germany, as published on 1 September 2026 (licence dl-de/by-2-0). It is a CSV exported from a spreadsheet: a UTF-8 byte-order mark, ten lines of notes before the header, a second header row, semicolon separators and German decimal commas. The setup saves it untouched as data/stations.csv.

from pathlib import Path

import requests

Path("data").mkdir(exist_ok=True)
if not Path("data/stations.csv").exists():
    Path("data/stations.csv").write_bytes(requests.get(
        "https://data.bundesnetzagentur.de/Bundesnetzagentur/DE/Fachthemen/ElektrizitaetundGas/"
        "E-Mobilitaet/Ladesaeulenregister_BNetzA_2026-09-01.csv", timeout=600).content)
head = Path("data/stations.csv").read_bytes()[:400].decode("utf-8-sig").splitlines()
print(f"{Path('data/stations.csv').stat().st_size / 1e6:.1f} MB; first lines:", head[:3])
55.6 MB; first lines: ['Ladesäulenregister Bundesnetzagentur;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;', ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;', 'Hinweis: ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;']

Example 1 — Coerce and count the bad rows

import pandas as pd

df = pd.read_csv("data/stations.csv", sep=";", skiprows=10, encoding="utf-8-sig", low_memory=False)
df = df.rename(columns={"Breitengrad": "lat", "Längengrad": "lon"})

df["lat"] = pd.to_numeric(df["lat"], errors="coerce")
df["lon"] = pd.to_numeric(df["lon"], errors="coerce")

n_bad = (df["lat"].isna() | df["lon"].isna()).sum()
print(f"{n_bad} rows failed numeric parsing out of {len(df)}")
116442 rows failed numeric parsing out of 116443

All but one row: coercion without cleaning turns a decimal-comma file into a column of NaN. Count before you drop.

Example 2 — Clean comma decimals and degree symbols

import pandas as pd

df = pd.read_csv("data/stations.csv", sep=";", skiprows=10, encoding="utf-8-sig",
                 dtype=str)                          # read as text to preserve mess
df = df.rename(columns={"Breitengrad": "lat", "Längengrad": "lon"})

for col in ["lat", "lon"]:
    df[col] = (
        df[col]
        .str.strip()
        .str.replace("°", "", regex=False)
        .str.replace(",", ".", regex=False)
    )
    df[col] = pd.to_numeric(df[col], errors="coerce")

print(df[["lat", "lon"]].head(3), "\nunparseable:", df[["lat", "lon"]].isna().any(axis=1).sum())
         lat       lon
0  48.442398  9.659075
1  48.449424  9.672201
2  48.449424  9.672201 
unparseable: 0

Example 3 — Range filter

mask = df["lat"].between(-90, 90) & df["lon"].between(-180, 180)
print(f"Keeping {mask.sum()} of {len(df)} rows after range check")
clean = df[mask].copy()
Keeping 116443 of 116443 rows after range check

Example 4 — Swapped-axis heuristic

# A latitude can never exceed 90. If it does but fits as a longitude, swap.
swapped = ~df["lat"].between(-90, 90) & df["lat"].between(-180, 180) \
          & df["lon"].between(-90, 90)
df.loc[swapped, ["lat", "lon"]] = df.loc[swapped, ["lon", "lat"]].values
print(f"Swapped {swapped.sum()} rows")
Swapped 0 rows

Example 5 — Build and save the GeoDataFrame

import geopandas as gpd

gdf = gpd.GeoDataFrame(
    clean,
    geometry=gpd.points_from_xy(clean["lon"], clean["lat"]),  # x=lon, y=lat
    crs="EPSG:4326",
)
gdf = gdf.drop_duplicates(subset=["Ladeeinrichtungs-ID"]).reset_index(drop=True)   # by ID
gdf.to_file("data/stations_clean.gpkg", driver="GPKG")
print(len(gdf), gdf.crs)
116443 EPSG:4326

Why coordinates arrive as strings

Why coordinates arrive as strings. CSV is a text format with no type system. When a producing tool writes a coordinate, it can attach formatting — a degree symbol, a comma decimal, a N/S suffix — or simply leave a blank cell. The moment any non-numeric character lands in a column, pandas infers the whole column as object (string), because it cannot represent mixed types as float64. So even one stray "N/A" in 10,000 clean numbers downgrades the entire column to text.

Range checks that catch swapped and out-of-range coordinates. the global range check, and its limitlatitudelongitudevalid range✓−90 … 90✓−180 … 180typical columnlat, ylon, xif the value exceeds✕90 → swapped✕180 → projectedIn the charging register it caught 0 of 3 bad rows; a Germany box caught all 3 spatialworkflow.io
Values beyond ±180 usually mean the coordinates are already projected, not broken.

Why errors="coerce" matters. pd.to_numeric has different error modes. The default "raise" throws on the first unparseable value, killing your whole pipeline over a single bad cell. "coerce" instead converts each unparseable value to NaN individually, leaving the good values intact. That gives you a clean, vectorized way to find, count, and drop bad rows with standard pandas tools (isna, dropna) instead of writing fragile try/except loops.

Why the CRS must be set explicitly. Coordinates are just numbers until you say what they mean. EPSG:4326 (WGS 84) declares that x is longitude in degrees and y is latitude in degrees on a specific datum. Without a CRS, GeoPandas cannot reproject, cannot align your points with other layers, and cannot place them on a web basemap (which usually wants EPSG:3857). Setting crs="EPSG:4326" at construction time records the assumption you are already making when you treat the numbers as lon/lat. For converting to another system afterward, see how to reproject spatial data in GeoPandas.

The lon=x / lat=y ordering trap. This is the most common mistake in the entire workflow. People say and write "lat/lon," but geometry libraries are built around the mathematical (x, y) convention, and x is longitude. gpd.points_from_xy(x, y) therefore wants points_from_xy(lon, lat). Pass them in spoken order — points_from_xy(lat, lon) — and every point silently lands at the wrong place: your data ends up rotated and mirrored across the globe, often in the ocean, with no error raised. Always pass longitude first.

Axis order, locales and Null Island

The argument order of points_from_xy, and the two ways the numbers themselves mislead. x is longitude, and it comes firstgdf = gpd.GeoDataFrame(df,geometry=gpd.points_from_xy(df.lon, df.lat),crs="EPSG:4326",)①②①The signature is points_from_xy(x, y): x is longitude. Points that lookflipped or scattered are almost always this.②Only if the numbers are degrees. Values in the hundreds of thousands areprojected metres — set the real projected CRS instead.A blanket replace(",", ".") corrupts thousands separators — read with decimal="," instead spatialworkflow.io
Both notes are about trusting the numbers you were given.

points_from_xy takes x = longitude first

Worth repeating because it is the bug you will hit most. The signature is points_from_xy(x, y, z=None). For geographic data, x is longitude and y is latitude. If your points appear flipped or scattered, this is the first thing to check.

Locale comma decimals and thousands separators

A blanket .str.replace(",", ".") works when commas are decimal separators but corrupts numbers that use commas as thousands separators ("1,234.56" would become "1.234.56", then coerce to NaN). If your file uses one convention throughout, read it with the right locale up front:

located = pd.read_csv("data/stations.csv", sep=";", skiprows=10, encoding="utf-8-sig",
                      decimal=",", low_memory=False)
print(located[["Breitengrad", "Längengrad"]].dtypes.to_dict())
{'Breitengrad': dtype('float64'), 'Längengrad': dtype('float64')}

decimal="," tells pandas the comma is the decimal point, and the columns arrive as floats. Inspect a few raw values before choosing an approach.

Coordinates in projected units, not lat/lon

Not every coordinate CSV is in degrees. Values like easting = 500000, northing = 4649776 are projected (for example UTM, in metres), not lon/lat. The giveaway is magnitude: numbers in the hundreds of thousands or millions are not degrees. In that case, do not set EPSG:4326 — set the actual projected CRS (e.g. crs="EPSG:32633" for UTM zone 33N) and reproject to EPSG:4326 afterward if you need lon/lat. Setting the wrong CRS is a classic CRS mismatch source.

(0, 0) null-island artifacts

A point at exactly longitude 0, latitude 0 sits in the Gulf of Guinea off West Africa — "Null Island." It is almost always an artifact of missing data being filled with zeros rather than a real location. The register has none, but the whole-degree values it does have are the same failure one step removed: a coordinate someone did not know, filled with the nearest round number. Unless your study area genuinely includes that spot, drop or flag (0, 0) rows: df = df[~((df["lon"] == 0) & (df["lat"] == 0))].

Precision and rounding

Coordinate precision is finite and meaningful: 5 decimal places of a degree is roughly 1 metre, 6 is roughly 0.1 metre. Storing 12 decimals implies a precision your GPS never had and bloats files. Rounding to 5-6 decimals before deduplication is a good way to collapse points that are "the same place" recorded at slightly different times. Choose the precision to match your data's real accuracy.

Mixed or text null markers

errors="coerce" handles "NA", "null", "none", and blanks, but only once they reach to_numeric as strings. If you read with dtype=str, they survive the string-cleaning step and get coerced cleanly. You can also pass them to read_csv up front: pd.read_csv(path, na_values=["NA", "null", "none", "-", "unknown"]).

FAQ

Why are my points in the wrong place?

The two usual causes are swapped axes and the wrong CRS. If you passed points_from_xy(lat, lon) instead of points_from_xy(lon, lat), every point is flipped. If the numbers are large (hundreds of thousands), they are projected coordinates and need their real CRS, not EPSG:4326. Plot a few known locations to diagnose which it is.

Why do I get NaN coordinates?

pd.to_numeric(..., errors="coerce") converts any value it cannot parse into NaN. That includes blanks, text like "unknown", malformed numbers like "12.3.4", and comma decimals you did not convert to dots. The NaN is a feature: it lets you find and drop bad rows with df.dropna(subset=["lat", "lon"]).

Which comes first, latitude or longitude?

For human-readable address-style notation, latitude is usually spoken first. For geometry construction in GeoPandas and Shapely, longitude comes first because it is the x coordinate: gpd.points_from_xy(lon, lat). Mixing these up is the single most common error in coordinate workflows.

How do I detect swapped lat/lon when both values are valid?

You often cannot from numbers alone — if both fall within ±90, either ordering is numerically plausible. Use a known reference point, plot the data against a country boundary, or check that the points land in your expected study area. The numeric heuristic only catches cases where one value exceeds the valid latitude range.

Should I drop out-of-range coordinates or fix them?

Flag first, then decide. A latitude of 120 cannot be a real latitude, but it may be a swapped longitude you can repair. Values that make no sense in either column (like 9999) are corrupt and should be dropped. Keep a log of what you removed so the cleaning is auditable.

What CRS should I use for plain lon/lat degrees?

EPSG:4326 (WGS 84). That is the standard for unprojected longitude/latitude in degrees, which is what GPS devices and most "lat/lon" CSVs report. If you need to plot on a web map tile background or measure distances in metres, reproject to EPSG:3857 or an appropriate local projected CRS afterward.

Why use GeoPackage instead of shapefile for output?

GeoPackage (driver="GPKG") is a single-file SQLite-based format that preserves full column names, data types, and the CRS without the shapefile's limitations: shapefiles truncate field names to 10 characters, scatter data across multiple sidecar files, and have weaker type support. For modern GeoPandas workflows, prefer GeoPackage.

How do I handle duplicate points that are slightly different?

Round the coordinates to a precision that matches your real accuracy (5-6 decimal places is metre-level), then deduplicate on the rounded values: gdf.drop_duplicates(subset=["lon_r", "lat_r"]). This collapses points that represent the same location recorded with trivial floating-point or GPS jitter, while keeping genuinely distinct nearby points.