TEC-DATA85-09 · Lesson 6 · 16 clock hours · lab
Python for Spatial Data: GeoPandas, Shapely, and Folium
Objective. Students will read, manipulate, and spatially analyze vector data using GeoPandas and Shapely in a Jupyter Notebook, and render an interactive web map using Folium.
⚠ Pending professional review
This lesson, its worked examples and its practice questions has not yet been verified by an industry-certified professional or by a certification or approval body. It is a study draft and may contain errors. Do not use it as instructions for work on a job site or on live equipment. Always follow your instructor, your supervising professional, and current official references and regulations.
VoTechWorks program approval and accreditation are still in progress. Credentials are issued only by the certifying bodies named in each program, after their own exams.
Python for Spatial Data: GeoPandas, Shapely, and Folium
Geographic Information Systems (GIS) analysis no longer requires expensive desktop software. Python's open-source ecosystem provides everything you need to read, manipulate, analyze, and visualize spatial vector data inside a Jupyter Notebook. In this lab you will use three libraries that work together seamlessly: GeoPandas, Shapely, and Folium.
What Are Vector Data?
Vector data represent geographic features as points, lines, or polygons stored with attribute information. A library location is a point. A road network is a collection of lines. A census tract boundary is a polygon. The GeoJSON format is a common, human-readable text format for vector data, and it is the format you will use in this lab. US Census TIGER/Line shapefiles and open-data portals both distribute GeoJSON files freely.
GeoPandas: The Foundation
GeoPandas extends the familiar Pandas DataFrame by adding a geometry column. The result is called a GeoDataFrame. Every row in a GeoDataFrame holds one feature, its attributes, and its geometry object. You interact with attributes exactly as you would in Pandas — boolean indexing, `groupby`, `merge` — while GeoPandas handles the spatial side.
To read a GeoJSON file:
```python
import geopandas as gpd
tracts = gpd.read_file("census_tracts.geojson")
print(tracts.crs) # always check the coordinate reference system
print(tracts.head())
```
The `crs` attribute tells you the Coordinate Reference System (CRS) — the mathematical model that maps longitude/latitude or projected coordinates to positions on Earth. A common geographic CRS is EPSG:4326 (WGS 84), which stores coordinates in decimal degrees. For area or distance calculations you must reproject to a projected CRS that uses meters, for example EPSG:3857 (Web Mercator) or a state-appropriate EPSG code.
To reproject and compute area:
```python
tracts_m = tracts.to_crs(epsg=3857) # reproject to meters
tracts_m["area_m2"] = tracts_m.geometry.area # area in square meters
```
Never compute area in EPSG:4326; the result will be in squared degrees and will be meaningless.
Shapely: Geometry Under the Hood
Shapely provides the geometry objects — `Point`, `LineString`, `Polygon`, `MultiPolygon` — that populate the geometry column. In most workflows you do not import Shapely directly; GeoPandas calls it automatically. However, understanding Shapely is important when you construct custom geometries or run predicates such as `contains`, `intersects`, or `within`.
```python
from shapely.geometry import Point
pt = Point(-87.6298, 41.8781) # longitude, latitude — note the order
```
Shapely predicates are the engine behind spatial joins.
Spatial Joins with `gpd.sjoin`
A spatial join combines two GeoDataFrames based on a geometric relationship rather than a shared key column. The function `gpd.sjoin` is the primary tool.
```python
libs = gpd.read_file("libraries.geojson").to_crs(epsg=4326)
tracts = gpd.read_file("census_tracts.geojson").to_crs(epsg=4326)
joined = gpd.sjoin(libs, tracts, how="left", predicate="within")
```
This attaches each library point to the census tract that contains it. Both GeoDataFrames must share the same CRS before the join — always verify with `assert libs.crs == tracts.crs`. After joining, you count libraries per tract with `groupby` and merge the counts back to the tract GeoDataFrame for mapping.
Choropleth Mapping
A choropleth map shades polygons by a numeric variable — here, the count of libraries per tract. GeoPandas supports a quick static choropleth via Matplotlib:
```python
tracts.plot(column="library_count", cmap="YlOrRd", legend=True)
```
For an interactive map you use Folium.
Folium: Interactive Web Maps
Folium wraps the Leaflet.js JavaScript library, allowing you to build interactive maps entirely in Python and export them as self-contained HTML files viewable in any web browser — no server required.
```python
import folium
m = folium.Map(location=[41.88, -87.63], zoom_start=11)
folium.Choropleth(
geo_data=tracts,
data=tracts,
columns=["GEOID", "library_count"],
key_on="feature.properties.GEOID",
fill_color="YlOrRd",
legend_name="Libraries per Census Tract"
).add_to(m)
m.save("libraries_map.html")
```
Add `GeoJsonTooltip` to display the tract ID and library count when a user hovers over a polygon. Open the exported HTML file in your browser to verify tooltips and pan/zoom behavior at multiple zoom levels.
Lab Safety and Best Practices
This is a software lab; physical hazards are minimal. Follow these professional practices:
- Data integrity: Download source files only from official portals (US Census Bureau, city open-data sites). Verify file checksums or record the download URL and date in a notebook markdown cell — this is your data provenance record.
- Environment hygiene: Use the provided Anaconda environment. Do not install packages outside it, as version conflicts can corrupt your environment for other students on a shared machine.
- File paths: Use relative paths in your notebook so that it runs on any machine without editing. Hard-coded absolute paths are a common source of notebook failures during grading.
- Notebook reproducibility: Before submitting, restart the kernel and run all cells top-to-bottom. A notebook that does not run clean from top to bottom is considered incomplete.
- Sensitive data: Open public datasets rarely contain personally identifiable information, but if you ever work with data that might, do not share files outside the lab environment without instructor approval.
Key terms8
GeoDataFrameA GeoPandas data structure that extends a Pandas DataFrame with a geometry column, enabling spatial operations alongside tabular attribute management.
Coordinate Reference System (CRS)A mathematical framework that defines how coordinates map to real positions on the Earth's surface; examples include EPSG:4326 (geographic, degrees) and EPSG:3857 (projected, meters).
Spatial JoinAn operation that combines two GeoDataFrames by matching features based on a geometric relationship — such as 'within' or 'intersects' — rather than a shared attribute key.
ShapelyA Python library that provides and manipulates geometric objects (Point, LineString, Polygon) and supports spatial predicates such as contains, intersects, and within.
Choropleth MapA thematic map that shades or colors polygons according to the value of a numeric variable, making spatial patterns in aggregated data visually apparent.
GeoJSONAn open, text-based format for encoding geographic features and their attributes as JSON; widely used by open-data portals and web mapping tools.
ReprojectionThe process of transforming a GeoDataFrame's coordinates from one CRS to another, required before computing measurements such as area or distance in metric units.
FoliumA Python library that generates interactive Leaflet.js web maps and exports them as standalone HTML files, supporting layers, tooltips, and choropleths.
Worked examples3
1. A GeoDataFrame of census tracts is in EPSG:4326. A tract's polygon has an area of 0.0021 square degrees as reported by Shapely. After reprojecting to EPSG:3857, the same polygon reports an area of 1,850,000 square meters. Convert that area to square kilometers.
- Identify the area in square meters: 1,850,000 m².
- Recall the conversion: 1 km² = 1,000,000 m².
- Divide: 1,850,000 ÷ 1,000,000 = 1.85 km².
Answer: The tract area is 1.85 square kilometers. The value in square degrees (0.0021) is meaningless for area measurement and must never be reported.
2. After a spatial join, you use groupby to count libraries per census tract. The groupby result shows tract '17031010100' has 3 libraries and tract '17031010200' has 0 libraries (it never appeared in the join output). You merge the count Series back to the tract GeoDataFrame with a left merge. What value will tract '17031010200' have in the library_count column, and how should you handle it before mapping?
- A left merge keeps all rows from the tract GeoDataFrame, even tracts absent from the groupby result.
- Tracts with no matching libraries will receive NaN in the library_count column after the merge.
- Fill NaN with 0 using: tracts['library_count'] = tracts['library_count'].fillna(0).
- Convert the column to integer type if needed: tracts['library_count'] = tracts['library_count'].astype(int).
Answer: Tract '17031010200' will have NaN after the merge. Fill it with 0 using fillna(0) so that zero-library tracts display correctly on the choropleth instead of appearing as missing data.
3. You have 45 libraries spread across 12 census tracts. After the spatial join and groupby, the sum of all library_count values equals 43, not 45. What are two likely causes, and how do you investigate each?
- Cause 1 — CRS mismatch: if the library points and tract polygons had different CRS values when sjoin ran, some points may have fallen outside all polygons. Investigate by verifying libs.crs == tracts.crs before the join; reproject one GeoDataFrame to match the other if they differ.
- Cause 2 — Points on or outside boundaries: two library points may lie exactly on a tract boundary or slightly outside all tracts (data quality issue). Investigate by checking the sjoin result for rows where the tract GEOID is NaN (indicating no match); map those unmatched points to see if they fall outside the tract layer extent.
- Fix: after confirming the same CRS, use predicate='within' or try predicate='intersects' for boundary-straddling points, and inspect unmatched features before deciding how to handle them.
Answer: Two missing libraries likely result from either a CRS mismatch causing incorrect alignment, or library points that lie on polygon boundaries or outside the tract layer. Always assert matching CRS before a spatial join and check for unmatched rows in the output.
The math in this lesson1
Learners convert tract areas from square meters to square kilometers in a projected CRS.
N-Q.1
In class: Code-along: instructor opens a Jupyter Notebook and walks through reading a GeoJSON file into a GeoDataFrame, inspecting CRS, filtering rows with Pandas-style boolean indexing, and computing a new column (e.g., area in square meters using projected CRS); students type and run each cell, adding markdown commentary. · Independent coding exercise: students use GeoPandas to perform a spatial join between a point GeoDataFrame (e.g., library locations) and a polygon GeoDataFrame (census tracts), compute the count of libraries per tract, and merge the result back to the tract GeoDataFrame for choropleth mapping. · Folium mapping task: students build an interactive Folium map displaying their choropleth layer with a tooltip showing tract ID and library count, add a tile layer, and export the map as an HTML file; they open it in a browser and screenshot three zoom levels.
Materials: Python 3.x with GeoPandas, Shapely, Folium, Matplotlib installed (via course Anaconda environment), Jupyter Notebook, GeoPandas documentation (geopandas.org), Folium documentation (python-visualization.github.io/folium), US Census TIGER/Line GeoJSON files, library location GeoJSON from city open-data portal
Check: Students submit their Jupyter Notebook (.ipynb) with all cells executed and the exported Folium HTML file. Instructor checks that the spatial join produces correct feature counts, the choropleth classification is applied correctly, and the Folium map loads and displays tooltips; pass mark: all three deliverables correct, notebook runs top-to-bottom without errors.
Practice check8
Answer each question, then check it. Nothing is recorded; this is your own practice.
Record this lesson on my transcript. Sign in with your key and enrollment email to record lessons (enroll free if you have no key). This page stays open to read and practise.