Apportioning Split-Zoned Parcels by Area

A split-zoned parcel — one lot carrying two or more zoning districts — breaks the assumption every simple rule engine makes, which is that a parcel has a district. Assigning the majority district discards the minority one; assigning both and evaluating twice produces two contradictory verdicts. For quantity standards there is a third answer that is usually the one the ordinance intends: apportion the standard in proportion to the area in each district. This guide computes those shares reliably, applies them to the standards that can be apportioned, and identifies the ones that cannot. It is the split-parcel case of land use intersection mapping.

Prerequisites

Step-by-step

Step 1: Compute the shares with one overlay

The shares come from an intersection overlay, aggregated per parcel and district. Doing it in one pass keeps the numbers consistent — computing each district’s share with a separate query invites shares that do not sum to one.

import geopandas as gpd

def district_shares(parcels, districts, dist_col="zoning_district"):
    """Area of each parcel in each district, as an absolute area and a share."""
    parts = gpd.overlay(parcels[["parcel_id", "geometry"]],
                        districts[[dist_col, "geometry"]],
                        how="intersection", keep_geom_type=True)
    parts["part_area"] = parts.area
    agg = (parts.groupby(["parcel_id", dist_col])["part_area"].sum().reset_index())
    lot = parcels.set_index("parcel_id").area
    agg["lot_area"] = agg["parcel_id"].map(lot)
    agg["share"] = agg["part_area"] / agg["lot_area"]
    return agg

keep_geom_type=True is doing necessary work: overlaying two polygon layers that share boundaries produces line and point fragments along those boundaries, and while they carry no area they do carry rows, which corrupts a count and confuses the geometry type of the result.

One overlay, then the residue, then the sharesDistrict shares come from a single intersection overlay; the gap to one is split into floating-point noise and genuinely unmapped area before anything is decided.Intersect parcels with districtsone pass; keep_geom_type drops the line fragmentsSum the area per parcel and districtabsolute area and share of the lotSeparate slivers from unmapped areaa hole in the district layer is not a districtRenormalise the survivors to oneso downstream arithmetic is safe
Shares summing to 0.97 understate every apportioned quantity by three percent — small enough to survive review, large enough to matter on a big site.

Step 2: Absorb the residue before deciding anything

The shares will not sum to exactly one. Some of the gap is floating-point noise; some is a genuine hole where the district layer does not cover the parcel — an unmapped right-of-way, a water body, a mapping gap. The two need different treatment and conflating them is how a parcel ends up with 3% of its area in a district nobody can name.

SLIVER_SHARE = 0.005     # half a percent: below any plausible regulatory relevance

def resolve_residue(agg, tol=1e-9):
    covered = agg.groupby("parcel_id")["share"].sum()
    residue = 1.0 - covered
    unmapped = residue[residue > SLIVER_SHARE]
    if len(unmapped):
        print(f"{len(unmapped)} parcels have unmapped area above {SLIVER_SHARE:.1%}")
    # Drop slivers, then renormalise so the remaining shares sum to one.
    keep = agg[agg["share"] >= SLIVER_SHARE].copy()
    total = keep.groupby("parcel_id")["share"].transform("sum")
    keep["share"] = keep["share"] / total
    return keep, unmapped

Renormalising after dropping slivers is the part that makes downstream arithmetic safe: a permitted unit count computed from shares that sum to 0.97 is 3% low, which is small enough to survive review and large enough to matter on a large site. The unmapped list is separate output and belongs in front of a person — it usually means the district layer needs extending, not that the parcel is unusual.

Step 3: Classify each standard as apportionable or not

This is the judgement the whole technique depends on, and it follows from what the standard measures.

APPORTIONABLE = {
    "max_dwelling_units": "quantity",       # units allowed = sum(share x density x area)
    "max_floor_area": "quantity",
    "min_lot_area_per_unit": "quantity",
    "max_impervious_fraction": "fraction",  # weighted average of the district limits
}

