Skip to content
← Back to Python for GIS: scripting, GDAL and spatial statistics
Coding for GIS · Part 10 of 22Beginner· 10 min readSources

GeoPandas First Steps: Points, Coordinate Systems, Measurements and Maps

Turn a table of latitude and longitude into a GeoDataFrame, set and change the CRS, measure distance and area correctly, draw a map and save the layer.

GeoPandas adds one thing to pandas: a column of geometries (points, lines or polygons) and the tools to work with them. Everything from Parts 7 to 9 still works. A GeoDataFrame is the Python version of an attribute table with its shapes attached.

What is GeoPandas?

GeoPandas reads and writes vector formats such as Shapefile, GeoPackage and GeoJSON, keeps the coordinate reference system with the data, and offers GIS operations such as buffer, spatial join and overlay. Under the hood it uses the Shapely library for geometry, pyproj for coordinate systems and GDAL (through pyogrio) for files. Install it with pip install geopandas.

ObjectWhat it is
GeoSeriesa column of geometries
GeoDataFramea table with one active geometry column, normally called geometry

1. Make points from a table

Start with a pandas table that has longitude and latitude columns, as in the cleaned file from Part 6.

import pandas as pd
import geopandas as gpd

# Step 1: an ordinary table of cities. lat = north/south position, lon = east/west position.
cities = pd.DataFrame({
    "city": ["Bengaluru", "Mysuru", "Mangaluru", "Hubballi", "Chennai", "Hyderabad", "Mumbai", "Delhi"],
    "state": ["Karnataka", "Karnataka", "Karnataka", "Karnataka", "Tamil Nadu", "Telangana", "Maharashtra", "Delhi"],
    "population": [8443675, 920550, 484785, 943857, 4646732, 6809970, 12442373, 11034555],
    "lat": [12.9716, 12.2958, 12.9141, 15.3647, 13.0827, 17.3850, 19.0760, 28.6139],
    "lon": [77.5946, 76.6394, 74.8560, 75.1240, 80.2707, 78.4867, 72.8777, 77.2090],
})

# Step 2: turn it into a map layer.
# points_from_xy makes a point from each pair. The order is always x first (lon), then y (lat).
# crs="EPSG:4326" says the numbers are longitude and latitude (GPS style coordinates).
gdf = gpd.GeoDataFrame(
    cities,
    geometry=gpd.points_from_xy(cities["lon"], cities["lat"]),
    crs="EPSG:4326",
)
print(gdf[["city", "geometry"]].head(3))      # a new "geometry" column holds the points
print(type(gdf).__name__)
        city                 geometry
0  Bengaluru  POINT (77.5946 12.9716)
1     Mysuru  POINT (76.6394 12.2958)
2  Mangaluru   POINT (74.856 12.9141)
GeoDataFrame

points_from_xy(x, y) builds one point per row. In a geographic system x is longitude and y is latitude, so longitude goes first. The crs="EPSG:4326" argument records what the numbers mean. Forgetting it leaves the layer with no CRS, and every later step is a guess.

2. Inspect a GeoDataFrame

print(gdf.crs)                                    # the coordinate system of the layer
print(gdf.geom_type.unique().tolist())            # the kinds of shape in it (here only Point)
print(gdf.total_bounds.round(2))                  # the box around everything: min x, min y, max x, max y
print(gdf.geometry.iloc[0])                       # the first shape, in text form
print(gdf.geometry.iloc[0].x, gdf.geometry.iloc[0].y)     # its x and y on their own
EPSG:4326
['Point']
[72.88 12.3  80.27 28.61]
POINT (77.5946 12.9716)
77.5946 12.9716

crs is the coordinate reference system. geom_type tells you the shape type. total_bounds returns the extent as minx, miny, maxx, maxy. A point has .x and .y. The first things to check on any new layer are its CRS, geometry type, number of rows and bounds.

3. CRS: set_crs and to_crs

