Proximity & Buffer Analysis in Python: Metric-Correct Distance Workflows
A buffer grows a fixed-distance zone around a feature, and proximity analysis measures how close features sit to one another — together they answer every "within X metres" question that runs through Spatial Analysis & Advanced Query Techniques. The two operations are inseparable in practice: a buffer defines a catchment, and a proximity query decides what falls inside it. Both are only correct in a projected, metric coordinate reference system, so this guide treats CRS discipline as a first-class concern rather than an afterthought. It covers the GeoPandas buffer API and its cap/join parameters, the spatial indexing that keeps nearest-neighbour lookups off the O(n²) path shared with Nearest Neighbor & KD-Tree Search, and how buffer output feeds straight into Geometric Intersections & Overlays; the scaling path for millions of features lives in Optimizing Buffer Operations for Large Datasets.
Architecture & Data Structures
A buffer in Python is a constructive geometry operation: GeoPandas exposes GeoSeries.buffer(distance), which dispatches to the vectorized GEOS engine underneath Shapely geometry operations and returns a new GeoSeries of polygons — one per input feature, whatever the input geometry type. Points become circles, lines become capsules, and polygons grow outward (or, with a negative distance, shrink inward). The distance is interpreted in the units of the layer's CRS, which is the single fact that governs whether the result is meaningful, and it is a value the API will never check for you.
Proximity, by contrast, is a query rather than a construction. It ranks or filters features by distance, and at any real scale it is backed by a spatial index — an R-tree (GeoDataFrame.sindex, backed by Shapely 2.0's STRtree) for geometry-aware bounding-box search, or a KDTree/cKDTree over raw coordinate arrays for exact point-to-point nearest-neighbour work.
import geopandas as gpd
# One point layer of utility sites; the buffer API returns a new polygon GeoSeries
sites = gpd.read_parquet("utility_sites.parquet") # CRS carried in the file metadata
service_zones = sites.geometry.buffer(500) # 500 in CRS units — metres only if projected
print(service_zones.geom_type.unique()) # ['Polygon']
print(sites.crs.axis_info[0].unit_name) # must read 'metre', not 'degree'
Hold onto the mental model that a buffer is a cut of space, not an attribute: the returned polygons carry no columns of their own until you attach them back to a GeoDataFrame. Everything downstream — dissolving overlapping catchments, counting what falls inside, intersecting with land use — follows from treating that polygon column as the new geometry of an analysis layer.
Internally GEOS does not draw a circle. It walks the input geometry's edges, generates an offset curve at the requested distance on each side, closes the ends according to cap_style, stitches the corners according to join_style, and then nodes and cleans the resulting ring. Four behaviours fall directly out of that algorithm, and each of them surprises somebody eventually:
- Curves are polygonal approximations. A "circle" from
buffer(500)is a regular polygon with4 × resolutionvertices, so its area is always slightly less than π·r². At the defaultresolution=8the error is about 0.5%; at 16 it drops near 0.1%. If you are reporting catchment areas to two decimal places, that difference is real. - A negative distance erodes.
buffer(-25)offsets the ring inward, which is the standard way to derive a setback or to shave a sliver off a boundary. Any feature narrower than 50 m across vanishes entirely and returns an empty polygon rather than raising. - Holes behave like reversed rings. Buffering a polygon with an interior ring grows the shell outward and shrinks the hole inward by the same distance, so small holes disappear at modest buffer distances. That is usually what you want for a coverage question and never what you want if the hole is a legal exclusion zone.
- Buffering merges nothing. Two overlapping 500 m circles come back as two overlapping polygons, not one union. Counting area over the raw buffer output double-counts every overlap — the
dissolvestep later in this guide exists precisely to prevent that.
Shapely 2.0 also exposes single_sided=True, which offsets only one side of a line, and the sign of the distance chooses the side. That is the correct tool for corridors that are asymmetric by definition — a right-of-way that extends 30 m from one edge of a rail line, a riparian zone on the north bank only — where a symmetric buffer would silently include land that is not part of the analysis.
Environment Configuration & Dependency Resolution
Buffer topology and the vectorized proximity path both depend on Shapely 2.0, which rewired constructive operations onto array-level GEOS calls and added make_valid. Pin a floor of shapely>=2.0 and keep GEOS/GDAL/PROJ aligned by installing the whole stack from a single channel — mixing a pip GEOS wheel with a conda GEOS build silently shifts buffer vertex counts and predicate results between versions.
# conda-forge keeps GEOS/GDAL/PROJ consistent across the whole stack
conda create -n proximity -c conda-forge \
"python=3.12" "geopandas>=1.0" "shapely>=2.0" \
"pyproj>=3.6" "scipy>=1.11" "pyarrow>=14"
conda activate proximity
# Fail loudly if the vectorized constructive layer is unavailable
import shapely
assert tuple(int(p) for p in shapely.__version__.split(".")[:2]) >= (2, 0), (
"Shapely 2.0+ is required for vectorized buffer, make_valid, and STRtree."
)
scipy supplies the KDTree/cKDTree used for exact nearest-neighbour queries; for radius joins and bounding-box prefilters you need nothing beyond GeoPandas, whose sindex is built lazily on first access. Add dask-geopandas only when a single machine can no longer hold the data — that path is covered in Scaling with Dask-GeoPandas.
Three version boundaries decide whether code written against an older stack still runs:
cap_styleandjoin_styleaccept strings only from Shapely 2.0. Older code passes the integer constants (cap_style=1for round,2for flat,3for square) or theshapely.geometry.CAP_STYLEenum. Both forms work on 2.x, so string literals are the portable choice going forward, but an integer in a codebase you inherit is not a bug.unary_unionbecameunion_all()in GeoPandas 1.0. The old name still resolves with a deprecation warning; the new one takes amethod=argument and agrid_size=that fixes many of the sliver problems that used to force abuffer(0)repair.sjoingainedpredicate="dwithin"in GeoPandas 0.14. That matters here because it answers "everything within X metres" without materialising buffer polygons at all — the R-tree is queried with an expanded envelope and the exact distance test runs on the survivors. On a point layer it is both faster and less memory-hungry than buffering first.
Version skew in the compiled stack is subtler and worth ruling out early. GEOS is what actually generates the offset curve, so two environments on different GEOS builds can return buffers with different vertex counts and areas that differ in the sixth decimal place. That is harmless for a map and fatal for a regression test with an exact-equality assertion. Pin the whole stack from one channel, record shapely.geos_version alongside your results, and compare areas with a relative tolerance rather than ==.
import geopandas as gpd, shapely, pyproj
print(gpd.__version__, shapely.__version__, shapely.geos_version, pyproj.proj_version_str)
# 1.0.1 2.0.4 (3, 12, 1) 9.4.0 — record this next to any published area figure
Vectorized Operations & Core Workflow
A production proximity job is a fixed sequence: ingest, align to one metric CRS, buffer, dissolve, index, and query. Every stage is vectorized — the C engine processes the whole geometry array at once, so never loop rows when a native method exists. The block below runs end to end, buffering utility sites into service catchments and counting the parcels that fall inside each.
sindex keeps the sjoin off the O(n²) path.import geopandas as gpd
# 1. Ingest and align to ONE projected CRS (metres, not degrees)
sites = gpd.read_parquet("utility_sites.parquet")
parcels = gpd.read_parquet("parcels.parquet")
if sites.crs.is_geographic:
metric = sites.estimate_utm_crs() # auto-pick the right UTM zone
sites = sites.to_crs(metric)
parcels = parcels.to_crs(sites.crs) # both layers share ONE CRS
# 2. Buffer the sites into 500 m service catchments
zones = sites.copy()
zones["geometry"] = sites.geometry.buffer(500)
# 3. Dissolve overlapping catchments of the same operator into one polygon
service_areas = zones.dissolve(by="operator").reset_index()
# 4. Proximity query: which parcels fall inside a catchment? (sjoin uses sindex)
parcels_in_reach = gpd.sjoin(
parcels, service_areas[["operator", "geometry"]],
how="inner", predicate="within",
)
# 5. Aggregate — parcels served per operator
coverage = (
parcels_in_reach.groupby("operator")["parcel_id"].nunique()
.rename("parcels_served").reset_index()
)
service_areas.to_parquet("service_areas.parquet", compression="zstd")
Using dissolve rather than a plain groupby keeps the result a GeoDataFrame and unions the overlapping per-operator circles into single contiguous catchments, so the CRS and geometry column survive the aggregation intact. The sjoin in step 4 is the proximity query: it consults the R-tree on service_areas to prune candidate pairs before running the exact within predicate, which is what keeps the join off the O(n²) brute-force path.
When the buffer polygons are a means rather than an end — you need the count, not the geometry — skip building them:
# Same question, no buffer geometry ever allocated (GeoPandas 0.14+)
in_reach = gpd.sjoin(
parcels, sites[["site_id", "geometry"]],
how="inner", predicate="dwithin", distance=500,
)
The distance is again in CRS units, so this needs the same projected frame. On a few hundred thousand parcels against a few thousand sites, the dwithin form typically runs in a fraction of the time and a fraction of the memory of buffer-then-join, because every 500 m circle at resolution=16 would otherwise cost 64 vertices of allocation for a polygon that is discarded moments later. Keep the buffer path when the zone itself is the deliverable — a map layer, an export, an input to an overlay — and take the dwithin path when the zone is scaffolding.
Proximity bands, not a single ring
Most real proximity questions are graded rather than binary: noise exposure by distance band, retail catchments at 400 m / 800 m / 1600 m walking bands, contamination risk rings. The mistake is to buffer each distance and treat the results as bands, because a 800 m buffer contains the 400 m one, so every feature is counted in every larger band. Build true annuli by differencing successive rings:
import geopandas as gpd
import pandas as pd
stations = gpd.read_parquet("transit_stations.parquet")
stations = stations.to_crs(stations.estimate_utm_crs())
edges = [0, 400, 800, 1600] # metres, ascending
bands = []
for inner, outer in zip(edges[:-1], edges[1:]):
ring = stations.geometry.buffer(outer)
if inner:
ring = ring.difference(stations.geometry.buffer(inner))
band = stations[["station_id"]].copy()
band["geometry"] = ring
band["band_m"] = f"{inner}-{outer}"
bands.append(band)
catchment_bands = gpd.GeoDataFrame(pd.concat(bands, ignore_index=True), crs=stations.crs)
# Each parcel now lands in exactly one band per station
banded = gpd.sjoin(parcels, catchment_bands, how="inner", predicate="within")
print(banded.groupby("band_m")["parcel_id"].nunique())
# band_m
# 0-400 1842
# 400-800 3915
# 800-1600 10238
Two things to watch. The bands are per-station, so a parcel near two stations appears twice — decide deliberately whether the question wants "parcels within 400 m of any station" (dissolve the bands first, then join) or "station-parcel pairs" (join as written). And difference on the raw buffers is exactly the kind of geometry rebuild that belongs to Geometric Intersections & Overlays; if the bands then need clipping to a municipal boundary, do it there rather than accumulating differences here.
Geometry / Data Processing Details
The parameters of buffer() decide the shape of every ring, and the defaults are rarely what a production job wants. Three matter most:
resolution— segments per quarter-circle. Higher values approximate a true arc more closely but multiply vertex count and memory; 8 is fine for display, 16+ for area accounting on curved features.cap_style— how line ends are closed:round(default),flat(square-cut at the endpoint), orsquare(extended past it). This only affects line and point buffers.join_style— how outer corners are handled on polygon and line buffers:round,mitre(sharp corners, bounded bymitre_limit), orbevel.
cap_style and join_style change the silhouette of the same buffer: round ends and corners smooth the outline, flat/butt stops exactly at the vertex, square extends past it, and mitre points the corner (bounded by mitre_limit) where bevel cuts it flat.from shapely import make_valid
# Explicit topology controls — round caps, mitred corners, 16-segment arcs
zones["geometry"] = zones.geometry.buffer(
500, resolution=16, cap_style="round", join_style="mitre", mitre_limit=2.0,
)
# Repair any self-intersections the buffer introduced on pathological inputs
invalid = ~zones.geometry.is_valid
zones.loc[invalid, "geometry"] = zones.loc[invalid, "geometry"].apply(make_valid)
# Drop degenerate zero-area buffers from collapsed / empty input geometries
zones = zones[zones.geometry.area > 0]
Two data-processing habits keep buffer output clean. First, prefer make_valid() over the legacy buffer(0) repair trick — it preserves geometry type and is deterministic; the deeper repair strategies live in Topology Validation & Repair. Second, filter zero-area results before any overlay, because a collapsed buffer becomes an empty polygon that raises downstream.
resolution deserves its own budget line, because it multiplies through every downstream step. A point buffer emits 4 × resolution vertices, so 50 000 sensors buffered at the default 8 produce 1.6 million coordinate pairs; the same layer at 32 produces 6.4 million. That inflation is paid three times — once in the buffer allocation, again in the R-tree build, and a third time in every overlay or export that touches the result. Pick the value from the question being asked: 4 or 8 for a screening filter or a web map, 16 when the buffered area feeds a numeric report, 32 or more only for a small number of features where the arc really is the deliverable. When a high-resolution buffer must be exported, simplify() afterwards with a tolerance an order of magnitude below the buffer distance recovers most of the size without visibly changing the ring.
Corridor work is the other place where the defaults mislead. A road buffered with the default round caps extends half a circle past each endpoint, so the corridor is longer than the road and adjacent segments of the same route overlap at every junction. Flat caps stop the buffer exactly at the vertex, which is what a length-based corridor calculation needs:
import geopandas as gpd
rail = gpd.read_parquet("rail_centrelines.parquet")
rail = rail.to_crs(rail.estimate_utm_crs())
# Symmetric 30 m corridor that stops at the segment ends
corridor = rail.copy()
corridor["geometry"] = rail.geometry.buffer(30, cap_style="flat", join_style="round")
# Asymmetric 30 m strip on one side only; a negative distance flips the side
from shapely import buffer as shp_buffer
north_strip = rail.copy()
north_strip["geometry"] = shp_buffer(rail.geometry.values, 30, single_sided=True)
print(corridor.area.sum() / 1e4, "ha two-sided")
print(north_strip.area.sum() / 1e4, "ha one-sided") # ≈ half the two-sided figure
Buffering each segment independently and then dissolving is also measurably cheaper than dissolving lines first and buffering the union, because GEOS's offset-curve cost grows with the vertex count of a single geometry. Buffer many small geometries, then union — not the other way round.
For exact nearest-neighbour proximity — "the five closest sensors to this outfall" — reach for a KDTree rather than a distance matrix. The tree works on point coordinates only, so extract centroids from non-point geometries first, and remember the coordinates must already be projected so that Euclidean distance means metres.
import numpy as np
from scipy.spatial import cKDTree
# Extract projected point coordinates (centroids for non-point features)
pts = sites[sites.geometry.geom_type == "Point"].copy()
coords = np.column_stack([pts.geometry.x, pts.geometry.y]) # metres, projected
tree = cKDTree(coords)
# Five nearest sites to a target outfall, distances returned in metres
outfall = np.array([[500000.0, 4600000.0]]) # example UTM coordinate
distances, idx = tree.query(outfall, k=5)
nearest = pts.iloc[idx[0]][["site_id", "geometry"]]
A buffer plus a spatial join answers "everything within a radius"; a KD-Tree answers "the k closest, ranked". Pick the tool by the question — the R-tree behind sindex prunes by bounding box for predicate joins, while the KD-Tree gives ordered, exact point distances shared with Nearest Neighbor & KD-Tree Search.
CRS Alignment & Projection Pipeline
Buffering is a planar operation, so a projected, metric CRS is not optional — it is the difference between a 500-metre ring and silent nonsense. GeoSeries.buffer(500) on unprojected WGS84 (EPSG:4326) grows a ring 500 degrees wide, and even a "correct-looking" small value distorts north–south versus east–west because a degree of longitude shrinks with latitude. Project first, then buffer; estimate_utm_crs() picks the right zone automatically, and the full mechanics — axis order, always_xy, EPSG versus PROJ strings — live in Coordinate Systems with PyProj.
- Never buffer in degrees. Assert the active CRS is projected before the call; reject any geographic CRS for a metric radius.
- Avoid Web Mercator (EPSG:3857) for the radius. Its scale factor diverges from true metres away from the equator (roughly 1.4× at 45° N), so a "1 km" buffer is not 1 km on the ground — use a local UTM zone or national grid instead.
- Mind axis order in hand-built transformers. PROJ 6+ honours each CRS's authority-defined axis order, so EPSG:4326 is (lat, lon); pass
always_xy=Truewhen constructing aTransformerto keep the intuitive (lon, lat) ordering. - Prefer EPSG codes over
+init=PROJ strings — PROJ 6+ deprecated the latter and treats them differently.
import geopandas as gpd
def buffer_metric(gdf: gpd.GeoDataFrame, distance_m: float) -> gpd.GeoDataFrame:
"""Reproject to a metric CRS, buffer, and return in the original CRS."""
original = gdf.crs
metric = gdf.estimate_utm_crs() if gdf.crs.is_geographic else gdf.crs
assert metric.axis_info[0].unit_name == "metre", "Buffer CRS must be metric."
out = gdf.to_crs(metric)
out["geometry"] = out.geometry.buffer(distance_m)
return out.to_crs(original) # hand results back in the caller's CRS
That helper is correct for a dataset inside one UTM zone and quietly wrong for one that is not. A UTM zone is 6° wide and its scale error grows away from the central meridian, so a layer spanning a continent gets buffers that are accurate in the middle and several percent off at the edges — and estimate_utm_crs() will happily hand you one zone for the lot. Two ways out, chosen by what the buffer is for:
- Equal-area projection for the whole extent. If the analysis reports areas rather than distances, an equal-area CRS (a national Albers or Lambert Azimuthal, or
EPSG:6933globally) keeps the areas honest at the cost of distorting shapes. Areas are preserved exactly; a 500 m ring is not exactly 500 m everywhere. - Per-feature azimuthal equidistant. If the radius must be exact around each feature, the only rigorous answer is a projection centred on that feature. That is expensive, so reserve it for the small, high-stakes case — a few hundred regulatory exclusion zones rather than a million sensors.
For truly geodesic rings, PyProj can generate the ring directly on the ellipsoid without any projection at all. Geod.fwd walks out from the centre along a range of azimuths and returns the lon/lat of each point, which you close into a polygon:
import numpy as np
from pyproj import Geod
from shapely.geometry import Polygon
import geopandas as gpd
geod = Geod(ellps="WGS84")
def geodesic_ring(lon: float, lat: float, radius_m: float, n: int = 180) -> Polygon:
"""A true ellipsoidal circle: every vertex is radius_m from the centre."""
azimuths = np.linspace(0, 360, n, endpoint=False)
lons, lats, _ = geod.fwd(
np.full(n, lon), np.full(n, lat), azimuths, np.full(n, radius_m)
)
return Polygon(zip(lons, lats)) # lon/lat order — always_xy convention
# Three monitoring stations spanning 40 degrees of longitude
stations = gpd.GeoDataFrame(
{"station_id": ["A", "B", "C"]},
geometry=[geodesic_ring(lon, 61.2, 25_000) for lon in (-149.9, -120.0, -110.0)],
crs="EPSG:4326",
)
The rings come back in EPSG:4326 and can be stored or drawn directly. Do not measure them there — reproject to a projected CRS for any area calculation, and remember that a geodesic ring is not a circle once projected, which is exactly the point. This is also the technique behind sane buffers at high latitudes, where UTM zones narrow to a few hundred kilometres and per-zone reprojection becomes impractical; the projection theory behind the choice is in Coordinate Systems with PyProj, and the automatic zone selection helper in Choosing a UTM Zone Automatically in Python.
Production Export & Integration
Buffer zones rarely stay in isolation — they are the input to an overlay, a spatial join, or a web map. Wire them into the rest of the stack rather than re-solving each hop:
- Persist as GeoParquet.
to_parquet(..., compression="zstd")keeps the CRS metadata attached to the file and reads far faster than Shapefile — the columnar format the rest of the Cloud-Native Geospatial Formats workflow expects. - Feed overlays, not just joins. When a catchment must be split by another layer — service area clipped to a watershed, buffer differenced against a right-of-way — hand it to Geometric Intersections & Overlays; a spatial join attaches attributes, an overlay rebuilds geometry.
- Push large radius queries into the database. For shared, concurrently queried, or persistently indexed proximity, run
ST_DWithininside PostGIS Integration with Python, where a GiST index bounds the candidate scan server-side, or useST_Bufferin DuckDB Spatial Analytics over GeoParquet. - Round coordinates for the web. Reproject the dissolved zones to EPSG:4326 and snap to a ~6-decimal grid (~11 cm) before GeoJSON export to shrink payloads for Web Mapping & Interactive Visualization.
from shapely import set_precision
# Web-ready export: reproject to WGS84, snap to a 6-decimal grid, write GeoJSON
web_ready = service_areas.to_crs("EPSG:4326").copy()
web_ready["geometry"] = web_ready.geometry.apply(lambda g: set_precision(g, grid_size=1e-6))
web_ready.to_file("service_areas.geojson", driver="GeoJSON")
# PostGIS-native radius query (run against a GiST-indexed geometry column):
# SELECT p.parcel_id
# FROM parcels p JOIN sites s ON ST_DWithin(p.geom, s.geom, 500)
Reach for Scaling with Dask-GeoPandas or the dedicated Optimizing Buffer Operations for Large Datasets guide once one process can no longer hold the geometry array — both partition the buffer across workers and persist the spatial index to disk.
Windows / Platform Edge Cases & Debugging
- Buffer rings are enormous or vanishingly small. The layer is in EPSG:4326; the distance was read as degrees. Reproject to a metric CRS before buffering.
PROJ_LIB/ "PROJ data directory not found" on Windows. A half-migrated conda environment or a strayPROJ_LIBenv var; printpyproj.datadir.get_data_dir()and point it at the active env'sshare/proj.TopologyExceptionwhen dissolving buffered zones. A buffer introduced a self-intersection on a pathological input; runmake_validon the buffer column before thedissolve.- KD-Tree distances look wrong (thousands where you expect metres). The coordinates were still geographic; a KD-Tree computes planar Euclidean distance, so project the points before building the tree.
buffer(-distance)returns empty geometries. A negative (inward) buffer larger than the feature's half-width collapses it; filterarea > 0afterwards and expect fewer rows.- Mitred corners produce long spikes.
join_style="mitre"without amitre_limitlets sharp corners extend far; setmitre_limit=2.0or switch tobevel. - Buffer output is unexpectedly heavy. A high
resolutionmultiplies vertices on every arc; drop it to 8 for display, orsimplify()before export. - Total buffered area exceeds the study area. Overlapping rings were summed without dissolving.
union_all()the zones first, then measure the single polygon — summingareaover raw buffers double-counts every overlap. - A
dwithinjoin returns nothing. Thedistanceargument is in CRS units, and the frame is still geographic, so 500 means 500 degrees on one side and nothing matches after the exact test. Reproject both frames before the join. - Counts differ between two machines by a handful of features. Different GEOS builds produce marginally different offset curves, so a feature sitting exactly on the boundary can fall either side. Compare
shapely.geos_versionacross environments and add a small tolerance to boundary-sensitive tests. bufferon aGeoSerieswith mixed geometry types silently drops rows. Empty orNonegeometries propagate as empty polygons rather than raising; checkgeometry.is_empty.sum()before and after, and handle mixed frames as described in Handling Mixed Geometry Types in a GeoDataFrame.- The R-tree seems to be ignored.
sindexis built lazily and rebuilt whenever the geometry column is reassigned; assigning a buffered column inside a loop throws the index away on every iteration. Buffer once, then query.
Frequently Asked Questions
Should I buffer and join, or use a distance predicate?
Use a distance predicate (sjoin(..., predicate="dwithin", distance=...) in GeoPandas, ST_DWithin in the database) whenever the answer is a count, a flag or a set of pairs. It never allocates the ring geometry, so it is faster and lighter. Buffer when the zone itself is an output — a map layer, an export, the input to an overlay or a dissolve — or when the shape needs non-default caps and joins that a radius test cannot express.
What buffer distance should I use for a "walkable" catchment? Whatever your evidence supports, but be aware that a circular buffer answers a different question than most people intend. A 800 m ring is as the crow flies; the walkable area is bounded by the street network and is typically 40–60% of the ring's area in a gridded city and far less where a river or motorway cuts through. If the analysis is about access rather than proximity, build a real isochrone from the street network instead — that path is in Building Isochrones from a Street Network.
Why is the area of my 500 m buffer less than 785,398 m²?
Because the ring is a polygon, not a circle. At resolution=8 the inscribed 32-gon covers roughly 99.5% of the true circle; raising resolution to 16 closes most of the gap. If the shortfall is much larger than a percent, the cause is the CRS rather than the vertex count — check that the layer is projected and that the projection is not Web Mercator.
Can I do all of this in the database instead?
Yes, and you should when the data already lives there and the result set is small relative to the table. ST_DWithin against a GiST-indexed column is the server-side equivalent of the whole buffer-and-join pipeline, with the index bounding the candidate scan — see PostGIS Integration with Python. The reason to keep the Python path is iteration speed: choosing a distance, a resolution and a dissolve strategy is much faster against an in-memory frame than against a table you have to reload.
How many features can I buffer before I need a different approach?
On a modern laptop, buffering a few hundred thousand simple geometries is seconds and comfortably in RAM. The wall is memory rather than CPU: the source geometry column and the full buffered copy are resident at the same time, so peak usage roughly doubles, and complex polygons with high resolution hit it far sooner than points. Past a few million features, chunk the work — the sizing rules and the parallel implementation are in Optimizing Buffer Operations for Large Datasets.
Is a negative buffer a safe way to shrink a polygon?
It is the standard way, with two caveats. Features narrower than twice the distance collapse to empty geometries rather than erroring, so filter on area > 0 afterwards and expect the row count to change. And an inward buffer on a self-intersecting polygon can produce surprising fragments, so run make_valid first — a negative buffer is not a repair tool, despite the old buffer(0) folklore.