library(sf)Warning: package 'sf' was built under R version 4.5.2
Linking to GEOS 3.13.0, GDAL 3.8.5, PROJ 9.5.1; sf_use_s2() is TRUE
13 October, 2026
In this session we cover geospatial data and how to handle it with R — but the point is not “learn sf syntax by hand.” The point is: spatial bugs are often silent. A map with the wrong projection, a distance computed by hand on raw lon/lat coordinates instead of a projected one — these still look like a map or a plausible number. Nothing crashes. Nothing looks obviously wrong. You have to know what to check — and, just as importantly, which operations already check for you and which don’t.
tl;dr
Yes, R is a fully fledged GIS. No, you don’t need an ArcGIS (or other) license to do real work with spatial data.
Today: you’ll ask your agent (OpenCode) to propose CRS transforms and spatial joins. You verify every one against a plotted map and a known fact (an area, a distance, a row count) — because “it looks like a map” is not verification.
R is our main reference.sf package vignettes are outstanding.st_join() on mismatched CRS refuses to run rather than silently mis-joining. Knowing which operations protect you and which don’t is itself part of the skill.Warning
Rule for today: never accept an agent’s spatial code because “the plot looks right.” Always check it against something you can independently compute or already know (a real distance, a known area, an expected row count).
1. Vector Data
We represent things with points, lines and polygons. We can scale and stretch and transform those easily with mathematical operations. Can increase precision to arbitrary levels (can always zoom in further).
2. Raster Data
We have fixed-size tiles or cells (like a mosaic, or like pixels), which form a grid. Fixed resolution.
This lecture deals mostly with Vector Data (see the pointers at the end for Raster Data).
c(2.34,48.85)Important
This is exactly the kind of thing an agent will get subtly wrong: mixing a geographic CRS (degrees) with a projected CRS (meters) in the same computation. Both “work” — neither errors out — but only one gives you a sensible distance in km.