A coordinate reference system (CRS) says how numbers map to places on Earth. Two kinds matter here:

  • A geographic CRS uses angles, degrees of latitude and longitude, on the globe. EPSG:4326 (WGS 84) is the one GPS and most web data use.
  • A projected CRS flattens the Earth onto a plane and uses metres. EPSG:32643 is WGS 84 / UTM zone 43N, which covers 72°E to 78°E and suits Karnataka.

An EPSG code is a short number from a public registry that names one CRS.

Two different operations are easy to mix up:

MethodUse it whenWhat it changes
set_crsthe layer has no CRS, or a wrong label, and you know the true oneonly the label, never the numbers
to_crsyou want the same places in another CRSthe label and every coordinate
# Step 1: keep only Karnataka's cities. .copy() makes a separate table we can change safely.
karnataka = gdf[gdf["state"] == "Karnataka"].copy()

# Step 2: to_crs() converts the coordinates to another system.
# EPSG:32643 is UTM zone 43N, which measures in metres (good for distance and area).
utm = karnataka.to_crs("EPSG:32643")

# Same place, two sets of numbers: degrees before, metres after
print(karnataka.crs.to_epsg(), utm.crs.to_epsg())
print(karnataka.geometry.iloc[0])
print(utm.geometry.iloc[0])
4326 32643
POINT (77.5946 12.9716)
POINT (781481.0202947541 1435426.4069104537)

