Setting Decimal Precision for Survey Boundaries
You are running a cadastral or engineering workflow and need to lock down how many decimal places each boundary vertex should carry — before topology checks, before spatial joins, and before any regulatory submission. The answer is not a single global number: it derives from the Coordinate Reference System (CRS) precision standards that govern your project, which translate a contractual ground tolerance (e.g. ±15 mm) into an exact decimal count for whichever CRS your data lives in. Get this wrong and you accumulate micro-slivers, false topology violations, and parcel area drift that is difficult to trace back to its source.
This page gives you the procedure from tolerance specification through automated pipeline enforcement, including a precision-derivation diagram, runnable Python, and the specific checks that must pass before data is promoted.
Prerequisites
- Python 3.10+ with
geopandas 0.14+,shapely 2.0+,pyproj 3.6+, andnumpy 1.24+. Earlier Shapely versions lack vectorisedset_precision(). - PostGIS 3.1+ if you are enforcing precision at the database layer (
ST_ReducePrecisionwas added in 3.1;ST_SnapToGridplusST_MakeValidcovers earlier versions). - Confirmed CRS for all input layers. Mixed-CRS inputs must be re-projected to the target CRS before any rounding is applied — rounding in the wrong CRS introduces irreversible positional bias.
- A documented accuracy specification. You need the contractual ground tolerance in metres or feet (e.g. NGS Accuracy Class A: ±2 cm, PLSS cadastral: ±0.05 ft) before you can derive the correct decimal count. If the specification is missing, treat the data as unverified and route it for manual review.
- Gotcha: If you are working in a US state-plane coordinate system with foot-based units (e.g. EPSG:2264, North Carolina State Plane feet), the unit conversion factor differs from metric projections. Confirm
pyproj.CRS.axis_info[0].unit_namebefore applying any rounding table.
Precision Derivation Diagram
The diagram below shows how a ground tolerance flows through CRS unit resolution into a decimal place count, then into the rounding step and downstream topology checks.
Step-by-Step Procedure
Step 1 — Confirm the CRS and its native unit
Extract the Coordinate Reference System from dataset metadata and verify the unit type:
from pyproj import CRS
def get_crs_unit(epsg_code: int) -> str:
"""Return the native unit name for a CRS identified by EPSG code."""
crs = CRS.from_epsg(epsg_code)
return crs.axis_info[0].unit_name # 'metre', 'foot', or 'degree'
# Examples
print(get_crs_unit(26915)) # UTM Zone 15N → 'metre'
print(get_crs_unit(4326)) # WGS84 → 'degree'
print(get_crs_unit(2264)) # NC State Plane → 'foot'
Verification: If unit_name returns anything other than metre, foot, or degree, halt and inspect the CRS definition — some legacy projections use US survey foot or Clarke's foot, which have different conversion factors.
Step 2 — Map ground tolerance to decimal places
Use the table below as the primary lookup. Ground tolerances come from the accuracy specification in your contract, data purchase agreement, or applicable standard (e.g. FGDC Geospatial Positioning Accuracy Standards, NGS Accuracy Classes).
| Ground Tolerance | Projected — metres | Projected — feet | Geographic — degrees | Typical context |
|---|---|---|---|---|
| ±10 cm | 1 decimal | 1 decimal | 5 decimals | Regional planning, generalised basemaps |
| ±1 cm | 2 decimals | 2 decimals | 6 decimals | Municipal zoning, parcel fabric |
| ±1 mm | 3 decimals | 4 decimals | 7 decimals | Engineering cadastral, as-built surveys |
| ±0.1 mm | 4 decimals | 5 decimals | 8 decimals | High-precision geodetic control |
For geographic coordinates (degrees), the conversion depends on latitude. At the equator, 1 degree ≈ 111.32 km; at 45° latitude it is approximately 78.85 km in the east–west direction. Use cos(latitude) to scale for longitude precision. For most cadastral work in mid-latitudes, treat the table values as conservative minimums.
Verification: If the tolerance is expressed in feet and your CRS uses metres (or vice versa), convert first: 1 ft = 0.3048 m.
Step 3 — Add one safety-buffer decimal
Always round to one decimal place beyond the contractual tolerance. This absorbs cumulative floating-point drift that occurs during re-projection and coordinate-array manipulation. A 1 mm tolerance project should target 4 decimal places for metric projected data, not 3.
UNIT_TOLERANCE_MAP = {
"metre": {
"10cm": 2, # 1 decimal + 1 buffer
"1cm": 3,
"1mm": 4,
"01mm": 5,
},
"foot": {
"10cm": 2,
"1cm": 3,
"1mm": 5,
"01mm": 6,
},
"degree": {
"10cm": 6,
"1cm": 7,
"1mm": 8,
"01mm": 9,
},
}
def get_required_decimals(unit: str, tolerance_class: str) -> int:
return UNIT_TOLERANCE_MAP[unit][tolerance_class]
Step 4 — Apply deterministic rounding after re-projection
Always re-project to the target CRS first, then round. Reversing this order biases coordinates in the output system.
import numpy as np
import geopandas as gpd
from shapely.ops import transform
from shapely.validation import make_valid
def round_geometry(geom, decimals: int):
"""Round all coordinate arrays in a geometry to the specified decimal count."""
if geom is None or geom.is_empty:
return geom
def _round_coords(x, y, z=None):
rx, ry = np.round(x, decimals), np.round(y, decimals)
if z is not None:
return rx, ry, np.round(z, decimals)
return rx, ry
return transform(_round_coords, geom)
def standardise_boundary_precision(
gdf: gpd.GeoDataFrame,
target_epsg: int,
decimals: int,
tolerance_m: float,
) -> gpd.GeoDataFrame:
"""
Re-project, round, and repair a GeoDataFrame of survey boundaries.
Args:
gdf: Input GeoDataFrame (any CRS).
target_epsg: EPSG code of the required output CRS.
decimals: Decimal places derived from the tolerance table.
tolerance_m: Minimum acceptable polygon area (m²) after rounding.
Polygons that collapse below this are flagged and excluded.
"""
# 1. Re-project first — never round before transformation
gdf = gdf.to_crs(epsg=target_epsg).copy()
# 2. Record pre-rounding area for drift monitoring
if not gdf.crs.is_geographic:
gdf["_area_before"] = gdf.geometry.area
# 3. Apply deterministic rounding to every vertex
gdf["geometry"] = gdf["geometry"].apply(
lambda g: round_geometry(g, decimals)
)
# 4. Repair self-intersections introduced by vertex snapping
gdf["geometry"] = gdf["geometry"].apply(make_valid)
# 5. Flag and remove collapsed polygons
if not gdf.crs.is_geographic:
gdf["_area_after"] = gdf.geometry.area
collapsed = (
gdf.geometry.geom_type.isin(["Polygon", "MultiPolygon"])
& (gdf["_area_after"] < tolerance_m ** 2)
)
if collapsed.any():
print(f"WARNING: {collapsed.sum()} polygon(s) collapsed below tolerance and were removed.")
gdf = gdf[~collapsed].drop(columns=["_area_before", "_area_after"])
return gdf
Verification: After calling standardise_boundary_precision, run gdf.geometry.is_valid.all() — expect True. Any False values indicate remaining self-intersections that need manual inspection.
For Shapely 2.0+, shapely.set_precision(geom, grid_size, mode="valid_output") is an alternative that preserves topology by snapping coincident edges. Use it when topology preservation matters more than hitting a precise decimal count:
import shapely
def set_grid_precision(gdf: gpd.GeoDataFrame, grid_size: float) -> gpd.GeoDataFrame:
"""Snap all vertices to a grid and preserve topological validity."""
gdf = gdf.copy()
gdf["geometry"] = shapely.set_precision(
gdf.geometry.values,
grid_size=grid_size,
mode="valid_output",
)
return gdf
# 3 decimal metres → grid_size 0.001
# 4 decimal metres → grid_size 0.0001
set_grid_precision(gdf, grid_size=0.001)
Step 5 — Run topology and area-drift checks before promoting data
Rounding alone does not guarantee valid boundaries. The geometry validity checks required before data promotion are:
- Ring closure: Every polygon exterior ring must close within ±0.001 units after rounding. Open rings signal snapped vertices that broke topology.
- Self-intersection check: Use
shapely.is_valid()vectorised over the entire GeoDataFrame. Expect zero failures. - Shared-edge alignment: Adjacent parcels must carry identical vertex coordinates on shared boundaries. Run
geopandas.overlay(a, b, how="intersection")and confirm that the intersection area is below the area-drift threshold. - Area drift: Flag any record where
|A_before − A_after| / A_before > 0.001%. Larger drift means the precision level is too coarse for that feature’s geometry. - Coordinate range: Confirm no coordinate exceeds the valid extent for the target CRS (e.g. UTM zone bounds, state-plane false-origin ranges). Out-of-range values indicate a CRS mismatch, not a precision issue.
import shapely
def run_boundary_qa(gdf: gpd.GeoDataFrame) -> dict:
"""Return a summary dict of QA results for rounded survey boundaries."""
is_valid = shapely.is_valid(gdf.geometry.values)
validity_reasons = shapely.is_valid_reason(gdf.geometry.values)
invalid_mask = ~is_valid
report = {
"total_features": len(gdf),
"valid": int(is_valid.sum()),
"invalid": int(invalid_mask.sum()),
"invalid_reasons": list(set(validity_reasons[invalid_mask])),
}
return report
# Example output for a passing dataset:
# {'total_features': 4821, 'valid': 4821, 'invalid': 0, 'invalid_reasons': []}
Verification: report["invalid"] must be 0 before the dataset proceeds to spatial joins or OGC topology rule checks.
Interpreting Results
| Symptom | Likely cause | Fix |
|---|---|---|
Self-intersection [x y] from is_valid_reason |
Rounding snapped two non-adjacent vertices together | Call make_valid() on the affected feature; if the feature collapses, escalate to manual editing |
| Sliver polygons appear after overlay | Adjacent parcels were rounded independently to different grids | Re-run standardise_boundary_precision on both datasets with the same decimals and target_epsg before the overlay |
| Area drift > 0.001% on large polygons | Insufficient decimal count for the feature’s size | Increase decimals by 1 for large-area polygons (>10 ha), or use set_precision with a finer grid_size |
| Collapsed polygons (< threshold area) | Narrow slivers or thin building footprints lost vertices entirely after rounding | Review features individually; consider storing them at higher precision under a separate schema tier |
| Out-of-range coordinate values | CRS mismatch: data is in geographic degrees but was treated as projected | Re-check source CRS before any rounding; correct the projection before proceeding |
When you encounter an invalid_reason string not in this table, pass it to shapely.explain_validity(geom) (Shapely < 2.0) or inspect shapely.is_valid_reason(geom) directly for the GEOS-level diagnostic. The Building Rule Engines with GeoPandas guide shows how to route these diagnostic strings into a structured error-classification system rather than dumping them to a log.
Gotchas & Edge Cases
-
Rounding before projection is a data-corruption risk. If source data arrives in EPSG:4326 and your pipeline rounds to 5 decimal places before re-projecting to a UTM zone, the latitude-dependent scale error shifts rounding thresholds unpredictably. Always call
to_crs()first. -
shapely.set_precision()withmode="pointwise"does not guarantee topological validity. It snaps vertices individually without checking adjacency, so neighbouring features can drift to different grid positions. Usemode="valid_output"for cadastral work, or follow withmake_valid(). -
Foot-based state-plane systems silently pass the wrong decimal count. EPSG:2264 (North Carolina, feet) and EPSG:2263 (New York, feet) look like metric projected CRS at first glance. If you apply the metre-column values from the tolerance table, you are off by a factor of ~3.28. Always inspect
unit_namefrompyprojbefore choosingdecimals. -
Repeated rounding compounds drift. If a dataset is rounded, re-projected, then rounded again (a common pattern when merging two separately processed layers), cumulative drift can exceed the contractual tolerance. Process all layers to the same target CRS and decimal count in a single pass before any merging.
-
ISO 19157 area accuracy reporting uses a different threshold than coordinate precision. The standard’s positional accuracy element reports root-mean-square error across a sample of check points — not the per-vertex decimal count. When a spatial data quality policy references ISO 19157 thresholds, confirm whether it is specifying coordinate precision or statistical positional accuracy, as these require different validation approaches.
When to Escalate
Move beyond this procedure when any of the following apply:
-
Feature count exceeds 500,000 polygons. Single-node
geopandasrounding will exhaust memory on dense cadastral datasets. Switch to a PostGIS-based workflow usingST_ReducePrecisionwith chunkedUPDATEstatements, or usedask-geopandasas described in Scaling GeoPandas Validation with Dask. -
Precision tier conflict between layers. If one input layer carries survey-grade precision (7–8 decimals) and another carries planning-grade precision (5 decimals), and they must share boundaries, escalate to a data steward to define a tolerance arbitration rule. Automatic rounding to the coarser tier may violate the survey contract; rounding to the finer tier may introduce false accuracy in the planning layer.
-
More than 5% of features fail post-rounding validity checks. A failure rate above this threshold indicates a systemic problem — likely a CRS mismatch or inconsistent source precision — that requires investigation at the ingestion stage rather than feature-by-feature repair.
-
Regulatory submission requires a specific coordinate encoding. Some land-registry systems mandate ISO 6709 text encoding with explicit precision declarations, or GML with defined
srsNameandsrsDimensionattributes. The automated rounding approach here produces correctly rounded coordinates but does not enforce encoding format; you will need a schema-aware export step.
Related:
- Coordinate Reference System Precision Standards — the parent topic covering tolerance matrices, CRS registry integration, and PostGIS precision enforcement at scale
- Geometry Validity Checks for Vector Data — OGC validity rules and repair strategies that run after precision standardisation
- Understanding OGC Topology Rules — adjacency and containment constraints that shared-edge alignment must satisfy