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.

What each method does at four kinds of corner A four-row by three-column matrix of geometric situations against how a negative buffer and a half-plane intersection each behave. Rows are a convex corner, a reflex corner, per-edge distances, and curved frontage digitised as many short segments. Columns are the buffer behaviour, the half-plane behaviour, and which one matches the ordinance. At a convex corner the buffer rounds by default where the ordinance implies a sharp corner. At a reflex corner the buffer can shave permitted area. Per-edge distances cannot be expressed by a buffer at all. What each method does at four kinds of corner buffer(-d) half-plane intersection matches the ordinance Convex corner rounds by default sharp intersection half-planes Reflex corner (L or flag lot) can shave permitted area correct by construction half-planes Different distance per edge not expressible native half-planes Curved frontage, many segments handled as one boundary needs collinear merging buffer, or merge first correct workable with care wrong here
The third row is decisive: a buffer takes one 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.

Envelope area on a 40 × 30 m lot at a uniform 5 m inset Lollipop chart of the buildable envelope area produced on a 40 by 30 metre lot at a uniform 5 metre inset, by three methods. Mitred joins and half-plane intersection both give 600 square metres. Round joins, which are the buffer default, give about 578 square metres — 21.5 square metres less, because each of the four corners is curved away. The loss is the area of four squares minus four quarter-circles of the inset radius. Envelope area on a 40 × 30 m lot at a uniform 5 m inset 0 200 400 600 800 half-plane intersection 600 m² buffer, join_style=2 (mitre) 600 m² buffer, join_style=1 (round, the default) 578.5 m² Envelope area (m²) Round-join loss = 4 × (1 − π/4) × d², computed at d = 5 m.
On a convex lot with a uniform inset, mitred joins and half-planes agree exactly. The default round join loses 21.5 m² — four corners each short by (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
Runtime and code size, relative to a single buffer call Grouped bar chart comparing a negative buffer against half-plane intersection on two axes, each expressed as a multiple of the buffer baseline. Runtime over a 41 000 parcel layer is 1 for the buffer and about 5 for half-planes, since one intersection runs per edge. Lines of code are 1 for the buffer and about 25 for half-planes. Both penalties are real and neither changes the fact that the buffer cannot express per-edge setbacks at all. Runtime and code size, relative to a single buffer call 0 10 20 30 1 5 runtime over 41 000 parcels 1 25 lines of code Multiple of the buffer baseline buffer(-d) half-plane intersection Lower is cheaper; the buffer cannot express per-edge distances at any price.
Both costs are real: roughly 5× the runtime and 25× the code. Neither is a reason to use the buffer for per-edge setbacks, because it cannot express them — but they are a good reason to run the cheap buffer first as a pre-filter and reserve half-planes for the parcels that survive it.

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.