Masking Precise Coordinates for Privacy

This page shows you how to lower the precision of sensitive location data in a dbt mart — with ST_SnapToGrid, coordinate rounding, H3 aggregation, or jitter — and expose the precise geometry only to roles entitled to see it.

When to use this approach

Coordinate masking is the right control when a record’s location is the sensitive attribute and you still need location-shaped analytics. Reach for it when:

  • Exact coordinates re-identify a person or asset. A home address, a patient’s residence, or a device’s overnight location is disclosive at full precision. Generalizing the point keeps the map useful while breaking the link to an individual. This sits alongside access control in data security and scoping rules — scoping decides who sees a row, masking decides how precisely they see it.
  • Different roles need different precision. Analysts get grid-snapped or aggregated points; a fraud or operations team with a lawful basis gets full precision. That is a role-based masking decision, complementary to the topology-based filtering in implementing row-level security for geospatial data.
  • You are already aggregating to a grid at scale. If a mart rolls points up to H3 cells for performance, that same aggregation is a privacy control — the technique overlaps with partitioning geospatial tables with H3 and dbt.

If the whole row must be hidden from a role, masking is the wrong layer — filter it out with scoping instead. Masking assumes the row is visible but its precision must be reduced.

Prerequisites

  • dbt Core >= 1.6 so var() defaults and target-aware conditionals resolve consistently across environments.
  • A spatial engine: PostGIS >= 3.1 (ST_SnapToGrid, ST_MakePoint), the DuckDB spatial extension for CI, or BigQuery / Snowflake with their native masking policies for the warehouse-side layer.
  • A canonical SRID enforced in staging. Grid size and rounding precision are meaningless unless every geometry shares one coordinate frame — normalize first via spatial reference system management.
  • A defined precision policy per role, wired through var() so the same models deploy unchanged from staging to production:
yaml
# dbt_project.yml
vars:
  canonical_srid: 4326
  mask_grid_degrees: 0.01     # ~1.1 km grid at the equator; tune to your risk model
  mask_min_cell_count: 5      # k-anonymity threshold for aggregated marts

Step-by-step instructions

The four techniques trade privacy strength against analytic utility differently. The chart maps each to what it preserves and what it destroys, so you can pick per column rather than applying one blunt rule everywhere.

Four coordinate-masking techniques ranked by privacy strength and analytic utility Four techniques are shown as rows. Coordinate rounding truncates decimal places and is reversible in effect and weak, keeping point-level shape but snapping to a lattice. ST_SnapToGrid snaps each point to a fixed grid origin, deterministic and moderate, collapsing nearby points onto shared grid nodes. Jitter adds a small random offset per row, which is weak alone because averaging repeated observations recovers the true point, and it is non-deterministic. H3 aggregation groups points into hexagonal cells and reports only per-cell counts above a minimum threshold, which is the strongest because it enforces k-anonymity and never emits an individual coordinate. An arrow along the bottom notes that privacy strength rises and per-point analytic fidelity falls as you move from rounding to aggregation. Pick per column: stronger privacy costs per-point fidelity TECHNIQUE WHAT IT DOES STRENGTH Coordinate rounding ROUND(ST_X, 2) Truncate decimal places; snaps to a lattice, keeps point-level shape at reduced resolution WEAK ST_SnapToGrid ST_SnapToGrid(geom, 0.01) Snap to a fixed grid origin; nearby points collapse onto shared nodes, deterministic MODERATE Jitter (random offset) ST_Translate(geom, dx, dy) Add a small random offset per row; averaging repeated observations recovers the true point WEAK ALONE H3 aggregation GROUP BY h3_index HAVING n >= k Report only per-cell counts above a threshold; no individual coordinate ever leaves the mart STRONGEST per-point fidelity privacy strength →

1. Snap coordinates to a fixed grid

ST_SnapToGrid collapses every point onto the nearest node of a lattice, so any two locations within the same cell become indistinguishable. It is deterministic — the same input always yields the same masked point — which keeps joins and dedup stable.

sql
-- models/marts/mart_customer_locations_masked.sql
{{ config(materialized='table') }}

SELECT
    customer_id,
    ST_SnapToGrid(geom, {{ var('mask_grid_degrees') }}) AS geom_masked,
    region_code
FROM {{ ref('stg_customer_locations') }}

Verify that many precise points now share a masked location (indistinguishability):

