Field boundary data explained: where it comes from

On this page

Problem statement

Almost every agricultural analysis is per field, and the field boundary is the one layer you are least likely to have. There are four sources β€” a national agricultural parcel register, OpenStreetMap, a commercial product, or delineation from imagery β€” and they differ in coverage, in currency, and in what a polygon means.

The last of those is the trap. A cadastral parcel is a legal object, an agricultural parcel is a subsidy declaration, an OpenStreetMap landuse=farmland polygon is whatever a mapper drew, and a delineated field is a management unit. They overlap and they are not the same thing, and a yield figure per "field" means four different things depending on which you used.

Quick answer

Check what you actually have before using it:

import numpy as np, geopandas as gpd

f = gpd.read_file("fields.gpkg").to_crs(28992)
f["area_ha"] = f.area / 1e4
f["compactness"] = 4 * np.pi * f.area / (f.length ** 2)

print(f"{len(f):,} parcels; median {f.area_ha.median():.2f} ha, "
      f"max {f.area_ha.max():.1f} ha, total {f.area_ha.sum():,.0f} ha")
print(f"under 0.5 ha: {(f.area_ha < 0.5).sum()} ({(f.area_ha < 0.5).mean():.1%})")
print(f"compactness below 0.3: {(f.compactness < 0.3).sum()} "
      f"({(f.compactness < 0.3).mean():.1%})")
print(f"with a crop attribute: {f['crop'].notna().mean():.1%}")
492 parcels; median 18.62 ha, max 234.4 ha, total 9,791 ha
under 0.5 ha: 16 (3.3%)
compactness below 0.3: 54 (11.0%)
with a crop attribute: 0.0%

On 492 OpenStreetMap farmland and meadow polygons in Flevoland polder country: a median of 18.62 ha, a maximum of 234.4 ha, 3.3% under half a hectare, 11.0% with a compactness below 0.3, and 0.0% carrying a crop tag.

Four field boundary sources compared. four sourcesparcel registerOpenStreetMapdelineationcoverageβœ“near-complete!patchyβœ“anywhereannual cropβœ“yesβœ•0.0%βœ•nocurrencyβœ“annualβœ•unknownthe season usedwhat a polygon isa declaration!a mapper’s viewβœ“a management unitCrop coverage measured on 492 OSM polygons in Flevoland polder country spatialworkflow.io
Four sources, four definitions of "field"; only two of them carry a crop.

National parcel registers first, then OpenStreetMap

1. Try the national agricultural parcel register first

Most European countries publish the parcels declared for subsidy under the Integrated Administration and Control System β€” the Netherlands' BRP, Germany's InVeKoS, France's RPG, Denmark's Markkort. They are annual, they carry a crop code, and they cover essentially all commercial agriculture. Where one exists, nothing else comes close.

2. Know what OpenStreetMap gives you

Good geometry where somebody has mapped it, no crop attribute in practice, no currency guarantee, and polygons drawn to whatever definition the mapper used. In the reference extract not one of 492 polygons had a crop tag. It is a usable geometry source and not an agricultural dataset.

3. Understand the difference between the parcel types

type defined by changes when carries a crop
cadastral parcel land ownership ownership changes no
agricultural parcel a subsidy declaration the declaration changes yes, annually
management unit what the farmer treats alike the farmer decides implicitly
delineated field image segmentation the imagery changes no

4. Expect a year in the attribute, not in the geometry

Agricultural parcels are declared annually. The geometry is often stable and the crop is not, so a boundary layer without a year is a boundary layer whose crop attribute belongs to an unknown season.

5. Screen for the artefacts before using the layer

Slivers, ribbons along roads, duplicated polygons and multipart records with parts kilometres apart are all common. Area and compactness catch most of them in two lines.

6. Decide about internal features

Tracks, ditches, headlands, pylons and ponds may be inside the polygon or cut out of it. That decision changes every per-hectare figure, and it differs between sources.

7. Delineate from imagery only when you must

