v0.1.0 docs · things may still move · contact@gridpin.dev

Documentation

Everything you need to go from an empty directory to millisecond, fully offline geocoding: one binary, a data file per country, three ways to call it.

Quick start

Get started in 5 minutes

GridPin is an offline geocoder: a country in a memory-mapped file (most are a single file; some regions add a small companion, POI is separate), forward and reverse lookups in milliseconds. Throughput is entirely query-shape dependent — exact lookups run at ~3,000/s per core and reverse at ~1,800/s, while typo-tolerant fuzzy matching is much slower (tens of ms per query); see the benchmarks. No server, no database import; once the files are on disk, no query leaves your machine.

1

Download a sheet

A sheet is a data file per country — France is a single 365 MB file with 26.1M addresses; most countries are one file, though a few regional datasets add a small companion and the optional POI layer is separate. Put it anywhere on disk.

2

Grab the CLI

One self-contained binary from the releases page — download, unpack, put on PATH (see Install below). It memory-maps the sheet on first use and is ready instantly.

3

Query

Forward, reverse, or batch. Every query runs on your machine; not a byte of what you geocode leaves it.

Install

CLI. Prebuilt self-contained binaries for Linux x86_64 (x86_64-unknown-linux-gnu), macOS Apple Silicon (aarch64-apple-darwin) and Windows x86_64 (x86_64-pc-windows-msvc) are attached to every GitHub release; any other target (Intel macOS, Linux arm64/musl, Windows arm) builds from source:

terminal
# macOS (Apple silicon) example; pick your platform archive
$ base=https://github.com/gridpin/gridpin/releases/download/v0.1.0
$ curl -fsSLO "$base/gridpin-aarch64-apple-darwin.tar.gz"
$ curl -fsSLO "$base/gridpin-release-signers"
$ curl -fsSLO https://dl.gridpin.dev/v0.1.0/attestation.json
$ curl -fsSLO https://dl.gridpin.dev/v0.1.0/attestation.json.sig
# unpacking runs its contents sooner or later — authenticate first. The trust root comes
# from the OTHER channel: whoever owns one can swap the archive AND its checksum line.
$ ssh-keygen -Y verify -f gridpin-release-signers -I gridpin-release \
    -n gridpin-g02 -s attestation.json.sig < attestation.json \
    || { echo "signature does not verify — STOP"; exit 1; }
# extract the EXPECTED hash from the attestation you just authenticated, then COMPARE.
# Printing a hash is not a check: something has to fail when it differs.
$ want=$(python3 -c 'import json;print([a["sha256"] for a in json.load(open("attestation.json"))["assets"] if a["name"]=="gridpin-aarch64-apple-darwin.tar.gz"][0])')
$ test "$want" = "$(shasum -a 256 gridpin-aarch64-apple-darwin.tar.gz | cut -d' ' -f1)" \
    || { echo "archive does not match the signed attestation — STOP"; exit 1; }
$ tar xzf gridpin-aarch64-apple-darwin.tar.gz && sudo mv gridpin /usr/local/bin/
$ gridpin --help

# or build from source (stable Rust)
$ cargo build --release --manifest-path gridpin/Cargo.toml

Python. pip install gridpin

DuckDB. Two ways to get gridpin_ext:

duckdb
-- 1. once the extension is accepted into the community catalog — DuckDB's own
-- catalog signs and verifies it there; that trust root is DuckDB's, not ours:
INSTALL gridpin_ext FROM community;
LOAD gridpin_ext;
duckdb
# 2. until then: download gridpin_ext-<platform>.zip from the releases page.
# It is loadable code, so authenticate it BEFORE loading — see "Verifying a release"
# in the README; the trust root comes from the other channel, never from the download.
$ ssh-keygen -Y verify -f gridpin-release-signers -I gridpin-release \
    -n gridpin-g02 -s attestation.json.sig < attestation.json \
    || { echo "signature does not verify — STOP"; exit 1; }
$ want=$(python3 -c 'import json;print([a["sha256"] for a in json.load(open("attestation.json"))["assets"] if a["name"]=="gridpin_ext-osx_arm64.zip"][0])')
$ test "$want" = "$(shasum -a 256 gridpin_ext-osx_arm64.zip | cut -d' ' -f1)" \
    || { echo "extension does not match the signed attestation — STOP"; exit 1; }