The same Bengaluru point has coordinates of about 77.59, 12.97 in degrees, and about 781,481 and 1,435,426 in metres (easting and northing from the zone's origin). Calling set_crs("EPSG:32643") on the degree numbers instead would claim the point is a few metres from the origin, which is wrong. Use to_crs to convert.

4. Measure distance correctly

Degrees are not a unit of length. One degree of longitude is about 111 km at the equator and shrinks towards the poles. Measure in a projected CRS.

# Step 1: pick out Bengaluru's point
blr = utm[utm["city"] == "Bengaluru"].geometry.iloc[0]

# Step 2: distance from Bengaluru to every city. In UTM the answer is in metres, so divide by 1000 for km.
utm["km_from_bengaluru"] = (utm.geometry.distance(blr) / 1000).round(1)
print(utm[["city", "km_from_bengaluru"]])
        city  km_from_bengaluru
0  Bengaluru                0.0
1     Mysuru              127.9
2  Mangaluru              297.2
3   Hubballi              375.8

distance(point) returns the straight line distance from every geometry to the point, in the CRS units, which are metres here. Dividing by 1000 gives kilometres. These are straight line distances, not road distances.

If you call distance on the EPSG:4326 layer the answer is in degrees, which has no useful meaning. A good cross-check is the geodesic distance, the shortest path on the ellipsoid, from the pyproj library:

from pyproj import Geod                           # a tool that measures on the curved Earth

geod = Geod(ellps="WGS84")                        # the Earth shape used by GPS

# Two places given as longitude and latitude: Bengaluru and Mysuru
lon1, lat1 = 77.5946, 12.9716
lon2, lat2 = 76.6394, 12.2958

# inv() returns three values: direction there, direction back, distance in metres.
# We only want the distance, so the first two are thrown away with _
_, _, metres = geod.inv(lon1, lat1, lon2, lat2)
print(round(metres / 1000, 1))                    # compare with the UTM answer above
127.9

The projected result for Mysuru and the geodesic result both give 127.9 km, because Bengaluru and Mysuru are well inside zone 43N. Far from the zone's central meridian, a single UTM zone stretches distances, so use a CRS suited to the study area (or geodesic methods) for national scale work.

5. Measure area and length

Draw a rectangle of roughly 43 km by 44 km around central Bengaluru and measure it.

from shapely.geometry import box

# A box around Bengaluru, written in degrees: box(west, south, east, north)
area = gpd.GeoDataFrame(
    {"name": ["test box"]},
    geometry=[box(77.4, 12.8, 77.8, 13.2)],
    crs="EPSG:4326",
)

# In degrees the "area" is a meaningless number (degrees squared)
print("in degrees squared:", round(area.geometry.area.iloc[0], 4))

# Convert to metres first, then the area and length are real
area_utm = area.to_crs("EPSG:32643")
print("in km2:", round(area_utm.geometry.area.iloc[0] / 1_000_000, 1))
print("perimeter km:", round(area_utm.geometry.length.iloc[0] / 1000, 1))
in degrees squared: 0.16
in km2: 1922.5
perimeter km: 175.4

GeoPandas warns you when you ask for the area of a geographic layer, since the number is in squared degrees. The projected value is in square metres. Divide by 1,000,000 for square kilometres. For lines, .length works the same way, so to get the length of a road layer, project first and then read length.

6. Draw a map

plot() uses matplotlib to draw a layer.

import matplotlib.pyplot as plt                   # the drawing library

# Step 1: create an empty picture (fig) with one drawing area (ax)
fig, ax = plt.subplots(figsize=(9, 8))

# Step 2: draw the points. Colour and size both follow the population.
gdf.plot(
    ax=ax,
    column="population",                          # which column decides the colour
    cmap="YlOrRd",                                # colour ramp: yellow to red
    markersize=gdf["population"] / 40_000,        # bigger city, bigger dot
    edgecolor="black",
    legend=True,
    legend_kwds={"label": "Population", "shrink": 0.6},
)
ax.margins(0.12)                                  # add some empty space around the points

# Step 3: write each city name next to its dot
for _, row in gdf.iterrows():                     # go through the table one row at a time
    ax.annotate(row["city"], (row["lon"], row["lat"]), xytext=(6, 5), textcoords="offset points", fontsize=9)

# Step 4: title, axis names, and save to a file
ax.set_title("Cities by population")
ax.set_xlabel("Longitude")
ax.set_ylabel("Latitude")
fig.savefig("cities_map.png", dpi=150, bbox_inches="tight")
print("saved")
saved
Map of eight Indian cities as circles sized and coloured by population, with names
Figure 10.1. Eight cities as points, coloured and sized by population

column="population" colours the points by that field, cmap chooses the colour ramp and markersize sets the point size. The loop writes each city's name at its coordinates. ax is the drawing area, and savefig writes the picture to a file. For a quick map with a basemap, gdf.explore() makes an interactive version in a notebook once the folium and mapclassify packages are installed.

7. Save and read layers

# Save the layer in two common GIS formats. GeoPackage (.gpkg) is the better default.
gdf.to_file("cities.gpkg", layer="cities", driver="GPKG")
gdf.to_file("cities.geojson", driver="GeoJSON")

# Read it back to prove the save worked: same number of rows, same coordinate system
back = gpd.read_file("cities.gpkg", layer="cities")
print(len(back), back.crs.to_epsg())
8 4326

GeoPackage (.gpkg) is a single file that holds several layers, keeps field types and is the format to prefer. A Shapefile is really several files and truncates field names to 10 characters. GeoJSON is text and handy for the web. Its standard (RFC 7946) uses EPSG:4326, so convert with to_crs(4326) before saving a GeoJSON for web tools. read_file loads any of them.

Run this part as a notebook

The code on this page is also a Jupyter notebook. It has the theory in text cells, then the code, then guided practice, an independent assignment and hidden solutions, so you can try each task before you look. Download the Part 10 notebook, open it in JupyterLab and run the cells from top to bottom. Part 1 shows how to install and start Jupyter.

Practice

  1. Make a GeoDataFrame from five places you choose and check its CRS and bounds.
  2. Project the Karnataka cities to EPSG:32643 and find the pair that is farthest apart.
  3. Use set_crs on a layer with no CRS and to_crs on another one. Explain what happened to the coordinates each time.
  4. Plot the points coloured by state with column="state" and legend=True.

Common mistakes

  • Giving latitude first to points_from_xy. It takes longitude (x) first.
  • Using set_crs where to_crs is needed, which relabels the numbers without moving the data.
  • Measuring distance or area in EPSG:4326.
  • Using a UTM zone that does not cover the study area.
  • Forgetting that two layers must share a CRS before they are combined.
  • Saving to Shapefile and losing long field names.

Key takeaways

Next: GeoPandas spatial joins, overlay and dissolve.