NOT_APPORTIONABLE = {
    "front_setback_ft": "attaches to a boundary, not to an area",
    "max_height_ft": "attaches to a structure at a location",
    "use_permitted": "a use is allowed or it is not",
}

A density standard is a quantity per unit area, so it apportions naturally: the parcel’s total permitted units is the sum over districts of that district’s density times the parcel’s area in it. A setback does not apportion at all — it attaches to a lot line, and the district governing that lot line is the one that applies. Height attaches to a structure, so the district containing the structure governs. Use is categorical: apportioning a permitted use produces nonsense.

Which standards apportion, and which cannotStandards grouped by what they measure, with the operation each takes when a parcel carries two districts.OperationWhyDwelling units, floorareaSum of rate x areaA quantity per unit areaImpervious, openspaceArea-weighted averageA fraction of the lotSetbacksDoes not apportionAttaches to a lot lineHeight, permitted useDoes not apportionAttaches to a structure; or categorical
Summing fractional limits gives a parcel permitted coverage above 100%; averaging quantity limits gives it a fraction of its entitlement. Both are silent.

Step 4: Apportion the quantity standards

With shares in hand and standards classified, the arithmetic is short.

def apportioned_quantity(shares, standards, lot_area, measure="max_dwelling_units"):
    """Sum over districts of (this district's rate) x (parcel area in this district)."""
    total = 0.0
    detail = []
    for _, row in shares.iterrows():
        rate = standards[row["zoning_district"]][measure]      # e.g. units per acre
        area_here = row["share"] * lot_area
        contribution = rate * area_here
        total += contribution
        detail.append({"district": row["zoning_district"],
                       "share": round(row["share"], 4),
                       "rate": rate, "contribution": contribution})
    return {"total": total, "by_district": detail}

Keeping the per-district detail is not optional. The apportioned total is the number that goes in a determination, and the only way anyone can check it is by seeing the shares and rates that produced it. This is the same reasoning that makes the applicability records in deciding which parcels a rule applies to worth storing.

Step 5: Handle the fractional standards as weighted averages

Standards expressed as a fraction of the lot — impervious coverage, open space — are area-weighted averages rather than sums.

def apportioned_fraction(shares, standards, measure="max_impervious_fraction"):
    return sum(row["share"] * standards[row["zoning_district"]][measure]
               for _, row in shares.iterrows())

The distinction between this and step 4 is easy to get wrong in either direction, and both errors are silent. Summing fractional limits gives a parcel a permitted impervious cover above 100%; averaging quantity limits gives it a fraction of the units it is entitled to.

Step 6: Route the standards that do not apportion

For non-apportionable standards the pipeline must resolve to one district per instance of the standard, and say which.

def resolve_non_apportionable(measure, edge_or_structure_geom, districts):
    """A setback follows its lot line; a height follows its structure."""
    hits = districts[districts.intersects(edge_or_structure_geom)]
    if len(hits) == 1:
        return {"district": hits.iloc[0]["zoning_district"], "basis": "sole district"}
    if len(hits) == 0:
        return {"district": None, "basis": "no district covers this feature — review"}
    # A lot line or structure spanning a district boundary: the stricter standard governs.
    strictest = max(hits["zoning_district"], key=lambda d: STANDARDS[d][measure])
    return {"district": strictest, "basis": "spans a district boundary; stricter governs"}
A standard that cannot be apportioned still needs an answerFor a boundary-attached or categorical standard the pipeline resolves to one district per instance, and says on what basis.Does exactly one districtcover this lot line orstructure?noThe stricter standard governs, and it isflaggedor no district covers it at all — reviewyesThat district governsbasis recorded as "sole district"Store the shares with the district layer version that produced themthey are the input to several standards
The stricter-governs default is the conservative reading rather than a universal rule; where it decides a determination, it belongs in review.

A structure that itself straddles the district line is the genuinely hard case, and the stricter-governs default above is the conservative reading rather than a universal rule. Where it materially affects a determination it belongs in review, routed the way variance and exception handling describes.