Segmentation from a time series works and produces management units rather than legal parcels, which is often what you want. It also merges adjacent fields with the same crop and splits fields with within-field variation β€” see Delineated field boundaries merge fields or swallow roads.

A cadastral parcel, an agricultural parcel, a management unit and a delineated field over the same ground. cadastralownershipagriculturalcrop: wheata declarationmanagement unitwhat is treated alikedelineatedfrom imageryA per-field statistic means four different things spatialworkflow.io
The same ground, four polygons; a per-field statistic means four different things.

Profiling and flagging two boundary layers

The data used on this page

Two boundary layers for the same piece of Flevoland polder, 5.55–5.75Β° E, 52.48–52.56Β° N, east of Dronten. fields.gpkg holds every OpenStreetMap polygon tagged landuse=farmland or landuse=meadow that touches the box, keeping any crop tag. brp.gpkg is the 2025 Dutch parcel register, BRP Gewaspercelen, for the same box. OpenStreetMap changes daily; the setup reads its responses as saved on 9 October 2026.

import io
import os
import zipfile

import geopandas as gpd
import osmnx as ox
import requests

# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://www.spatialworkflow.io/data/osm-cache-field-boundaries-explained-2026-10-09.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
BOX = (5.55, 52.48, 5.75, 52.56)                    # west, south, east, north

if not os.path.exists("fields.gpkg"):
    osm = ox.features_from_bbox(BOX, tags={"landuse": ["farmland", "meadow"]}).reset_index()
    osm = osm[osm.geom_type.isin(["Polygon", "MultiPolygon"])]
    if "crop" not in osm:
        osm["crop"] = None
    osm[["id", "landuse", "crop", "geometry"]].to_file("fields.gpkg")

if not os.path.exists("brp.gpkg"):
    url = "https://api.pdok.nl/rvo/gewaspercelen/ogc/v1/collections/brpgewas/items"
    feats, params = [], {"f": "json", "bbox": ",".join(map(str, BOX)), "limit": 1000}
    while url:
        page = requests.get(url, params=params, timeout=120).json()
        feats += page["features"]
        url, params = next((l["href"] for l in page["links"] if l["rel"] == "next"), None), None
    brp = gpd.GeoDataFrame.from_features(feats, crs=4326)
    brp["ha"] = brp.to_crs(28992).area / 1e4
    brp.to_file("brp.gpkg")

osm, brp = gpd.read_file("fields.gpkg"), gpd.read_file("brp.gpkg")
print(f"OpenStreetMap: {len(osm):,} farmland and meadow polygons; BRP 2025: {len(brp):,} parcels")
OpenStreetMap: 492 farmland and meadow polygons; BRP 2025: 6,464 parcels

Example 1 β€” profile a boundary layer

import numpy as np, geopandas as gpd, pandas as pd

