Reprojecting Parcel Layers to State Plane in GeoPandas
State Plane coordinate systems give municipal analysts the low-distortion, locally accurate frame that zoning measurements demand, but choosing the wrong zone or the wrong unit variant quietly corrupts every area and setback that follows. This guide walks through selecting the correct State Plane EPSG code for a jurisdiction, reprojecting parcels with to_crs, and confirming that units and computed areas match the county record before the data feeds any compliance check. It is a focused application of the CRS standardization and datum management module, narrowed to the practical case of parcel fabric.
Prerequisites
Step-by-step
Step 1: Identify the correct State Plane EPSG code
State Plane zones come in datum and unit variants, and picking the right one is the decision that governs accuracy. A single county sits in exactly one zone, but that zone is published as both a metre realisation and a US-survey-foot realisation. For California zone 3 under NAD83, EPSG:26943 is the metre variant and EPSG:2227 is the US-survey-foot variant. Inspect the candidate before committing to it.
from pyproj import CRS
# California zone 3, NAD83 — metre and US survey foot realisations.
metric = CRS.from_epsg(26943)
foot = CRS.from_epsg(2227)
for crs in (metric, foot):
axis = crs.axis_info[0]
# unit_name distinguishes 'metre' from 'US survey foot' — the critical check.
print(crs.to_epsg(), axis.unit_name, "| projected:", crs.is_projected)
Step 2: Reproject the parcel layer
With the target chosen, reprojection is a single vectorized call. Confirm the source CRS is present first, because to_crs cannot transform a layer whose frame is undefined. Selecting a projected State Plane target here is what makes the later area computation valid, honouring the house rule that distance and area work only in a linear CRS.
import geopandas as gpd
TARGET_CRS = "EPSG:26943" # NAD83 / California zone 3 (metres)
parcels = gpd.read_file("parcels.gpkg")
if parcels.crs is None:
raise ValueError("Assign the true source CRS before reprojecting.")
# Full datum-aware transformation into the authoritative State Plane frame.
parcels = parcels.to_crs(TARGET_CRS)
print("Now in:", parcels.crs.name)
Step 3: Validate the linear unit
The most damaging State Plane error is a unit mismatch: treating survey feet as metres inflates every distance by a factor of about 3.28. Read the unit directly from the active CRS rather than assuming it, and convert deliberately when an ordinance is written in different units than your frame.
unit = parcels.crs.axis_info[0].unit_name
print("Working unit:", unit)
# One US survey foot is 1200/3937 metres exactly; use this for deliberate conversion.
US_FOOT_IN_M = 1200 / 3937
if unit == "metre":
# Example: express a 7.62 m computed distance in survey feet for a foot-based code.
setback_ft = 7.62 / US_FOOT_IN_M
print(f"7.62 m == {setback_ft:.2f} US survey feet")
Step 4: Verify computed areas against a known reference
Area is the acid test. Compute parcel areas in the projected frame, convert to the reporting unit, and compare against a trusted figure. A close match confirms both the zone and the unit are correct.
# Area comes back in the CRS unit squared (square metres here).
parcels["area_m2"] = parcels.geometry.area
parcels["area_acres"] = parcels["area_m2"] / 4046.8564224 # m^2 per acre
sample = parcels.iloc[0]
print(f"Parcel {sample.get('apn', 'n/a')}: {sample['area_acres']:.3f} acres")
Verification
Confirm three things before releasing the reprojected layer downstream. First, the active CRS is projected and in the expected unit: parcels.crs.is_projected is True and parcels.crs.axis_info[0].unit_name matches your intent. Second, feature counts are unchanged by reprojection; to_crs never adds or drops rows, so a different length signals an unrelated bug. Third, a sampled area agrees with the county record to within survey tolerance.
assert parcels.crs.is_projected, "Target CRS is not projected."
assert len(parcels) == len(gpd.read_file("parcels.gpkg")), "Row count changed."
delta = abs(parcels["area_acres"].iloc[0] - 0.172) # county-reported acreage
print("Area agrees" if delta < 0.005 else f"Investigate: off by {delta:.3f} ac")
The three assertions above are worth keeping as a reusable check rather than a one-off script, because they are exactly the checks that stop being run once the pipeline “works”. Wiring them into the ingest step means a supplier who quietly changes their export frame is caught by a failing run rather than by an anomalous report three weeks later.
It is worth knowing the shape of each failure, because they are distinguishable from the numbers alone. A feet-for-metres swap scales every area by about 10.76, which is unmistakable once you look for it. Choosing an adjacent zone leaves areas almost right, off by a fraction of a percent that grows with distance from the zone’s central meridian — small enough to survive review and large enough to matter on a tight FAR calculation. Leaving the layer in a geographic frame produces areas in square degrees, numbers so small they are usually spotted immediately. The dangerous middle case is the adjacent zone, which is why the acreage comparison in step 4 is not optional.
Common Pitfalls
- Confusing feet and metres. EPSG:2227 and EPSG:26943 describe the same zone in different units. A silent swap scales every measurement by roughly 3.28, which passes casual inspection but fails audit. Always read
unit_namefrom the CRS. - Choosing an adjacent zone. Parcels near a zone boundary still belong to their assigned zone; borrowing a neighbour’s zone introduces distortion that grows with distance from the central meridian.
- Reprojecting a layer with an undefined CRS. Assigning a frame with
set_crsis not the same as transforming withto_crs. Using one for the other either mislabels coordinates or moves them incorrectly, a distinction detailed in the sibling handling datum shifts from NAD83 to WGS84 guide.
Frequently Asked Questions
How do I find the right State Plane zone for my jurisdiction?
Consult an authoritative State Plane zone map or the EPSG registry filtered by state, then confirm the county falls entirely inside one zone. Most counties map to a single zone; a few large or oddly shaped counties are split, in which case use the zone that covers your specific study area. Verify by reprojecting a test parcel and checking that its area matches the county record.
Should I standardize on the metre variant or the US survey foot variant?
Match the unit your regulatory code speaks. If ordinances state setbacks in feet, working in the survey-foot realisation avoids conversion at every rule check and reduces rounding error. If you integrate with metric national datasets, the metre variant is cleaner. Whichever you pick, declare it once and enforce it everywhere, as recommended in the parent CRS standardization and datum management module.
Why did my parcel areas come out wildly wrong after reprojecting?
The usual cause is a unit mismatch or an undefined source CRS that was assumed rather than assigned. Check that the source layer declared its true frame before transformation, and read the target CRS unit with axis_info. An area that is off by a factor near 10.76 points squarely at a feet-versus-metres error.
Does reprojecting to State Plane change the number of features?
No. A coordinate transformation moves vertices but never adds or removes rows. If your feature count changes, the cause is elsewhere, such as an earlier filter, a failed read, or a geometry repair step that dropped empty geometries after transformation.
What if my study area spans two State Plane zones?
Process each zone in its own frame and reconcile at the boundary, or adopt a single custom projection defined for the whole area and record its full definition in the run manifest. What does not work is choosing one zone and accepting the distortion across the other, because the error grows with distance from the central meridian and will be largest exactly where the two jurisdictions meet — the parcels most likely to be contested.
Should I reproject once at ingest or per analysis step?
Once, at ingest. Repeated reprojection accumulates floating-point error, costs time proportional to vertex count, and creates the opportunity for two steps to work in different frames without noticing. Establish the frame during ingestion, assert it at every stage boundary, and treat any later transformation as a presentation step that produces an output copy rather than a new working layer.
How do I know the county’s published acreage is the right thing to compare against?
Treat it as a strong cross-check rather than ground truth. Assessor acreages are themselves computed from a parcel fabric with its own history, and they are often rounded or carried forward from an older survey. Agreement to within a few thousandths of an acre confirms your zone and unit are right; a systematic difference across many parcels points at your configuration, while scattered differences on individual parcels usually point at their boundary history.
Related
Part of: CRS standardization and datum management
- Choosing a projected CRS for a municipal compliance project — the decision behind the EPSG code used here.
- Handling datum shifts from NAD83 to WGS84 — the datum step that precedes projection.
- Validating CRS metadata before a compliance run — turning these assertions into a pre-flight gate.
- Unit conversion pitfalls in setback thresholds — the other place feet and metres collide.