sql
SELECT ST_AsText(geom_masked) AS cell, count(*) AS points
FROM {{ ref('mart_customer_locations_masked') }}
GROUP BY geom_masked HAVING count(*) > 1
ORDER BY points DESC LIMIT 10;
-- cells with a single point are still potentially identifying — see step 4

2. Round coordinates as a lightweight alternative

When you cannot rebuild geometry but can post-process lon/lat, rounding truncates precision directly. Two decimal places on EPSG:4326 is roughly a kilometre. Rebuild the point from rounded ordinates so the output stays a valid geometry with its SRID intact.

sql
SELECT
    customer_id,
    ST_SetSRID(
      ST_MakePoint(
        ROUND(ST_X(geom)::numeric, 2),
        ROUND(ST_Y(geom)::numeric, 2)
      ),
      {{ var('canonical_srid') }}
    ) AS geom_rounded
FROM {{ ref('stg_customer_locations') }}

Verify the rounding actually reduced precision and preserved the SRID:

sql
SELECT
    count(DISTINCT ST_AsText(geom))         AS distinct_precise,
    count(DISTINCT ST_AsText(geom_rounded)) AS distinct_rounded,
    count(DISTINCT ST_SRID(geom_rounded))   AS srids
FROM {{ ref('mart_customer_locations_masked') }};
-- distinct_rounded should be far smaller; srids must equal 1

3. Aggregate to H3 cells with a k-anonymity floor

The strongest control emits no individual coordinate at all. Roll points up to an H3 cell and suppress any cell with fewer than k members, so no output can be traced to a single record.

sql
-- models/marts/mart_density_by_cell.sql
{{ config(materialized='table', cluster_by=['h3_index']) }}

WITH celled AS (
    SELECT
        h3_latlng_to_cell(ST_Y(geom), ST_X(geom), 8) AS h3_index
    FROM {{ ref('stg_customer_locations') }}
)
SELECT
    h3_index,
    h3_cell_to_boundary_wkt(h3_index) AS cell_boundary,
    count(*)                          AS member_count
FROM celled
GROUP BY h3_index
HAVING count(*) >= {{ var('mask_min_cell_count') }}   -- suppress small cells

Verify no cell below the threshold leaked through:

sql
SELECT min(member_count) AS smallest_published_cell
FROM {{ ref('mart_density_by_cell') }};
-- must be >= var('mask_min_cell_count')

4. Apply jitter only as a complement, never alone

Jitter offsets each point by a small random vector. It hides an exact position in a single snapshot but is defeated by averaging repeated observations, so use it to blur a grid-snapped point, not as the sole control.

sql
SELECT
    customer_id,
    ST_Translate(
      ST_SnapToGrid(geom, {{ var('mask_grid_degrees') }}),   -- deterministic base
      (random() - 0.5) * {{ var('mask_grid_degrees') }},     -- dx within one cell
      (random() - 0.5) * {{ var('mask_grid_degrees') }}      -- dy within one cell
    ) AS geom_jittered
FROM {{ ref('stg_customer_locations') }}

Verify the jitter stays bounded (no point drifts more than a cell from its snapped base):

sql
SELECT max(ST_Distance(geom_jittered, ST_SnapToGrid(geom, {{ var('mask_grid_degrees') }}))) AS max_drift
FROM {{ ref('mart_customer_locations_masked') }};
-- max_drift should not exceed the grid size

5. Expose precision by role with a masking macro

Centralize the policy in one macro so every model masks identically and an entitled role can be granted precise geometry in exactly one place. Drive the decision from a var set per run or per environment.

sql
-- macros/mask_geometry.sql
{% macro mask_geometry(geom_col, role=none) %}
  {% set active_role = role or var('viewer_role', 'analyst') %}
  {% if active_role == 'privileged' %}
    {{ geom_col }}
  {% else %}
    ST_SnapToGrid({{ geom_col }}, {{ var('mask_grid_degrees') }})
  {% endif %}
{% endmacro %}
sql
-- models/marts/mart_locations_by_role.sql
{{ config(materialized='table') }}

SELECT
    customer_id,
    {{ mask_geometry('geom', var('viewer_role', 'analyst')) }} AS geom,
    region_code
FROM {{ ref('stg_customer_locations') }}

Verify the unprivileged build never emits full-precision points:

bash
dbt run --select mart_locations_by_role --vars '{viewer_role: analyst}'
# then confirm no coordinate carries more precision than the grid:
sql
SELECT count(*) AS overprecise
FROM {{ ref('mart_locations_by_role') }}
WHERE ST_X(geom) <> ROUND(ST_X(geom)::numeric, 2)::float8;
-- expect 0 for the analyst build; back this with warehouse column masking for ad-hoc access

Configuration reference

Parameter Accepted values Default Spatial notes
mask_grid_degrees numeric (degrees or CRS units) 0.01 Snap/jitter cell size. In EPSG:4326, 0.01 ≈ 1.1 km at the equator; in a metric SRID it is metres
mask_min_cell_count integer 5 k-anonymity floor; suppress any aggregated cell with fewer members so no output identifies one record
viewer_role analyst, privileged, role name analyst Drives the masking branch; only entitled roles receive precise geometry
canonical_srid any EPSG code 4326 Grid size and rounding are unit-dependent; enforce one SRID before masking
H3 resolution integer 015 8 Coarser (lower) resolution = stronger generalization, fewer suppressed cells
technique snap, round, h3, jitter+snap snap Aggregation is strongest; jitter alone is unsafe; choose per column risk

Gotchas & edge cases

  • A single-member grid cell still identifies. Snapping does not help when one point sits alone in its cell — the masked location still points at one record. Combine snapping with a k-anonymity suppression (step 3) or a minimum-density check before publishing.
  • Jitter alone is reversible by averaging. Repeated observations of the same entity with independent random offsets average back to the true point. Never rely on jitter as the only control; anchor it on a deterministic snap.
  • Masking in a shared intermediate model leaks upstream. If the precise geometry flows through an intermediate table any analyst can read, masking at the mart is too late. Mask at the boundary where the data first becomes broadly readable, mirroring where the scope gate sits in data security and scoping rules.
  • Grid units follow the SRID. ST_SnapToGrid(geom, 0.01) is ~1 km in degrees but 1 cm in a metric SRID. Confirm the CRS before choosing the grid size, or the mask silently does almost nothing.
  • Rounding can create invalid geometry for non-points. Rounding polygon vertices can collapse or self-intersect rings. Round only point coordinates; generalize polygons with ST_SnapToGrid or ST_Simplify and re-validate with ST_IsValid.
  • dbt-side masking does not stop ad-hoc SQL. A privileged analyst querying the precise staging table bypasses the mart mask entirely. Back the dbt layer with warehouse column-masking policies (Snowflake dynamic data masking, BigQuery data policies) for defense in depth, the same layering argued in implementing row-level security for geospatial data.

FAQ

Which masking technique should I use for home addresses?

Prefer aggregation with a k-anonymity floor over point-level masking. Snap-to-grid or rounding still emits one point per record, and a lone point in a sparse cell remains identifying. Rolling addresses up to an H3 cell and suppressing cells with fewer than k members guarantees no published output traces to a single household. If downstream analytics genuinely need a point rather than a cell, snap to a grid coarse enough that every cell holds several records, and verify that no single-member cells survive.

Why is jitter considered weak on its own?

Jitter adds an independent random offset each time a point is emitted, so if the same entity is observed repeatedly the offsets average toward zero and the true location re-emerges. It also does nothing to guarantee that nearby distinct entities become indistinguishable. Use jitter only to blur a value that is already generalized deterministically, for example to visually spread points that snap-to-grid has stacked on one node, never as the primary privacy control.

Should I mask in dbt or with a warehouse masking policy?

Both, in layers. A dbt-compiled mask keeps the policy in version control and CI and shapes the marts your pipeline builds, which covers everything that flows through the transformation graph. A warehouse column-masking policy enforces precision for ad-hoc queries that hit the tables directly and bypass your marts. Compile the mask in dbt for the served outputs, and back it with a database masking policy so a direct query cannot read full precision.

Does snap-to-grid break spatial joins downstream?

It changes them, so decide deliberately. Snapped points sit on grid nodes, so a join that needs true proximity should run on the precise geometry upstream, before masking, and only the result should be masked for exposure. If a downstream join is itself coarse, for example matching to a region, snapped points are usually fine. The rule is to perform precision-sensitive computation before the mask and expose only the generalized output.

Up: Part of Data Security & Scoping Rules.