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+, and numpy 1.24+. Earlier Shapely versions lack vectorised set_precision().
  • PostGIS 3.1+ if you are enforcing precision at the database layer (ST_ReducePrecision was added in 3.1; ST_SnapToGrid plus ST_MakeValid covers 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_name before 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.

Ground tolerance to decimal place derivation Flowchart showing five steps: contractual ground tolerance feeds into CRS unit resolution, which maps to a decimal place count, which drives deterministic rounding, which feeds topology and area-drift checks before data promotion. Ground Tolerance e.g. ±15 mm CRS Unit Resolution m / ft / degrees Decimal Count + 1 safety buffer Deterministic Rounding all vertices QA Checks ring closure · self-intersections · shared edges · area drift fail Quarantine flag + audit log pass Promote to production Production dataset STEP 1 STEP 2 STEP 3 STEP 4

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

  1. 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.

  2. shapely.set_precision() with mode="pointwise" does not guarantee topological validity. It snaps vertices individually without checking adjacency, so neighbouring features can drift to different grid positions. Use mode="valid_output" for cadastral work, or follow with make_valid().

  3. 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_name from pyproj before choosing decimals.

  4. 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.

  5. 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 geopandas rounding will exhaust memory on dense cadastral datasets. Switch to a PostGIS-based workflow using ST_ReducePrecision with chunked UPDATE statements, or use dask-geopandas as 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 srsName and srsDimension attributes. The automated rounding approach here produces correctly rounded coordinates but does not enforce encoding format; you will need a schema-aware export step.


Related:

Back to Coordinate Reference System Precision Standards