# The file inside is named gridpin_ext.duckdb_extension — keep that name: DuckDB derives
# the entry symbol from the file's basename, so it must load as gridpin_ext.
$ unzip gridpin_ext-osx_arm64.zip
$ duckdb -unsigned
duckdb
LOAD '/path/to/gridpin_ext.duckdb_extension';

Forward geocoding

Address in, ranked candidates out. -k sets how many candidates you want.

terminalCLI
$ gridpin query france.bin "1 rue de la Paix Paris" -k 3

Reverse geocoding

Coordinates in, nearest known address out — with a precision flag and its distance, so an approximate hit is never dressed up as exact.

terminalCLI
$ gridpin reverse france.bin 48.8686 2.3305

Batch mode

JSON Lines in ({"q": "1 rue de la Paix Paris"} per line), JSON Lines out ({"results": […candidates…]} per line, order preserved). This is the fastest path for large jobs.

terminalCLI
$ gridpin batch france.bin in.jsonl out.jsonl -k 1
Which sheet? France 365 MB / 26.1M addresses (BAN registry) · Italy 247 MB / 25.9M (Overture/ANNCSU) · Netherlands 103 MB / 9.9M (Overture/BAG) · Serbia 32 MB / 2.6M (Overture/RGZ). The free tier ships static builds of all available countries; monthly updates are subscriber-only.
Python

Python

The same engine as a wheel. Exact lookups run at ~3,000/s per core from Python; use geocode_many(), the CLI batch mode, or DuckDB to spread messy input across cores.

terminalpip
$ pip install gridpin
geocode.pyPython
import gridpin

g = gridpin.Geocoder("france.bin")   # mmap, opens instantly

# forward: query, k candidates
hits = g.geocode("1 rue de la Paix Paris", 1)

# reverse: lat, lon, k candidates
addr = g.reverse(48.8686, 2.3305, 1)

# exact lookups ~3,000/s per core

Results are plain objects with the same fields as the JSON response format below.

DuckDB

DuckDB extension

Geocode whole tables without leaving SQL. Load the extension, point it at a sheet, and call scalar functions in your queries.

duckdbSQL
LOAD gridpin_ext;
SELECT gridpin_load('france.bin');

-- forward: one JSON result per row
SELECT gridpin_geocode(address) FROM orders;

-- reverse: nearest address per coordinate pair
SELECT gridpin_reverse(lat, lon) FROM pings;

Both functions return the standard JSON response as a string; unpack fields with DuckDB's json_extract or the ->> operator.

Switching sheets. One index per session. Loading the same sheet again is a no-op; loading a different sheet is a hard error — it never silently replaces the loaded index. Switch explicitly with gridpin_reset(), which must be its own statement, then load the new sheet:

duckdbSQL
SELECT gridpin_load('france.bin');  -- index loaded
SELECT gridpin_load('italy.bin');   -- ERROR: 'italy.bin' while 'france.bin' is loaded; run gridpin_reset()
SELECT gridpin_reset();             -- index unloaded
SELECT gridpin_load('italy.bin');   -- index loaded
Reference

Response format

Every interface — CLI, Python, DuckDB — returns the same result shape. One object per candidate, best match first.

resultJSON
{
  "lat": 48.8686,
  "lon": 2.3305,
  "precision": "house",
  "score": 412.0,          // illustrative values
  "confidence": 0.84,
  "street": "Rue de la Paix",
  "housenumber": "5",
  "commune": "Paris 2e Arrondissement",
  "postcode": "75002",
  "flags": ["street_exact", "commune_prefix", "house_rep"]
}
FieldTypeMeaning
lat, lon float WGS84 coordinates of the pin.
precision enum How exact the pin is: house — forward: exact point for the requested house number; reverse: the nearest house is within 50 m interp — interpolated along the street between known numbers near — forward: snapped to the nearest known number on the street; reverse: the nearest house is 50–250 m from your point street — street-level match, no usable house number city — locality-level match only approximate — reverse only: the nearest known house is more than 250 m away
score float Raw ranking score used to order candidates. Comparable within one query, not across queries.
confidence float 0..1 Normalized estimate of how likely this candidate is the right answer. Use this for thresholds.
street string Matched street name, as stored in the sheet (forward and reverse alike — the house number is never glued into it).
housenumber string, optional House number with its suffix ("27", "12bis"). Present when a house is resolved: forward exact/near answers report the stored matched number, interpolation reports the requested number, and reverse reports the nearest house. Omitted for street/city-only answers.
commune string Matched municipality / locality.
postcode string Postal code of the matched address, when the source data has one.
flags string[] Match explanation (street_exact/street_fuzzy, commune_exact/commune_prefix, house_rep, pc_exact/pc_dept, ml, poi_layer). Omitted entirely when empty — treat a missing key as [].
distance_m float, optional Reverse queries only: metres from the queried point to the returned address.
region string, optional Administrative region of the pin, where the sheet carries admin polygons.

