How to Validate GeoJSON Plot Data Before Submission

Suman SharmaSuman Sharma

Bad geometry is the most common reason plot data fails, whether for EUDR submissions, carbon projects, or agricultural registries. Most of it is catchable automatically. Here's the pipeline I'd build, in order.

For why this matters under EUDR specifically, see EUDR Geolocation Requirements.

1. Parse and check structure

First, is it JSON? Paste it into my JSON Formatter if you're debugging by hand. Then check GeoJSON structure with a schema:

import { z } from "zod"; const Position = z.tuple([z.number(), z.number()]); const Ring = z.array(Position).min(4); const PointGeom = z.object({ type: z.literal("Point"), coordinates: Position }); const PolygonGeom = z.object({ type: z.literal("Polygon"), coordinates: z.array(Ring).min(1) }); const PlotFeature = z.object({ type: z.literal("Feature"), properties: z.object({ plotId: z.string() }).passthrough(), geometry: z.union([PointGeom, PolygonGeom]), });

2. Check coordinates

function checkPosition([lon, lat]: [number, number]) { const errors: string[] = []; if (lon < -180 || lon > 180) errors.push("longitude out of range"); if (lat < -90 || lat > 90) errors.push("latitude out of range"); const decimals = (n: number) => (n.toString().split(".")[1] ?? "").length; if (decimals(lon) < 6 || decimals(lat) < 6) errors.push("fewer than 6 decimals"); return errors; }

Also check the swap heuristic: if your plots should be in Brazil but the "longitude" looks like a Brazilian latitude, the coordinates are probably reversed. A point-in-country check catches this.

Note: JavaScript drops trailing zeros (36.820000 becomes 36.82), so check precision on the raw string if your input source preserves it.

3. Check ring closure and size

function checkRing(ring: [number, number][]) { const [first, last] = [ring[0], ring[ring.length - 1]]; const closed = first[0] === last[0] && first[1] === last[1]; return closed ? [] : ["ring not closed"]; }

4. Check validity (self-intersections)

This is where libraries earn their keep. In TypeScript, Turf.js has kinks to find self-intersections:

import kinks from "@turf/kinks"; const found = kinks(polygonFeature); if (found.features.length > 0) { errors.push(`self-intersects at ${found.features.length} point(s)`); }

In PostGIS, it's a single query:

SELECT id, ST_IsValidReason(geom) FROM plots WHERE NOT ST_IsValid(geom);

5. Check area rules

SELECT id, ST_Area(geom::geography) / 10000 AS area_ha FROM plots;

Casting to geography gives area in square metres on the ellipsoid. Then apply your rules, e.g. flag point-only plots that declare an area over the polygon threshold.

6. Check overlaps

SELECT a.id, b.id, ST_Area(ST_Intersection(a.geom, b.geom)::geography) / 10000 AS overlap_ha FROM plots a JOIN plots b ON a.id < b.id AND ST_Intersects(a.geom, b.geom) WHERE a.producer_id <> b.producer_id;

Big overlaps between different producers usually mean duplicated or mis-drawn plots.

Fixing vs flagging

  • Auto-fix only safe things: closing rings, removing duplicate consecutive points.
  • Flag for review: self-intersections (ST_MakeValid can help, but check the result), swapped coordinates, big overlaps.
  • Keep the original alongside any fixed version.

Show errors humans can act on

"Invalid geometry" helps nobody. "Plot PLT-0042: the boundary crosses itself near the north-east corner (see map)" does. Render the problem on a map.

Frequently asked questions

What makes a GeoJSON polygon invalid?

Common causes are unclosed rings, fewer than four positions, self-intersections, duplicate consecutive points, and holes outside the outer ring. Coordinates must also be valid longitude and latitude values in the right order.

How do I fix a self-intersecting polygon?

Tools like PostGIS ST_MakeValid or Shapely's make_valid can repair many invalid geometries. Review the result, since automatic fixes can split polygons or change shapes.

Which order are GeoJSON coordinates in?

Longitude first, then latitude, using WGS 84 (RFC 7946).

Contact

Get in Touch

Want to call me? book a call and I'll respond in the available time slot. I will ignore all soliciting.