c(2.34,48.85) in WGS84
c(600256.4, 127726.4) in NTF Lambert North FranceSame city, two totally different-looking numbers. Neither is “wrong” — but you cannot add, subtract, or compute distances across the two without transforming first.
Figure from Geocomputation with R. Geocentric and local geodetic datums shown on top of a geoid (in false color and the vertical exaggeration by 10,000 scale factor). Image of the geoid is adapted from the work of Ince et al. (2019)
R, Agent-NativeR code using sf: reads the data, checks/transforms CRS, does the join, plots it.Warning
If step 3 fails, you go back to the agent with what you found — not “it’s wrong,” but “the join produced 40 rows when there should be at most 12, I bet the CRS didn’t match before the join.”
sf being the main one. That itself relies on several other lower level libraries.source unless you know why.Warning: package 'sf' was built under R version 4.5.2
Linking to GEOS 3.13.0, GDAL 3.8.5, PROJ 9.5.1; sf_use_s2() is TRUE
Let’s read a shapefile from the sf package:
Reading layer `nc' from data source
`/Users/floswald/Library/R/arm64/4.5/library/sf/shape/nc.shp'
using driver `ESRI Shapefile'
Simple feature collection with 100 features and 14 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: -84.32385 ymin: 33.88199 xmax: -75.45698 ymax: 36.58965
Geodetic CRS: NAD27
Simple feature collection with 6 features and 4 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: -81.74107 ymin: 36.07282 xmax: -75.77316 ymax: 36.58965
Geodetic CRS: NAD27
AREA NAME FIPS BIR79 geometry
1 0.114 Ashe 37009 1364 MULTIPOLYGON (((-81.47276 3...
2 0.061 Alleghany 37005 542 MULTIPOLYGON (((-81.23989 3...
3 0.143 Surry 37171 3616 MULTIPOLYGON (((-80.45634 3...
4 0.070 Currituck 37053 830 MULTIPOLYGON (((-76.00897 3...
5 0.153 Northampton 37131 1606 MULTIPOLYGON (((-77.21767 3...
6 0.097 Hertford 37091 1838 MULTIPOLYGON (((-76.74506 3...
geometry column.data.frame.ggplot2 — this is our first choropleth, a map shaded by a variable value.Verification check: does AREA here have sensible units? North Carolina is roughly 139,000 km². Look at range(nc$AREA) — if the numbers don’t come close to plausible, something about the units or CRS is off before you even get to the transform step.
Ask your agent:
“Take the
ncsf object and reproject it to the Mollweide projection (+proj=moll). Plot both the original and reprojectedAREAchoropleth side by side.”
Then verify:
st_transform(), not just relabel the CRS with st_set_crs()? (The latter is the classic silent bug — it changes the label, not the coordinates.)st_crs(nc) before and after — did it actually change?st_ (for spatio-temporal): st_distance(x,y), st_intersection(x,y), st_area(x), st_union(x), st_buffer(x,dist), st_centroid(x), etc.units::set_units(), not a hand-rolled /1e6 (which breaks silently if the CRS units aren’t meters to start with).Verification check: North Carolina’s land area is about 126,000 km² (roughly 139,000 km² including water). If your agent’s number is off by orders of magnitude, suspect a CRS/units mismatch, not arithmetic.
We now build a real spatial-join example: French administrative regions, joined against the Seine/Marne/Yonne river network.
maps-package outline) has a ring that the default spherical (s2) geometry engine considers invalid; switching to the older planar GEOS engine tolerates it. This is itself an “ask your agent why” moment — checklist item 5 below.Important
This is exactly the moment to prompt-check your agent. Ask it: “before you join france and seine, print st_crs(france) and st_crs(seine) — are they the same?” If the agent skips this and jumps straight to st_join(), push back — that’s the step worth double-checking.
## Make sure they have the same projection
seine = st_transform(seine, crs = st_crs(france))
pseine = ggplot() +
geom_sf(data = france, alpha = 0.8, fill = "black", col = "gray50") +
geom_sf(data = seine, col = "#05E9FF", lwd = 1) +
labs(title = "Administrative regions of France",
subtitle = "Also showing the Seine, Marne and Yonne rivers")pl4 = st_join(france, seine) |>
## Get rid of regions with no overlap
dplyr::filter(!is.na(name)) |>
## Some regions are duplicated b/c two
## branches of the river network flow through them
dplyr::distinct(ID, .keep_all = TRUE) |>
ggplot() +
geom_sf(alpha = 0.5, fill = "#01731f", col = "#fcb4b3", linewidth = 0.5) +
geom_sf(data = seine, col = "#05E9FF", lwd = 1) +
labs(title = "Intersected regions only") +
theme_bw()Here is a version of this exact join, as an agent might first hand it back to you — with the CRS-fix step dropped:
## Reload a fresh, untransformed copy — this demo shouldn't depend on
## whether you ran the (eval:false) fix chunk above in your own session.
data("seine", package = "spData")
## BUG: no st_transform() before the join — seine is still in its
## original CRS, france is in whatever maps::map() returns.
pl_bug = st_join(france, seine) |>
dplyr::filter(!is.na(name))Error in `st_geos_binop()`:
! st_crs(x) == st_crs(y) is not TRUE
Good news, this time: st_join() checks that both inputs share a CRS and refuses to run rather than silently matching on whatever coordinates it has — you get st_crs(x) == st_crs(y) is not TRUE immediately, not a plausible-looking-but-wrong map. That’s sf protecting you. Not every spatial computation is this well-behaved — the next section shows one that isn’t.
st_distance() itself is CRS-aware — it does the sensible thing even on unprojected lon/lat input. But nothing stops an agent (or you) from reaching past it: pulling raw coordinates with st_coordinates() and hand-computing a Euclidean distance directly. That version has no error, no warning, and just returns a number — in degrees, silently mistaken for kilometers.pl = st_join(france, st_transform(seine, crs = st_crs(france))) |>
dplyr::filter(!is.na(name)) |>
dplyr::distinct(ID, .keep_all = TRUE)
subdeps = pl |> dplyr::filter(ID %in% c("Seine-Maritime", "Nievre"))
cents = st_centroid(subdeps)
## BUG: hand-rolled Euclidean distance on raw lon/lat coordinates,
## silently treated as if it were kilometers.
xy = st_coordinates(cents)
naive_dist = sqrt(sum(diff(xy)^2))
## Correct: project to a metric CRS first, then use st_distance().
correct_dist = subdeps |>
st_transform(2154) |>
st_centroid() |>
st_distance() |>
units::set_units("km")
naive_dist[1] 3.542147
Units: [km]
[,1] [,2]
[1,] 0.0000 336.5977
[2,] 336.5977 0.0000
Verification check: the real straight-line distance between these two départements’ centroids is roughly 335–340 km — two independent routes (a projected planar distance and a great-circle distance on the unprojected coordinates) agree within a couple of km of each other. naive_dist above comes out around 3.5 — two orders of magnitude off, because it’s a difference measured in degrees, not km. Nothing in that computation errors or warns; you only catch it by checking against an outside number.
st_distance(), or hand-computed coordinates directly. Either can look like a normal, plausible number — that’s the point of a silent bug.Modifying the join from before, ask your agent to produce two plots:


Desired outputs
Then verify the printed distance against an independent source (e.g. a quick search for the two départements’ préfectures and a rough map-ruler estimate) before you trust it.
This session’s outcome is a correct choropleth or spatial join you can explain end-to-end, including the one case you had to correct. A minimal checklist to walk through with your own agent-produced map:
st_crs() on every input before combining them?st_transform() changes coordinates; st_set_crs() only changes the label. An agent using the latter to “fix” a mismatch is a bug, not a fix.sf_use_s2()), ask it to explain why it was necessary before moving on.R package {raster} is the mature, traditional solution.R package {stars} is a great recent development in this space.{tidycensus}.