Empty results: the CLI prints nothing (exit 0), Python returns an empty list, DuckDB's gridpin_geocode/gridpin_reverse return the string '{}'.

Optional

POI layer

Addresses are the core index. Places — "pharmacie gare de Lyon" — live in an opt-in, separate file, currently for France: 231 MB of Overture places.

How the cascade works

The POI layer never sits inside the address index — it is a second file, and the engine runs the cascade for you when you pass it:

cascade
# CLI
$ gridpin query france.bin "pharmacie gare de Lyon" --poi fr_poi.bin -k 1

# Python
g = gridpin.Geocoder("france.bin", poi="fr_poi.bin")

-- DuckDB
SELECT gridpin_load_poi('fr_poi.bin');  -- after gridpin_load(...)

Cascade rules (built in): 1) the address index answers first; 2) the POI file is consulted only when the address result is empty, city-level or low-confidence; 3) an exact address match is never overridden. POI answers carry a "poi_layer" flag. Without --poi the engine behaves exactly as before — the layer is strictly opt-in.

Honest numbers. On a name-plus-city POI pilot with Wikidata coordinates as truth (France), GridPin with the POI layer resolves 49% of queries within 500 m (62% within 2 km). Photon resolves 71% within 500 m on the same set — it is stronger on POIs today. The layer is young and opt-in: address lookup is the core product, POI is an adjacent capability we are still building out.

Pipeline

Building sheets yourself

The prep/ pipeline that produces sheets is public: plain DuckDB SQL from raw source data to the finished file. You can build a working sheet for any supported country end-to-end. Official sheets differ in one part: the curated per-country rules section is private, so a self-built sheet runs on the engine's built-in defaults.

Try it on Monaco

Monaco is the smallest end-to-end example: it builds a sheet from a raw OSM extract. The same target runs in CI as a public smoke test with 23 live-query checks.

terminalmake
$ make mc      # build the Monaco sheet from an OSM extract
$ make smoke   # build Monaco + run the 23 live-query checks CI runs

Full countries

The same pipeline builds any country in the atlas, but plan for it: a full-country build takes hours and tens of gigabytes of temporary disk for the source data and intermediate tables. That is exactly the work the subscription buys you out of — fresh, tested sheets arrive monthly; the free tier is static, with no update schedule.

Delivery

Getting the files

Everything published today is free and needs no key, no account and no sign-up. Data files are served as individual objects — there is no directory listing, so each link points straight at a file: france.bin, italy.bin, netherlands.bin, serbia.bin, fr_poi.bin. The engine binaries, the checksums and the signed provenance attestation are on the GitHub releases page.

Every sheet carries its provenance inside the file: gridpin meta france.bin prints the source, its license, the source release and the attribution you need when you redistribute. The release additionally ships verify_release.py, which checks the owner’s signature over the whole set — see Verifying a release.

A published sheet is a static build: it does not change under you, and no refresh schedule is promised. Automated delivery of regularly rebuilt sheets is planned for paid plans, which are not open yet.

FAQ

Frequently asked questions

Can I load two countries at once?

CLI and Python: yes — open one Geocoder per country (each is just an mmap). DuckDB: one index per session — loading the same sheet again is a no-op, but loading a different sheet is refused. Switch explicitly with SELECT gridpin_reset(); then gridpin_load the new sheet (or use a separate session per country); see switching sheets.

Can I store the results?

Yes — forever. Everything GridPin computes on your machine is yours: cache it, load it into your database, ship it inside your product.

How does this work with GDPR?

Your data never leaves your infrastructure: no API call to geocode, no third-party processor, no telemetry — the geocoder runs where your data already lives. The only network action is downloading a sheet yourself, and a downloaded sheet carries no query data.

Does it really work offline?

Fully. The engine needs only the sheet file on disk. Air-gapped VPC, a laptop on a plane, a box in a basement — all the same to GridPin.

How often is the data updated?

Monthly on subscription. Free builds are static — no update schedule — and carry the same curated rules section subscribers get: you trade freshness, not quality. Each sheet states its source and build date.

Still stuck? We answer mail fast: contact@gridpin.dev. Benchmark methodology and per-release quality numbers live on the test bench.