Data Validation Rules for Store Coordinates
Within a retail site-selection pipeline, coordinate validation is the gate that decides whether every downstream spatial operation can be trusted, so it must run before any record reaches analytics.
When location intelligence teams ingest lease agreements, franchise submissions, or third-party POI feeds, raw latitude and longitude payloads routinely contain transcription errors, inverted axes, or mismatched reference systems. Implementing rigorous Data Validation Rules for Store Coordinates is not a data-hygiene afterthought; it is a pipeline control that prevents spatial miscalculations in trade area generation, drive-time modeling, and competitive gap mapping. Within the broader Location Intelligence Architecture & Data Foundations framework, coordinate validation acts as the primary gatekeeper before records enter analytical workloads such as the PostGIS retail database.
Concept and Theory: Why Coordinate Validation Is a Spatial Problem
A store coordinate is not merely a pair of numbers — it is a point geometry anchored to a coordinate reference system (CRS). Validation therefore has to answer three independent questions, and conflating them is the root cause of most ingestion bugs:
- Is the value syntactically legal? A WGS84 (
EPSG:4326) coordinate must satisfy latitude ∈ [-90, 90] and longitude ∈ [-180, 180]. Anything outside those ranges cannot be a real geographic point. - Is the value semantically correct? A coordinate can be perfectly in-bounds yet wrong — most commonly because latitude and longitude were swapped, or because a geocoder returned a default. These records pass naive range checks but land in the wrong hemisphere.
- Is the value spatially plausible? Even a correctly-ordered, in-bounds point is invalid for a Midwest retailer if it falls in the Atlantic. Plausibility is a containment test against a reference geometry for the operational footprint.
The reason axis inversion is so common is that the two dominant conventions disagree on ordering. GeoJSON and most spatial engines expect [longitude, latitude] (x, y), whereas humans, spreadsheets, and GPS displays almost always write latitude, longitude. Every CSV handoff between those two worlds is an opportunity for a silent swap. Because the United States sits at negative longitudes (roughly -66 to -125) and positive latitudes (roughly 24 to 49), a swapped CONUS point usually becomes out-of-bounds (longitude > 90), which is exactly the signal a repair heuristic can exploit. All comparisons in this stage assume a declared CRS — never operate on bare lat/lon floats without asserting EPSG:4326 first, so that a later reprojection cannot misinterpret the axis order.
The diagram below shows why this asymmetry makes the swap self-diagnosing. A real Chicago store at (41.88, -87.63) is in-bounds; transpose the pair to (-87.63, 41.88) and the latitude field now holds -87.63, which is below the -90 … 90 legal range. The out-of-bounds signal is the repair heuristic’s trigger.
Architecture Overview
Validation runs as a short, deterministic chain: a syntactic bounds check, a conditional axis-swap repair, a spatial containment test against the operational region, then a routing decision that either promotes the record to analytics or quarantines it with a machine-readable reason. The diagram below traces a single coordinate through that chain.
Configuration Parameters
Validation rules should live as version-controlled configuration (YAML or JSON), not hardcoded constants, so the same thresholds apply identically across batch and streaming paths. The table below lists the parameters that govern the stages above, with retail-oriented defaults.
| Parameter | Type | Valid range / values | Retail default | Purpose |
|---|---|---|---|---|
source_crs |
string | any EPSG code | EPSG:4326 |
CRS asserted on raw ingestion before any operation |
analysis_crs |
string | equal-area / projected code | EPSG:5070 |
CRS for distance and containment math (CONUS Albers) |
lat_bounds |
tuple(float) | within [-90, 90] | (-90, 90) |
Hard syntactic latitude limits |
lon_bounds |
tuple(float) | within [-180, 180] | (-180, 180) |
Hard syntactic longitude limits |
precision_decimals |
int | 0–8 | 6 |
Decimals retained (~0.11 m at the equator) |
max_precision_decimals |
int | 6–12 | 8 |
Flag values beyond this as GPS noise / bad conversion |
enable_axis_swap_repair |
bool | true / false | true |
Attempt lat/lon swap before rejecting out-of-bounds rows |
null_island_reject |
bool | true / false | true |
Reject exact (0.0, 0.0) geocoder defaults |
containment_predicate |
string | within / intersects |
within |
Spatial predicate against the region polygon |
region_buffer_m |
float | 0–5000 | 250 |
Buffer on the region to tolerate boundary GPS jitter |
fail_rate_threshold |
float | 0.0–1.0 | 0.05 |
Per-feed failure ratio that pauses ingestion |
Step-by-Step Python Implementation
Python remains the standard for coordinate validation because of its vectorized data manipulation and mature geospatial stack. The following implementation applies the syntactic, semantic, and spatial rules in one pass over a DataFrame using pandas, numpy, geopandas, and shapely, with the CRS asserted explicitly via geopandas/pyproj so that no operation runs on undeclared axes.
import pandas as pd
import numpy as np
import geopandas as gpd
from shapely.geometry import box
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def validate_store_coordinates(df: pd.DataFrame, valid_region: box) -> pd.DataFrame:
"""
Applies syntactic, semantic, and spatial validation rules to store coordinates.
Returns a DataFrame with validation status and error flags.
`valid_region` is interpreted in EPSG:4326 to match the asserted source CRS.
"""
# 1. Type coercion & NaN handling
df["lat"] = pd.to_numeric(df["latitude"], errors="coerce")
df["lon"] = pd.to_numeric(df["longitude"], errors="coerce")
# 2. Null Island rejection (failed geocoder returning the 0,0 default)
null_island = (df["lat"] == 0.0) & (df["lon"] == 0.0)
# 3. Syntactic bounds check
lat_valid = df["lat"].between(-90, 90)
lon_valid = df["lon"].between(-180, 180)
bounds_mask = lat_valid & lon_valid & ~null_island
# 4. Precision normalization (5-6 decimals)
df.loc[bounds_mask, "lat"] = df.loc[bounds_mask, "lat"].round(6)
df.loc[bounds_mask, "lon"] = df.loc[bounds_mask, "lon"].round(6)
# 5. Semantic axis-inversion detection — assert CRS, never bare floats.
# For out-of-bounds rows, test whether swapping lat/lon lands inside the region.
swapped_points = gpd.GeoSeries(
gpd.points_from_xy(df["lat"], df["lon"]), crs="EPSG:4326", index=df.index
)
swapped_mask = ~bounds_mask & ~null_island & swapped_points.within(valid_region)
df.loc[swapped_mask, ["lat", "lon"]] = df.loc[swapped_mask, ["lon", "lat"]].values
bounds_mask = bounds_mask | swapped_mask
# 6. Spatial containment check (lon, lat order for Point(x, y))
points = gpd.GeoSeries(
gpd.points_from_xy(df["lon"], df["lat"]), crs="EPSG:4326", index=df.index
)
spatial_mask = pd.Series(False, index=df.index)
spatial_mask.loc[bounds_mask] = points[bounds_mask].within(valid_region).values
# 7. Compile validation status with machine-readable reasons
df["validation_status"] = np.where(spatial_mask, "PASS", "FAIL")
df["error_reason"] = np.select(
[
null_island,
swapped_mask,
~lat_valid | ~lon_valid,
bounds_mask & ~spatial_mask,
],
[
"NULL_ISLAND",
"AXIS_INVERSION_CORRECTED",
"OUT_OF_BOUNDS",
"OUTSIDE_VALID_REGION",
],
default="PASS",
)
fail_count = (df["validation_status"] == "FAIL").sum()
if fail_count > 0:
logging.warning("Validation failed for %d records. Quarantining.", fail_count)
return df
# Usage:
# valid_region = box(-125.0, 24.0, -66.0, 49.0) # CONUS bounding box (EPSG:4326)
# validated_df = validate_store_coordinates(raw_df, valid_region)
For a self-contained, row-level validation function with axis-swap heuristics and detailed error codes, see Automating coordinate validation with Python and Shapely. In production the box(...) placeholder should be replaced with a real boundary geometry loaded from a reference shapefile or GeoParquet layer — a coarse bounding box passes points in the Gulf of Mexico that a true land polygon would reject.
Edge Cases and Failure Modes
Coordinate validation requires continuous tuning as data sources evolve. The recurring failure modes below cause the most silent corruption:
- DMS-to-decimal conversion. Franchise submissions often use
DD°MM'SS.SS"strings. Parse degrees, minutes, and seconds explicitly with a regex before applying decimal bounds — a raw41°52'48"parsed as a float collapses to41.0, shifting the point hundreds of metres. - Floating-point artifacts. Direct equality checks on coordinates fail across distributed environments. Use tolerance-based comparisons (
ST_DWithinin PostGIS orbuffer()in Shapely) when matching against existing store footprints rather than==. - Null Island defaults. A coordinate of exactly
(0.0, 0.0)is almost never a real store; it is a failed geocoder emitting a default. Reject it explicitly so it never reaches the containment test, where it would silently fail as “outside region” and obscure the true cause. - Reference geometry drift. Administrative boundaries and zoning maps update on a quarterly cadence. A stale region polygon produces false negatives on legitimately-placed stores near a boundary that has since moved. Schedule topology-cleaning refreshes of the validation polygons.
- Antimeridian and pole-adjacent points. Bounding-box containment misbehaves across the ±180° seam and near the poles. Retail footprints rarely cross it, but multi-region chains should use a true polygon predicate (
within) instead of min/max comparisons.
Performance and Scaling
The reference function is fully vectorized — every check operates on whole pandas/geopandas columns rather than per-row Python loops, which keeps it near O(n) for the syntactic stages. The cost centre is the spatial containment test, where each point is evaluated against the region geometry. Three measures keep it scalable:
- Batch size. Process ingestion in chunks of roughly 50k–250k rows. Below 50k the per-batch overhead of constructing GeoSeries dominates; above ~250k the candidate geometry index and intermediate masks pressure memory on a typical worker.
- Spatial index reuse. When the region is a multi-polygon (e.g. all operating states), build an STR-tree /
sindexonce and reuse it across batches instead of rebuilding per chunk. Pre-filter with the index before the exactwithinpredicate to skip points that cannot possibly match. - Pushdown at the storage layer. Where coordinates already live in PostGIS, run the bounds and containment checks as SQL with a GiST index on the geometry column, so the database filters before data ever crosses the network into Python.
Memory management matters most for the swap-repair branch, which materializes a second GeoSeries; restrict it to the out-of-bounds subset (as the code does) so the full-table copy never happens.
Validation and QA Gates
Before any validated batch is promoted downstream, run an automated gate that asserts the output is internally consistent — not just that individual rows passed:
- Row-count conservation.
len(passed) + len(quarantined) == len(input). A mismatch means a mask dropped or duplicated rows. - Geometry validity. Every promoted point must be a valid
EPSG:4326geometry with no NaN ordinate. Assertpoints.is_valid.all()andpoints.crs.to_epsg() == 4326. - Bounding-box sanity. The min/max of the passed coordinates must fall inside the declared region’s envelope; an outlier here indicates a containment predicate misconfiguration.
- Reason-code coverage. Every
FAILrow must carry a non-PASSerror_reason. An empty orPASSreason on a failed row signals a gap in thenp.selectconditions. - Feed-level failure rate. Compute the failure ratio per source feed; if it exceeds
fail_rate_threshold(default 5%), block promotion and raise an alert rather than quietly quarantining a flood of records.
These checks belong in the same job that writes the curated output, so a failing gate halts promotion atomically.
Integration Notes
Coordinate validation is the first gate of the ingestion pipeline, so its output contract shapes everything downstream. Embed it directly in the landing-zone workflow: when configuring an S3 geospatial data lake, trigger an AWS Lambda or Glue job on the s3:ObjectCreated event, route passing records to a curated analytics/ prefix, and quarantine failures to staging/quarantine/ with their structured error_reason and a rule-version tag for audit. Attach validation timestamps so quarterly portfolio refreshes are reproducible.
Only validated, in-region points should flow into the next stages: the curated point layer becomes the origin set for drive-time isochrone generation and the join key for demographic enrichment. Promoting unvalidated coordinates is what makes point-in-polygon spatial joins fail in PostGIS and skews trade-area math, so the contract is strict: a record that has not passed all three rule layers never leaves this stage.
Frequently Asked Questions
Should a failing coordinate be dropped, corrected, or quarantined?
Quarantined, with a reason code, and never silently corrected. Dropping the record makes a store disappear from every downstream count, which shows up eventually as an unexplained gap in a market’s coverage; auto-correcting it — swapping the axes back, snapping to the address centroid — buries a data-entry problem that will recur every time the source system exports. A quarantine table with the failing rule attached turns each bad record into something a person can fix at the source, and it keeps the pipeline’s own numbers honest about what it refused.
How far from its address may a store’s coordinate legitimately sit?
Far enough to cover a genuine parking-lot entrance or a mall anchor position, which in practice means a threshold of one to two kilometres in urban areas and rather more in rural ones. A single global tolerance is the mistake: set it tightly and every rural store trips it, set it loosely and a coordinate that landed in the wrong town passes. Scaling the tolerance by the geocoder’s own precision indicator — rooftop, parcel, street, locality — matches the rule to the evidence behind each point.
Do these rules belong in the database or in the pipeline?
Both, at different strengths. The database should hold the constraints that must never be violated — a declared SRID, a valid geometry, a non-null coordinate — because they protect the store of record from any writer, including a hurried manual fix. The graduated, judgement-carrying rules belong in the pipeline, where they can quarantine rather than reject, carry a reason code, and change without a migration. A rule that lives only in the pipeline is one that a direct insert can bypass; a rule that lives only in the database can only ever say no.
What is the most common failure in practice?
Transposed latitude and longitude, and it is common precisely because it usually still parses. A store in the eastern United States with its axes swapped lands in the Indian Ocean and is caught immediately, but one near a diagonal where both values are in range simply moves a few hundred kilometres and looks plausible. Checking the point against the jurisdiction it claims to be in catches both cases for the price of one spatial join, which is why that rule earns its place before any of the subtler ones.
How should validation treat stores that are not yet open?
As first-class records with a status, not as noise to be filtered. A planned store has a coordinate that matters — it belongs in cannibalization estimates and in competitor sets long before it trades — but it will fail rules that assume a trading location, such as agreement with a sales-weighted centroid or presence in a delivery footprint. Carrying an explicit lifecycle status and scoping each rule to the statuses it applies to keeps pipeline geometry complete without producing a permanent list of expected failures that everyone learns to ignore.
What should happen when a coordinate changes for an existing store?
It should be treated as an event, recorded with its reason and its previous value, rather than as an update in place. Store coordinates move for two very different reasons — a correction to a bad geocode, or a genuine relocation — and downstream layers need to tell them apart: a correction invalidates the old catchment as wrong, while a relocation makes it historically correct but superseded. Keeping the history also makes the trend visible, and a store whose coordinate has been corrected three times is telling you something about the source that produced it.
How should the rules themselves be tested?
With fixtures that must fail. Every rule needs a record that violates it and is expected to be rejected, alongside a record that is legitimately unusual and must pass — a genuinely rural store for the isolation rule, a mall anchor set back from its address for the distance rule. Without the second half, a rule tightened during an incident quietly starts rejecting valid stores, and without the first, a rule that stops working through a refactor is indistinguishable from a rule with nothing to catch.
Related
- Automating coordinate validation with Python and Shapely — a runnable, row-level validator with detailed error codes
- Setting Up PostGIS for Retail Analytics — the spatial database that consumes validated points
- Configuring AWS S3 for Geospatial Data Lakes — the landing/curated zones where validation is triggered
← Back to Location Intelligence Architecture & Data Foundations