shapely buffer vs manual offsets for setback geometry
lot.buffer(-7.62) is one line, fast, well-tested, and correct — for the specific case where every edge takes the same setback. Municipal zoning almost never specifies that, so the question is not which call is better but which of the two problems you have: a uniform inset, or a per-edge inset that has to survive reflex corners and curved frontage. This page compares them on the properties that decide it, so the choice is made on the geometry rather than on which one somebody wrote first. It sits under development capacity & buildable area analysis.
Diagnosis: what each approach actually does jump to heading
A negative buffer computes the set of points at least d from the boundary — a distance operation. Half-plane intersection computes the set of points on the inner side of every offset edge line — a constraint operation. They agree on a convex polygon with a uniform setback and diverge everywhere else.
At a convex corner the buffer rounds or mitres according to join_style, while half-planes produce the sharp intersection of the two offset lines. For setbacks the sharp corner is correct: the ordinance constrains distance from each lot line independently, and it does not curve around corners.
distance argument, so per-edge setbacks are not expressible. The first row is the quiet one — the buffer's default round join curves the envelope away from every corner, which is why negative-buffer setbacks come out slightly small.At a reflex corner — an L-shaped or flag lot — the buffer’s behaviour depends on the interaction of offsets from both edges and can round off area the ordinance permits. Half-planes handle it by construction, because each constraint is independent.
With different distances per edge the buffer simply cannot express the problem; there is one distance argument.
from shapely.geometry import Polygon, box
L_SHAPED = Polygon([(0, 0), (30, 0), (30, 12), (12, 12), (12, 30), (0, 30)])
def compare(lot, d=7.62):
"""Same uniform distance, both methods, on a lot with a reflex corner."""
return {
"buffer_mitre": round(lot.buffer(-d, join_style=2).area, 2),
"buffer_round": round(lot.buffer(-d, join_style=1).area, 2),
"halfplanes": round(halfplane_inset(lot, d).area, 2),
# On a convex lot all three agree; on L_SHAPED they do not.
}
Step-by-step implementation jump to heading
1. Use the buffer where it is genuinely the right tool jump to heading
FT = 0.3048
def uniform_inset(lot, distance_ft, *, join_style=2):
"""Correct and preferable when every edge takes the SAME setback: a conservative
fallback where frontage is unknown, a wetland or easement buffer, or a
'no structures within X of any lot line' rule.
join_style=2 (mitre) matches how a setback behaves at a corner. The default
round join is wrong here — it curves the buildable area away from the corner.
"""
out = lot.buffer(-distance_ft * FT, join_style=join_style)
return out # may be empty or a MultiPolygon; the caller must handle both
The join_style default is worth calling out: buffer defaults to round joins, which for a setback shaves area off every corner. Anyone using a negative buffer for setbacks and getting slightly small answers is usually hitting this.
(1 − π/4)d² — which is small, systematic, and in the direction that understates capacity.2. Use half-planes for per-edge distances jump to heading
import math
from shapely import affinity
from shapely.geometry import box as _box
def halfplane_inset(lot, distances_by_edge):
"""`distances_by_edge` maps edge index → inset distance in metres. Each edge
contributes one constraint, independent of the others, which is what makes reflex
corners and mixed distances work."""
coords = list(lot.exterior.coords)
result = lot
span = max(lot.bounds[2] - lot.bounds[0], lot.bounds[3] - lot.bounds[1]) * 2 + 10
for i in range(len(coords) - 1):
d = distances_by_edge.get(i, 0.0)
if d <= 0:
continue
(ax, ay), (bx, by) = coords[i], coords[i + 1]
ux, uy = bx - ax, by - ay
length = math.hypot(ux, uy)
if length == 0:
continue # duplicate vertex: no constraint
ux, uy = ux / length, uy / length
nx, ny = -uy, ux
rp = lot.representative_point()
if (rp.x - ax) * nx + (rp.y - ay) * ny < 0:
nx, ny = -nx, -ny # point the normal inward
plane = affinity.rotate(_box(0, 0, span, span),
math.degrees(math.atan2(uy, ux)), origin=(0, 0))
plane = affinity.translate(plane, ax + nx * d, ay + ny * d)
result = result.intersection(plane)
if result.is_empty:
return result
return result
3. Compare them on the axes that decide it jump to heading
| Property | buffer(-d) |
half-plane intersection |
|---|---|---|
| Per-edge distances | impossible | native |
| Corner behaviour | join_style-dependent; round by default |
sharp, matching the ordinance |
| Reflex corners | can shave permitted area | correct by construction |
| Curved frontage (many short segments) | handled as one boundary | needs collinear merging first |
| Empty / MultiPolygon results | yes, must be handled | yes, must be handled |
| Lines of code | 1 | ~25 |
| Speed on 41 000 parcels | fast — one C call | ~4–6× slower; one intersection per edge |
| Failure mode | silently understates | raises or returns empty |
| Well-tested by others | extensively | your tests only |
4. Compose them: buffer for the fallback, half-planes for the real answer jump to heading
def setback_envelope(lot, std, edge_classes):
"""The practical arrangement: half-planes when the edges are classified, a mitred
buffer at the largest setback when they are not — and a note either way."""
if edge_classes:
distances = {ec.index: std.numeric[f"{ec.kind}_setback_ft"] * FT
for ec in edge_classes}
return halfplane_inset(lot, distances), "per-edge half-planes"
worst = max(std.numeric[k] for k in
("front_setback_ft", "side_setback_ft", "rear_setback_ft"))
return uniform_inset(lot, worst), f"uniform buffer at {worst} ft (floor, not estimate)"
Verification & testing jump to heading
def test_the_two_agree_on_a_convex_lot_with_a_uniform_setback():
lot = box(0, 0, 40, 30)
d = 5.0
a = lot.buffer(-d, join_style=2)
b = halfplane_inset(lot, {i: d for i in range(4)})
assert abs(a.area - b.area) < 1e-6
def test_round_joins_understate_a_setback_envelope():
lot = box(0, 0, 40, 30)
d = 5.0
assert lot.buffer(-d, join_style=1).area < lot.buffer(-d, join_style=2).area
def test_halfplanes_keep_area_at_a_reflex_corner():
d = 5.0
buffered = L_SHAPED.buffer(-d, join_style=2)
planes = halfplane_inset(L_SHAPED, {i: d for i in range(6)})
assert planes.area >= buffered.area - 1e-9
def test_buffer_cannot_express_per_edge_distances():
"""Not a code test — a design one. Kept as documentation of why the extra 25
lines exist, so nobody 'simplifies' it back to a buffer."""
lot = box(0, 0, 18, 40)
front, side, rear = 25 * FT, 8 * FT, 20 * FT
per_edge = halfplane_inset(lot, {0: front, 1: side, 2: rear, 3: side})
best_uniform = max(lot.buffer(-x, join_style=2).area for x in (front, side, rear))
assert per_edge.area > best_uniform
def test_both_can_return_empty_or_multipolygon():
narrow = box(0, 0, 6, 40)
assert narrow.buffer(-5, join_style=2).is_empty
assert halfplane_inset(narrow, {i: 5.0 for i in range(4)}).is_empty
For a batch check, run both across a real county extract and plot the ratio: it should be near 1.0 for regular convex lots and diverge for the shape classes that matter. A ratio near 1.0 everywhere means the half-plane code is not actually being used, usually because edge classification is silently returning nothing.
Failure recovery jump to heading
Setbacks computed with a round-joined buffer. Every envelope is slightly small, uniformly, and the error is largest on lots with many corners. Recompute with join_style=2 or half-planes; the diff is small per parcel and systematic, which is exactly the kind of error that survives review for years.
Half-plane code returning empty on lots that should work. Almost always the inward-normal test. Assert on a known lot that the result is non-empty and contains the lot’s representative point; if it does not, the normals are pointing outward and every constraint is inverted.
Performance regression after switching. One intersection per edge is four to six times slower than one buffer call, which matters on a full county. Filter first: run the cheap buffer to identify parcels where the envelope is obviously empty, and reserve half-planes for the ones that survive.
Frequently asked questions jump to heading
Is a negative buffer ever the right tool for setbacks?
Yes, in two situations. When every edge genuinely takes the same distance — some ordinances write “no structure within 20 feet of any lot line” — and as the conservative fallback when frontage cannot be determined, where the largest setback is applied to every edge and the result is labelled a floor rather than an estimate. In both cases use mitred joins.
Why does join_style matter so much?
Because the default is round, which curves the buildable area away from each corner and quietly removes area the ordinance permits. A setback is a distance from each lot line considered independently; it has no radius. Mitred joins reproduce that, which is why join_style=2 is not a stylistic preference here.
Are half-planes worth 25 lines and a slowdown?
Whenever setbacks differ by edge, which is nearly always. The buffer cannot express the problem at all, so the alternative is applying one distance to every edge and understating capacity — typically by half on a narrow urban lot. Where speed matters, use the buffer as a cheap pre-filter and half-planes for the parcels that survive it.
What about parallel_offset on the boundary?
It offsets a linestring rather than constraining a polygon, so reassembling the offset segments into a closed ring is left to you — and that reassembly is exactly where reflex corners produce self-intersections. Intersecting half-planes never needs the ring to be rebuilt, which is why it is the more robust construction for this problem.
Related jump to heading
- Parent topic: Development Capacity & Buildable Area Analysis
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Computing a buildable envelope from setbacks and height limits — the per-edge construction in context
- Handling irregular and flag lots in setback computation — the reflex-corner shapes where the two methods diverge most
- Spatial Database Indexing & Performance — pre-filtering before the expensive geometry runs