How to reduce coordinate precision safely in Python
Problem statement
Rounding coordinates is the cheapest privacy measure there is, and the most often misapplied. Three decimal places sounds coarse โ about 111 m by 70 m at UK latitudes โ and on a real address extract it still left 143 points alone in their own cell.
It is also the measure people apply for the wrong reason. Cutting a GeoJSON from 15 decimal places to 5 is a file-size decision, not a privacy decision: it reduces the file by about 15% and reduces re-identification risk by nothing at all, because 5 dp is still under a metre.
This guide separates the two uses, shows what each precision level does to a real dataset, and explains why rounding is a snapping operation with visible artefacts rather than a blur.
Quick answer
Round in a projected CRS to a grid you have chosen from the uniqueness profile, and check how many points are still alone:
import geopandas as gpd, numpy as np, pandas as pd
GRID = 100 # metres
work = points.to_crs(27700)
snapped = gpd.GeoDataFrame(
points.drop(columns="geometry"),
geometry=gpd.points_from_xy((work.geometry.x / GRID).round() * GRID,
(work.geometry.y / GRID).round() * GRID),
crs=27700,
).to_crs(points.crs)
key = pd.Series(list(zip(snapped.geometry.x.round(6), snapped.geometry.y.round(6))))
vc = key.value_counts()
print(f"{len(vc):,} occupied cells; {int((vc == 1).sum()):,} hold exactly one point")
Rounding in degrees instead produces rectangular cells whose eastโwest size shrinks with latitude โ at 50.83ยฐN, 3 dp is 111.32 m northโsouth and 70.31 m eastโwest.
Step-by-step solution
1. Decide which problem you are solving
File size and privacy need different numbers. For transport, 5โ6 decimal places is plenty for any web map and costs nothing in accuracy. For privacy, the number has to come from a candidate count, and it is usually 2โ3 decimal places or a 100โ500 m grid.
2. Measure the uniqueness profile before choosing
On the 3,109 OpenStreetMap address points in Brighton & Hove:
| decimal places | cell (NโS ร EโW) | distinct cells | cells with one point | records alone |
|---|---|---|---|---|
| 6 | 0.11 m ร 0.07 m | 3,109 | 3,109 (100.0%) | 100.0% |
| 5 | 1.11 m ร 0.70 m | 3,108 | 3,107 (100.0%) | 99.9% |
| 4 | 11.13 m ร 7.03 m | 2,516 | 1,999 (79.5%) | 64.3% |
| 3 | 111.32 m ร 70.31 m | 487 | 143 (29.4%) | 4.6% |
| 2 | 1,113.20 m ร 703.12 m | 22 | 0 (0.0%) | 0.0% |
Only the last row leaves nobody alone, and it is a cell over a kilometre across.
3. Round in metres, not in degrees
A degree of longitude is 111.32 km at the equator and 70.3 km at 50.8ยฐN. Rounding lat/lon to a fixed number of decimals produces cells that change shape across the dataset and become unusable near the poles. Reproject, snap to a metric grid, reproject back.
4. Understand that rounding is snapping, not blurring
Every point moves to a lattice node. The result has visible rows and columns on a map, all points within a cell become exactly coincident, and distances between nearby points become multiples of the grid. If a smooth result is wanted, mask instead โ How to apply donut geomasking to sensitive points in Python.
5. Check the counts you are also publishing
Snapping moves points across cell boundaries, so any count you publish must be computed from the snapped points, not the originals, or the two disagree.
6. Do not round after masking
Masking then rounding adds a lattice to a distribution that was chosen to be smooth, and the lattice is a visible artefact that tells users something about the pipeline. Pick one.
7. Write the file at the precision you chose
GeoPandas writes full double precision by default: the first masked coordinate in a test GeoJSON came out as -0.175253311870371 โ fifteen decimal places. GDAL's COORDINATE_PRECISION option truncates on write.
snapped.to_file("release.geojson", driver="GeoJSON", COORDINATE_PRECISION=5)
Code examples
Example 1 โ the uniqueness profile at each precision
import numpy as np, pandas as pd
lat, lon = points.geometry.y.values, points.geometry.x.values
for dp in (6, 5, 4, 3, 2):
key = pd.Series(list(zip(np.round(lat, dp), np.round(lon, dp))))
vc = key.value_counts()
ns = 10 ** (-dp) * 111_320
ew = ns * np.cos(np.deg2rad(np.median(lat)))
print(f"{dp} dp: cell {ns:8.2f} x {ew:7.2f} m | distinct {len(vc):5,} | "
f"alone {int((vc == 1).sum()):5,} ({(vc == 1).mean():5.1%})")
6 dp: cell 0.11 x 0.07 m | distinct 3,109 | alone 3,109 (100.0%)
5 dp: cell 1.11 x 0.70 m | distinct 3,108 | alone 3,107 (100.0%)
4 dp: cell 11.13 x 7.03 m | distinct 2,516 | alone 1,999 ( 79.5%)
3 dp: cell 111.32 x 70.31 m | distinct 487 | alone 143 ( 29.4%)
2 dp: cell 1113.20 x 703.12 m | distinct 22 | alone 0 ( 0.0%)
Example 2 โ snapping to a metric grid
import geopandas as gpd
def snap_to_grid(gdf, grid_m, crs="EPSG:27700", offset=0.5):
"""Snap to cell centres so no point sits on a boundary."""
work = gdf.to_crs(crs)
x = (np.floor(work.geometry.x / grid_m) + offset) * grid_m
y = (np.floor(work.geometry.y / grid_m) + offset) * grid_m
out = work.copy()
out.geometry = gpd.points_from_xy(x, y, crs=crs)
return out.to_crs(gdf.crs)
for grid in (50, 100, 250, 500):
s = snap_to_grid(points, grid)
key = pd.Series(list(zip(s.geometry.x.round(6), s.geometry.y.round(6))))
vc = key.value_counts()
print(f"{grid:4d} m grid: {len(vc):5,} cells, {int((vc == 1).sum()):5,} with one point")
Snapping to cell centres rather than corners avoids points landing exactly on a boundary, where a later spatial join assigns them arbitrarily.
Example 3 โ precision on write, and what it costs
masked.to_file("release.geojson", driver="GeoJSON")
print("default precision:", pathlib.Path("release.geojson").stat().st_size / 1024, "KB")
masked.to_file("release.geojson", driver="GeoJSON", COORDINATE_PRECISION=5)
print("5 decimal places:", pathlib.Path("release.geojson").stat().st_size / 1024, "KB")
default precision: 406.0 KB
5 decimal places: 346.0 KB
A 15% saving, and no privacy gain whatsoever: 5 dp is 0.70 m eastโwest at this latitude. Use COORDINATE_PRECISION for bandwidth and a grid snap for privacy.
Explanation
Why three decimals is not anonymous
A cell of 111 m by 70 m holds a lot of houses in a terrace and none on a moor. Uniqueness is a property of the local density, and the density varies by three orders of magnitude inside any city. That is why the Brighton test still had 143 lone points at 3 dp: they are the sparse edges.
Why degrees are the wrong unit
Rounding to a fixed number of decimal degrees gives cells whose aspect ratio is 1/cos(latitude). At the equator they are square; at 60ยฐN they are twice as tall as wide; at 80ยฐN nearly six times. A dataset spanning latitudes gets a different privacy guarantee in each row.
Why snapping is visible and masking is not
Rounding maps a continuum onto a lattice, so the output has exactly as many distinct positions as there are occupied cells, and a scatter plot shows the grid. A masked dataset has as many distinct positions as points. Users notice the difference immediately, which matters if the release is meant to look like data rather than like a heat map.
Why coordinate precision is mostly a file-size lever
GeoPandas writes doubles; a GeoJSON of 3,109 points came out at 406 KB with full precision and 346 KB at five decimal places. That is a real saving for a web payload, and it is worth doing for every release. It is not a privacy control, and describing it as one in a data-sharing agreement would be wrong.
Edge cases or notes
- Snap to cell centres, not corners. Boundary points break later joins.
- The grid origin is a parameter. Two releases with different origins can be intersected.
- Round once. Rounding an already-rounded file changes nothing but the documentation.
- Coincident points become ties. Any nearest-neighbour analysis on snapped data needs a tie rule.
- Distances collapse. After a 100 m snap, the minimum non-zero distance between points is 100 m.
- Do not round elevations by accident. A 3D coordinate rounds the Z as well unless you handle it.
- Web maps do not need more than 6 dp. That is 11 cm; no browser map is more accurate.
- Shapefiles store doubles regardless. Precision options are a GeoJSON and GML feature.
Internal links
- Coordinate precision explained โ what each decimal place means on the ground
- Geomasking methods explained: donut, random and adaptive โ the smooth alternative
- How to measure re-identification risk in a point dataset โ producing the uniqueness profile
- Spatial k-anonymity explained โ choosing the grid from a candidate count
- How to aggregate points to units that meet a minimum count โ when a grid is not enough
- How to reduce GIS file size in Python โ precision as a bandwidth control
- How to prepare a GeoDataFrame for the web โ where COORDINATE_PRECISION belongs
- A shared file still contains the original coordinates โ full precision leaking through a side channel
FAQ
How many decimal places is safe?
None, by itself. Three decimal places โ about 111 m by 70 m at UK latitudes โ still left 143 of 3,109 address points alone in their own cell. Choose a grid from a measured candidate count instead.
Should I round in degrees or in metres?
Metres. Degree rounding gives cells whose eastโwest size shrinks with latitude, so the guarantee changes across the dataset.
Does COORDINATE_PRECISION protect anyone?
No. It is a file-size control. Cutting a GeoJSON from full precision to five decimal places saved 15% of the bytes and nothing else.
Why do my rounded points look like a grid?
Because they are one. Rounding snaps every point to a lattice node; if you want a smooth result, use a geomask.
Can I round and mask?
Pick one. Rounding after masking stamps a visible lattice onto a distribution chosen to be smooth.
What grid size should I use?
Whatever gives every cell at least your threshold of candidate subjects. In a city that is often 100โ250 m; at the rural edge of the same dataset it can be several times larger.