Verification

Shares must be complete, apportioned quantities must be bracketed by the single-district answers, and nothing may be double-counted.

sums = shares.groupby("parcel_id")["share"].sum()
assert ((sums - 1.0).abs() < 1e-6).all(), "shares do not sum to one after renormalising"
assert (shares["share"] > 0).all(), "zero-share rows survived the sliver filter"

# An apportioned quantity must lie between the all-in-one-district extremes.
rates = [STANDARDS[d]["max_dwelling_units"] for d in shares["zoning_district"]]
lo, hi = min(rates) * lot_area, max(rates) * lot_area
assert lo - 1e-6 <= result["total"] <= hi + 1e-6, "apportioned total outside its bounds"

# Areas must reconcile against the parcel.
assert abs(shares["part_area"].sum() - covered_area) < 1.0
print(f"{(sums.index.size)} split parcels; "
      f"{(shares.groupby('parcel_id').size() > 2).sum()} carry three or more districts")

The bracketing assertion is the useful one. Any apportionment bug that swaps a rate, drops a district or mis-normalises a share pushes the total outside the range spanned by the single-district answers, and that is a cheap, general check that does not depend on knowing the right answer.

Common Pitfalls

  • Picking the majority district and moving on. It discards a real entitlement, and on a parcel split 55/45 it is close to arbitrary.
  • Not renormalising after dropping slivers. Shares summing to 0.97 understate every apportioned quantity by 3%, silently.
  • Treating unmapped area as a district. A hole in the district layer is a data problem, and absorbing it into a neighbouring district hides it permanently.
  • Apportioning a setback. A setback attaches to a lot line. There is no meaningful “60% of a 25-foot setback”.
  • Summing fractional limits. A weighted average is the correct operation for coverage-type standards; summing gives limits above 100%.
  • Overlaying in a geographic CRS. Shares computed from degrees are wrong by a latitude-dependent factor, and they still sum to one, so they look fine.

Frequently Asked Questions

Does the ordinance usually address split zoning directly?

More often than people expect. Many codes contain a split-lot provision, and the treatments vary widely: some apportion as described here, some apply the more restrictive district to the whole lot, some let the majority district govern where the minority share is below a stated threshold, and a few require the lot to be divided. Implementing the local provision beats implementing the general technique — this guide is what to do when the code is silent.

What threshold makes a parcel “split” rather than a sliver artefact?

Half a percent works as a default and should be checked against the fabric. The test is the distribution: on a clean fabric, shares cluster near 0 and 1 with a thin population between, and the threshold belongs in that gap. A fat middle means the district and parcel layers are not aligned, which is a topology problem to fix before apportioning anything.

How does this interact with the density surface?

Apportioned per-parcel entitlements are the right input to an aggregate density surface, because they reflect what each parcel is actually permitted. Feeding a single-district assignment into aggregating density to H3 hexagons introduces an error concentrated exactly along district boundaries, which is where planners look most closely.

Can three or more districts be handled the same way?

Yes — the arithmetic generalises without modification. Parcels with three districts are rare but real, usually large or historically assembled lots, and they are worth counting each run since a sudden increase indicates a district layer change rather than a parcel change.

Should the shares be stored or recomputed?

Stored, with the district layer version that produced them. They are stable between district amendments, they are the input to several standards, and recomputing them per rule is both wasteful and a source of inconsistency if one rule normalises differently from another.

What about a parcel split between two jurisdictions?

That is a different problem with a different answer — two authorities, not two districts, and apportioning between them is usually wrong because each jurisdiction regulates its own part completely. Jurisdictional boundary and precedence resolution covers it.

Is the classification of standards stable across jurisdictions?

The reasoning is — quantities apportion, fractions average, boundary-attached and categorical standards do not apportion — but the mapping of a particular code’s standards onto those categories is local work. Keep the classification with the rule pack rather than in the apportionment code, so that a jurisdiction whose code treats floor area as non-apportionable can say so without a code change.

Part of: Land use intersection mapping