Geometry validity: find and understand invalid geometries
tool
geometry
bilingual
An interactive lab with Python, R and MapLibre — upload a GeoJSON, find out which geometries are invalid and why, compare how the two languages diagnose the same problem. Repair is a separate, optional step.
Published
August 29, 2026
Upload a GeoJSON, find out which geometries are invalid and why, and run the same diagnosis in Python and in R on the same map — repair (make_valid()/st_make_valid()) is a separate, optional step, at the bottom of the page.
A .geojson, or a shapefile (either as a single .zip, or as .shp/.dbf/.shx selected together) — see the section below.
Just want to quickly validate or fix a file, without the rest of the reading? Use the standalone tool — same engine, less prose.
// The input and the writing logic into the virtual filesystem live in// shared/upload.js (WebGeoDS.Upload) — same helper used in// topology-errors.qmd, not rewritten here.viewof uploadedFiles = WebGeoDS.Upload.createInput()
{const result =await WebGeoDS.Upload.load(uploadedFiles); mutable uploadStatus = result.message;}
Can a polygon look normal and still be invalid?
flowchart TD
A["Looks fine on the map"] --> B["Polygon"]
B --> C["Geometry validator"]
C --> D["❌ INVALID"]
D --> E["Why?"]
Yes — a polygon can have a boundary that crosses itself, a hole that extends outside the outer boundary, or two holes that overlap, and it’s not uncommon for the shape on the map to look perfectly fine. Below you can find out exactly what to look for, in your own file or in one of the four ready-made examples further down.
Shared map and table
A single map and table, shared by every example on the page: run the same diagnosis in Python or R and compare the result here.
// The color is decided by JS, not by Python/R: the Python/R cells only// return GeoJSON with properties like "valid" — neither language knows// or needs to know who's going to draw it red or green.//// Used ONLY on the first setGeoJSON() call on a source (when it// creates the layer — setGeoJSON() on an already-existing source only// calls setData(), never setPaintProperty): the paint chosen at// diagnosis time stays fixed for the layer's whole lifetime, even// after Repair replaces the data with different properties// (valid_before/valid_after, no longer "valid"). That's why it checks// BOTH possible keys — not just "valid" — otherwise the color would// stay red forever after the repair (verified empirically: a real// bug, not a theoretical one).VALIDITY_PAINT = ({"fill-color": ["case", ["any", ["==", ["get","valid"],true], ["==", ["get","valid_after"],true]],"#2ea44f","#e05252" ],"fill-opacity":0.55})
// Reactive report above the table: totals + valid/invalid count,// recomputed every time the content of one of the two sources// actually changes (same 'sourcedata'/'content' event tableCell()// already uses internally, listened to directly here because this// needs a count, not a table). Reads both the diagnosis property// ("valid") and the post-repair one ("valid_after"), so it stays// correct even after the Repair section further down has run.validationStats = Generators.observe((change) => {const isInvalid = (feature) => {const p = feature.properties?? {};return"valid_after"in p ? p.valid_after===false: p.valid===false; };const compute = () => {const features = ["geometry-py","geometry-r"].flatMap((id) => sharedMap.getGeoJSON(id)?.features?? []);const total = features.length;const invalid = features.filter(isInvalid).length;change({ total,valid: total - invalid, invalid }); };compute();const handler = (e) => {if (e.dataType==="source"&& e.sourceDataType==="content"&& (e.sourceId==="geometry-py"|| e.sourceId==="geometry-r")) {compute(); } }; sharedMap.map.on("sourcedata", handler);return () => sharedMap.map.off("sourcedata", handler);})
Geometry validity — feature, valid, invalid.
resetTopology =async () => {for (const id of ["geometry-py","geometry-r"]) {if (sharedMap.getGeoJSON(id)) {await sharedMap.setGeoJSON(id, { type:"FeatureCollection",features: [] }); } }}
// Factory for the "Load this example" buttons: replaces the code of// the diagnosis cell pair via WebGeoDS.CodeCell.find() + setCode() —// doesn't run anything on its own, leaves the "Run" click to the// student.loadExampleButton = (label, pyCode, rCode) => {const button =document.createElement("button"); button.textContent= label; button.onclick=async () => {awaitPromise.all([window.WebGeoDS.CodeCell.find("geometry-diagnose-py")?.setCode(pyCode),window.WebGeoDS.CodeCell.find("geometry-diagnose-r")?.setCode(rCode) ]);document.getElementById("geometry-diagnose-py")?.scrollIntoView({ behavior:"smooth",block:"center" }); };return button;}
tryExampleButton =loadExampleButton("▶ Try an example (bowtie polygon)", pyBowtieCode, rBowtieCode)
Diagnose: validate your geometry
Run the same check in Python or R on the current data (your file uploaded above, or the example already loaded below) — no repair yet, just diagnosis: is the geometry valid? If not, why?
Python and R answer the same question with one line each:
Python
gdf.geometry.is_valid
R
sf::st_is_valid(data)
Both go through the same geometry engine, GEOS, for this calculation — but don’t expect the exact same text when you ask why a geometry is invalid: explain_validity() in Python includes the coordinates of the problem (e.g. "Self-intersection[12.5 41.9]"), while st_is_valid(reason = TRUE) in R describes the edges involved without coordinates (e.g. "Edge 0 crosses edge 2") — same calculation, different vocabulary. It’s exactly the kind of detail that distinguishes one spatial ecosystem from the other, not a bug in either one.
Valid according to whom?
“Valid” isn’t absolute: it depends on the geometric model and the engine checking it.
Python → shapely → always GEOS.
R → sf → GEOS or S2, depending on sf::sf_use_s2().
For the diagnosis above the difference doesn’t show. It becomes concrete in the repair, below:
Important
Why sf_use_s2(FALSE) before st_make_valid()? With geographic coordinates (lon/lat) and sf_use_s2(TRUE) (the default), st_make_valid() in R dispatches to s2::s2_rebuild() instead of GEOS — a different algorithm that, on these cases, fixes nothing, returning the geometry unchanged without an error. Verified not just on webR but also on real R 4.3.3 with sf 1.0.15: this isn’t a bug in this project, it’s documented sf behavior (see issues #1985 and #1771 on r-spatial/sf). Forcing sf_use_s2(FALSE) routes the call through GEOS, which works correctly here; it’s turned back on right after, because other operations (distances, areas) on geographic coordinates benefit from S2. shapely in Python doesn’t have this problem because it has no alternative S2 engine: it always goes through GEOS.
Gallery of geometric errors
Error
What happens
Detect
Fix
Self-intersection
The boundary crosses itself
✓
✓
Hole outside the boundary
An inner ring extends outside
✓
✓
Self-touching ring
The boundary touches itself at a point
✓
✓
Overlapping holes
Two inner rings intersect
✓
✓
Each example below loads its own data into the diagnosis cell pair above — no repair here, that’s in the Repair section at the bottom of the page.
Self-intersection (“bowtie”)
The polygon’s boundary crosses itself, forming a bowtie shape instead of a single closed area — the most common case in practice, often the result of imprecise manual digitizing.
loadBowtieButton =loadExampleButton("📋 Load this example", pyBowtieCode, rBowtieCode)
Scroll to the Repair section at the bottom of the page to see how it’s fixed.
Hole extending outside the boundary
A “hole” (inner ring, e.g. a lake inside a land polygon) must lie entirely within the outer boundary. If even part of the hole extends outside it, the geometry violates the Simple Features validity rules.
loadHoleButton =loadExampleButton("📋 Load this example", pyHoleCode, rHoleCode)
Scroll to the Repair section at the bottom of the page to see how it’s fixed.
Ring touching itself (hourglass)
The polygon’s boundary touches itself at one point, without crossing, effectively splitting the shape into two lobes joined by a single vertex — not cleanly connected, so invalid.
loadTouchButton =loadExampleButton("📋 Load this example", pyTouchCode, rTouchCode)
Scroll to the Repair section at the bottom of the page to see how it’s fixed.
Overlapping holes
If a polygon has more than one hole, they can’t overlap each other: two intersecting holes create an ambiguous area — inside or outside the polygon? — which the model doesn’t allow.
loadOverlapHolesButton =loadExampleButton("📋 Load this example", pyOverlapHolesCode, rOverlapHolesCode)
Scroll to the Repair section below to see how it’s fixed.
Repair (optional)
The diagnosis tells you what’s wrong. Repair changes the data — make_valid()/st_make_valid() rebuild the geometry into an equivalent, valid form. Requires having run the diagnosis above at least once: it reuses exactly the same data just diagnosed (gdf in Python, data in R stay in the session between one cell and the next, like in a notebook) — no new reading of the file.
The table and the report above update themselves — the valid_before/valid_after columns automatically appear next to the diagnosis ones.
What about relationships between geometries?
A geometry can be perfectly valid on its own and still be wrong in relation to other geometries — two valid polygons overlapping when they shouldn’t, a gap between areas that should touch. That’s a different problem: topology. It’s covered in Topology Errors in a GeoJSON.