Reduce precision in the query, not in the renderer. Snap to a grid or aggregate to administrative areas inside PostGIS, choose the grid from the data’s local density, suppress single-record cells — and do not reach for random jitter, which repeated queries defeat.

Jump to heading Why this matters

Plenty of spatial datasets are useful in aggregate and sensitive at full precision: incident reports, health events, protected-species sightings, customer addresses. The usual arrangement is that a privileged role sees exact locations and everybody else sees something coarser, and the coarsening is where the design work is.

The temptation is to treat this as a rendering problem — load the precise geometry, blur it before drawing — and it is not. It is an access problem, and it belongs at the same boundary as the row-level authorisation described in security boundaries & auth. If exact coordinates reach the dashboard process, they are recoverable from that process, and the fact that they were not drawn on the map is not the property anybody is going to ask about.

Four ways to reduce spatial precision, and what each survivesDropping the geometry entirely and returning only an aggregate count is the strongest option and the least useful for a map. Snapping points to a grid keeps a usable map at a chosen resolution and is reversible only to the cell, but it leaks the cell, which for a sparse dataset in a rural area can identify a single record. Aggregating to an administrative area removes the grid-inference problem because the areas are already public boundaries, at the cost of losing within-area pattern. Adding random jitter looks like the gentlest option and is the most dangerous: repeated queries average the noise away, so an analyst who can re-run the same request recovers the true position. Rank them by what an adversary with repeated access can reconstruct rather than by how the first response looks.MASKING STRATEGIES, RANKED BY WHAT SURVIVES REPEATED QUERIESaggregate only — no geometrystrongest, and no longer a map; right for counts and rates, wrong for anything spatialsnap to a gridusable map at a chosen resolution; leaks the cell, which is enough in a sparse rural areaaggregate to admin areasthe boundaries are already public, so there is nothing extra to infer; loses within-area patternrandom jitterlooks gentlest and is the weakest — repeated queries average the noise away and recover the pointRank by what somebody with repeated access can reconstruct, not by how convincing a single response looks.

Jump to heading Prerequisites

  • PostGIS 3.x, or another spatial database that can snap and aggregate server-side.
  • The role extraction from role-based access control for internal dashboards, so the query knows which precision to serve.
  • A projected CRS for the snapping. Snapping in degrees produces cells that are rectangles of wildly varying ground size by latitude, which is the same unit trap as everywhere else.

Jump to heading Step-by-step solution

Jump to heading Step 1 — Put the masking in the SQL

Where the masking has to happenTwo paths for the same request from an unprivileged role. In the upper path the precise geometry is read from the database, travels to the dashboard process, and is masked in Python before rendering: the map looks correct, and the exact coordinates were nonetheless present in the process, in its memory, in any traceback, and in anything that logged the intermediate frame. In the lower path the masking is part of the SQL — a snap to grid or a join to an administrative area performed by PostGIS — so the precise geometry never leaves the database at all. The two are indistinguishable on screen and completely different to an audit, which is the same argument as row-level authorisation and for the same reason: the boundary is the connection, not the renderer.MASK IN PYTHON, OR MASK IN SQLin Pythonprecise geometrydashboard processmasked renderin SQLST_SnapToGrid in the querydashboard processmasked renderIdentical on screen. Different in a memory dump, a traceback, a debug write, and every audit that asks what theprocess was able to see.
python
PRECISE_ROLES = {"analyst_full", "data_steward"}

def fetch_incidents(conn, bounds_wkb: bytes, role: str, grid_m: int = 1000):
    if role in PRECISE_ROLES:
        geom_expr = "geom"
        group_by = ""
    else:
        # Snap in a projected CRS, then hand back geographic coordinates.
        geom_expr = (f"ST_Transform(ST_SnapToGrid("
                     f"ST_Transform(geom, 27700), {grid_m}), 4326)")
        group_by = f"GROUP BY {geom_expr} HAVING count(*) >= 5"

    query = f"""
        SELECT {geom_expr} AS geom, count(*) AS n
        FROM incidents
        WHERE ST_Intersects(geom, ST_GeomFromWKB(%s, 4326))
        {group_by or 'GROUP BY geom'}
    """
    return gpd.read_postgis(query, conn, params=(bounds_wkb,), geom_col="geom",
                            crs="EPSG:4326")

The HAVING count(*) >= 5 is doing as much work as the snap. Snapping bounds how precisely a record is placed; it does nothing about a cell that ends up containing exactly one record, which is a location whatever the grid size says. Suppressing sparse cells is what turns a coarse point layer into something that cannot be inverted at its edges.

Jump to heading Step 2 — Choose the grid from the data, not from a round number