def profile_fields(path, crs=28992, crop_col=None):
    f = gpd.read_file(path).to_crs(crs)
    f = f[~f.geometry.is_empty & f.geometry.notna()]
    f["area_ha"] = f.area / 1e4
    f["perimeter_m"] = f.length
    f["compactness"] = 4 * np.pi * f.area / (f.length ** 2)
    parts = f.explode(index_parts=False)

    out = {
        "features": len(f), "parts": len(parts),
        "multipart": int((parts.groupby(level=0).size() > 1).sum()),
        "area_ha_median": round(float(f.area_ha.median()), 2),
        "area_ha_mean": round(float(f.area_ha.mean()), 2),
        "area_ha_max": round(float(f.area_ha.max()), 1),
        "area_ha_total": round(float(f.area_ha.sum())),
        "under_0.5_ha": int((f.area_ha < 0.5).sum()),
        "compactness_median": round(float(f.compactness.median()), 3),
        "compactness_below_0.3": int((f.compactness < 0.3).sum()),
        "invalid": int((~f.geometry.is_valid).sum()),
        "overlapping_pairs": int(len(gpd.sjoin(f, f, predicate="overlaps")) // 2),
    }
    if crop_col:
        out["crop_present"] = round(float(f[crop_col].notna().mean()), 3)
        out["crop_classes"] = int(f[crop_col].nunique())
    return out

print(profile_fields("fields.gpkg", crop_col="crop"))
{'features': 492, 'parts': 492, 'multipart': 0, 'area_ha_median': 18.62, 'area_ha_mean': 19.9, 'area_ha_max': 234.4, 'area_ha_total': 9791, 'under_0.5_ha': 16, 'compactness_median': 0.568, 'compactness_below_0.3': 54, 'invalid': 0, 'overlapping_pairs': 1, 'crop_present': 0.0, 'crop_classes': 0}

A median of 18.62 ha and a maximum of 234.4 ha is characteristic of reclaimed polder land; the same profile on English or Italian farmland looks nothing like it, which is why the screening thresholds have to be local.

Example 2 β€” flag the artefacts explicitly

import numpy as np, geopandas as gpd

def flag_artefacts(f, min_area_ha=0.5, min_compactness=0.3, max_area_ha=500):
    f = f.copy()
    f["area_ha"] = f.area / 1e4
    f["compactness"] = 4 * np.pi * f.area / (f.length ** 2)
    f["flag_sliver"] = f.area_ha < min_area_ha
    f["flag_ribbon"] = f.compactness < min_compactness
    f["flag_huge"] = f.area_ha > max_area_ha
    f["flag_invalid"] = ~f.geometry.is_valid
    f["flag_multipart"] = f.geometry.geom_type == "MultiPolygon"
    flags = [c for c in f.columns if c.startswith("flag_")]
    f["n_flags"] = f[flags].sum(axis=1)
    print(f[flags].sum().to_string())
    return f


flagged = flag_artefacts(osm.to_crs(28992))
flag_sliver       16
flag_ribbon       54
flag_huge          0
flag_invalid       0
flag_multipart     0

Flagging rather than dropping keeps the decision visible. A ribbon polygon along a road is usually an artefact and occasionally a genuine strip field.

Example 3 β€” reconcile two sources

import geopandas as gpd

def compare_sources(a, b, crs=28992, min_overlap=0.5):
    # positional ids below, so a filtered layer must be renumbered first
    a, b = a.to_crs(crs).reset_index(drop=True), b.to_crs(crs).reset_index(drop=True)
    j = gpd.overlay(a[["geometry"]].assign(aid=range(len(a))),
                    b[["geometry"]].assign(bid=range(len(b))), how="intersection")
    j["frac_a"] = j.area / j["aid"].map(a.area)
    matched = j[j.frac_a >= min_overlap]
    print(f"A: {len(a):,} parcels, B: {len(b):,}")
    print(f"A parcels matching one B parcel by >{min_overlap:.0%}: "
          f"{matched.aid.nunique():,} ({matched.aid.nunique()/len(a):.1%})")
    print(f"A parcels split across several B parcels: "
          f"{(j.groupby('aid').size() > 1).sum():,}")
    return j


fields_brp = brp[brp.category.isin(["Bouwland", "Grasland"])]
print("A = OpenStreetMap, B = BRP arable and grassland")
j = compare_sources(osm, fields_brp)
print()
print("A = BRP arable and grassland, B = OpenStreetMap")
j = compare_sources(fields_brp, osm)

b = fields_brp.to_crs(28992)
covered = b.intersection(osm.to_crs(28992).union_all()).area.sum() / b.area.sum()
print(f"\nBRP arable and grassland area inside any OpenStreetMap farmland or meadow: {covered:.1%}")
A = OpenStreetMap, B = BRP arable and grassland
A: 492 parcels, B: 3,980
A parcels matching one B parcel by >50%: 199 (40.4%)
A parcels split across several B parcels: 428

A = BRP arable and grassland, B = OpenStreetMap
A: 3,980 parcels, B: 492
A parcels matching one B parcel by >50%: 2,939 (73.8%)
A parcels split across several B parcels: 403

BRP arable and grassland area inside any OpenStreetMap farmland or meadow: 87.9%

Here the two sources disagree about what a parcel is, not about where the farmland is. OpenStreetMap's farmland and meadow cover 87.9% of the register's arable and grassland area, and 73.8% of register parcels lie mostly inside a single OpenStreetMap polygon. Yet 428 of the 492 OpenStreetMap polygons overlap several register parcels: mappers drew the block between two ditches, farmers declared the fields inside it. Running the same comparison between a subsidy register and a delineation shows the difference between a declared parcel and a management unit in the same way.

Why a subsidy register is the best source

Why a subsidy register is the best source where it exists

It is compulsory, annual, geometrically maintained, and it carries the crop the farmer declared. Nothing assembled from imagery or volunteers matches that combination. Its weaknesses are that it only covers claimed land, that the declared crop is the intended crop rather than the grown one, and that the parcel is an administrative unit that may bundle two management units together.

Why OpenStreetMap has geometry but no crop

landuse=farmland is a land-cover tag, drawn by people mapping what a field looks like from imagery. The crop tag exists and is almost never used, because a crop changes every year and nobody maintains it β€” none of 492 polygons in the reference extract carried one. Treat OSM as a geometry source for regions with no register, and expect to attach the crop yourself.

Why compactness catches so much

A real field is a reasonably convex shape enclosing area efficiently. The ratio 4Ο€A/P24\pi A/P^2 is 1 for a circle, 0.785 for a square and 0.698 for a 2:1 rectangle; a ribbon along a road, a sliver between two polygons and a mis-digitised boundary all fall far below. The reference layer's median of 0.568 with 11.0% below 0.3 is a typical signature of a hand-mapped layer.

Why the year matters more than the geometry

Field geometry changes slowly β€” boundaries move when hedges are removed or fields amalgamated. The crop changes every year, and in many systems twice. A boundary layer used with a crop attribute from the wrong year gives a classification accuracy assessment that measures the rotation rather than the classifier.

The shape profile of a real OpenStreetMap farmland layer. 492 polygons, Flevoland poldermeasurevaluepolygons492 farmland and meadowmedian area18.62 hamean area19.9 hamaximum area234.4 hatotal area9,791 haunder 0.5 ha16 (3.3%)compactness below 0.354 (11.0%)with a crop tag0 (0.0%)428 of the 492 overlap several parcels in the 2025 registerPolder fields are large and regular; the same profile elsewhere looks nothing like it spatialworkflow.io
Every screening threshold has to be local; polder fields are not English fields.

Headlands, tracks and missing grassland

  • Headlands may be inside or outside. It changes every per-hectare number.
  • Tracks and ditches are cut out of some registers and not others.
  • Grassland is often absent from arable-focused datasets.
  • Rented land crosses ownership boundaries. Cadastral parcels do not follow management.
  • Sub-field strips are common in trials and in strip cropping.
  • Greenhouses and orchards are agricultural and behave nothing like arable.
  • Check for overlaps. Two registers of the same year should not overlap and sometimes do.
  • Record the source, the year and the parcel definition with the layer.

FAQ

Where do field boundaries come from?

A national agricultural parcel register where one exists, otherwise OpenStreetMap, a commercial product, or delineation from imagery. Only the first two carry a crop attribute, and in practice only the first.

Does OpenStreetMap have crop information?

Almost never. In a 492-polygon Dutch extract, not one carried a crop tag.

What is the difference between a cadastral and an agricultural parcel?

A cadastral parcel is defined by ownership; an agricultural parcel by a subsidy declaration. They frequently do not coincide, and only the second carries a crop.

How do I spot bad polygons in a boundary layer?

Area and compactness. In the reference layer, 3.3% were under half a hectare and 11.0% had a compactness below 0.3.

Does the year of the boundary layer matter?

The geometry changes slowly; the crop changes annually. Using a crop attribute from the wrong year measures the rotation rather than whatever you were testing.

Can I delineate fields from imagery instead?

Yes, and the result is management units rather than legal parcels β€” often what you want, but it merges adjacent fields with the same crop.