Grid size against re-identification riskThe same point layer of health incidents is snapped to four grid sizes and the number of cells containing exactly one record is counted, because a single-record cell is a re-identification. At a hundred metres, sixty-one percent of cells hold one record and the mask is providing almost no protection in rural areas while looking rigorous. At five hundred metres it is twenty-two percent. At one kilometre it is seven percent, and at five kilometres under one percent, at which point the map is a coarse density surface rather than a location. The curve is the argument for choosing the grid from the data's density rather than from a round number: the same five-hundred-metre grid that anonymises an urban dataset completely leaves a rural one almost intact.SNAPPED POINT LAYER — CELLS CONTAINING EXACTLY ONE RECORD100 m grid61% of cellshold one record500 m grid22%1 km grid7%5 km gridunder 1% — a density surface, not a locationThe same grid that anonymises a city leaves a rural dataset almost intact, so choose it from the local density ratherthan from a round number that sounds cautious.
sql
-- Run this once per candidate resolution, per region, before choosing.
SELECT count(*) FILTER (WHERE n = 1)::float / count(*) AS single_record_share
FROM (
  SELECT ST_SnapToGrid(ST_Transform(geom, 27700), 500) AS cell, count(*) AS n
  FROM incidents GROUP BY cell
) cells;

Run it separately for a dense region and a sparse one. A single global grid that satisfies both is usually much coarser than the urban data needs; where that loss matters, a variable resolution — finer where density supports it — is defensible, provided the resolution itself is not published in a way that reveals density.

Jump to heading Step 3 — Make the coarse layer honest about being coarse

A map of grid centroids drawn as small dots looks exactly like a map of precise locations, and users will read it as one. Draw the cells as cells: squares at the grid resolution, or a choropleth over the administrative areas, so the visual carries the precision the data actually has.

python
# Render cells as cells, not as points that happen to be on a grid.
cells = subset.copy()
cells["geometry"] = cells.geometry.buffer(grid_m / 2, cap_style=3)  # squares

This is a security control as much as a design one. A dot implies a location; a square implies an area, and an analyst who copies the layer into their own work carries the correct implication with it.

Jump to heading Step 4 — Log the role alongside the query

python
log.info("spatial_query", extra={
    "role": role, "masked": role not in PRECISE_ROLES,
    "grid_m": None if role in PRECISE_ROLES else grid_m,
    "rows": len(result),
})

An audit asks two questions: who saw precise data, and can you prove it. A structured log record per query answers both, and it fits the schema from logging spatial query performance with one extra field.

Jump to heading Verification

python
# 1. An unprivileged role never receives a coordinate off the grid.
masked = fetch_incidents(conn, bounds, role="viewer", grid_m=1000)
xs = masked.to_crs(27700).geometry.x
assert (xs % 1000 == 0).all(), "an unsnapped coordinate escaped"

# 2. No cell is below the suppression floor.
assert (masked["n"] >= 5).all(), "a sparse cell was returned"

# 3. The privileged path is genuinely different.
precise = fetch_incidents(conn, bounds, role="data_steward")
assert len(precise) > len(masked), "the roles returned the same thing"

The third assertion is the one worth having in CI. A refactor that accidentally routes both roles through the same branch produces a dashboard that works perfectly, looks correct to everybody, and quietly serves precise coordinates to viewers — and nothing else in the system will notice.

Jump to heading Edge cases and gotchas

  • Snapping in degrees. ST_SnapToGrid on geographic coordinates makes cells whose ground width shrinks with latitude, so a “one kilometre” grid is one kilometre in exactly one place. Transform to a projected CRS, snap, transform back.
  • Suppression leaks at the boundary. If a cell disappears when it drops below five records, an observer who can query repeatedly over time learns when a cell crossed the threshold. Where that matters, suppress on a fixed published schedule rather than live.
  • Joining a masked layer to a precise one. An analyst who has a masked incidents layer and a precise addresses layer can sometimes intersect them back to a single candidate. Masking one layer does not protect it if the other half of the join is available.
  • Caching across roles. A cache key that omits the role will serve a privileged result to an unprivileged session. Put the role — or better, the precision — in the key, as query result caching describes.

Jump to heading FAQ

Is aggregating to administrative areas better than a grid?

Usually, for two reasons. The boundaries are already public, so the aggregation reveals nothing about the underlying density the way a chosen grid resolution can. And the areas match how the results will be discussed and acted on, which makes the map more useful rather than merely safer. The cost is that areas vary enormously in size, so the effective precision is uneven.

Can I let privileged users toggle precision in the interface?

Yes, provided the toggle is a request for a different query rather than a client-side switch over data already sent. If both precisions are in the browser, the toggle is decoration and the precise data is one devtools panel away.

What about exports?

Exports are the most common leak, because they are often wired to a different code path from the map. Route them through the same masked query, and include the role and grid size in the exported file’s metadata so a spreadsheet that escapes into someone’s email still says what it is.

Back to Security Boundaries & Auth.