← All posts
ExtensionsSpatiaLiteSpatial SQLTutorial

Spatial SQL on the desktop: using SpatiaLite with Chwilio

Load the SpatiaLite extension in Chwilio and run real Spatial SQL (nearest-neighbour, point-in-polygon, distance, area, reprojection) against an ordinary SQLite file. No server, no PostGIS.

Chwilio12 min read

SQLite plus SpatiaLite is a complete, OGC-compliant spatial database in a single file. You get geometry types, hundreds of spatial functions, an R*Tree spatial index, and on-the-fly reprojection between coordinate systems — most of what you’d reach to PostGIS for, with nothing to run but a .sqlite file you can email.

There’s just one catch, and it’s the reason this post exists.

SQLite can do GIS, if your tools let it

SpatiaLite ships as a loadable SQLite extension (mod_spatialite). And loading an extension is exactly the thing almost no GUI SQLite tool will do. Extensions are native code, so most browsers disable loading entirely, which is why, on the desktop, Spatial SQL has been a command-line-only affair. You could build a whole spatial database, but the moment you wanted to look at it, you were back in a terminal.

I built Chwilio around the opposite idea. Loading a trusted extension should be a normal, point-and-click thing, so SpatiaLite becomes something you load once and then use like any other SQL — autocomplete, a result grid, EXPLAIN, and export. (Here’s how Chwilio loads extensions safely.)

Everything below is a real run against a small spatial-demo.sqlite (ten world cities and two region polygons), captured straight from the app.

What SpatiaLite gives you

  • Geometry types. POINT, LINESTRING, POLYGON, their multi- variants, and collections, each tagged with an SRID (coordinate system).
  • The ST_* function family. Measurement (ST_Distance, ST_Area, GLength), relationships (ST_Within, ST_Contains, ST_Intersects), construction (MakePoint, GeomFromText, ST_Buffer), and readers (AsText, AsGeoJSON).
  • A real spatial index. An R*Tree you create with one function call and query through a virtual table.
  • Reprojection. Transform() converts geometry between coordinate systems using PROJ (e.g. WGS84 ↔ Web Mercator).
  • Virtual tables. KNN, SpatialIndex, shapefile and spreadsheet readers, and more.

It’s native code, which is why a browser has to deliberately choose to load it. Chwilio does that deliberately — on macOS behind an architecture check and a helper-process test load first — then keeps it loaded for every connection.

Step 1: Load mod_spatialite in Chwilio

SpatiaLite is the awkward case for extension loading, because it isn’t one file. It’s one library plus a whole tree of dependencies — GEOS, PROJ, freexl, libxml2 and friends — and how you get that tree to resolve differs by platform. Pick your side.

brew install libspatialite puts mod_spatialite.dylib in /opt/homebrew/lib. You can load it straight from there, but it’s worth taking one extra step first.

Homebrew’s mod_spatialite.dylib references its dependencies (GEOS, PROJ, freexl, libxml2, and friends) by absolute /opt/homebrew/... paths. That ties it to your exact Homebrew install: move it, ship it to a colleague, or load it from anywhere else and dyld can’t find the chain, so the load fails with “Library not loaded”. dylibbundler (brew install dylibbundler) copies the whole dependency tree into one folder and rewrites every reference to @loader_path/, a path relative to the dylib itself, making it a portable unit that loads from any location:

mkdir -p ~/spatialite-fixed && cd ~/spatialite-fixed
cp /opt/homebrew/lib/mod_spatialite.dylib .

dylibbundler -of -cd -b \
  -x ./mod_spatialite.dylib \
  -d ./ \
  -p @loader_path/

The flags: -x is the file to fix, -d where the copied dependencies go, -p the path written into the references, -of overwrite, -cd create the destination dir, -b bundle the dependencies. Then verify the rewrite:

otool -L mod_spatialite.dylib

Every non-system line should now read @loader_path/... with no /opt/homebrew left. You now have a self-contained ~/spatialite-fixed/mod_spatialite.dylib you can move, back up, or share.

Windows: download the official bundle, and that’s it

There’s no relinking step, because the project already ships a self-contained build. Grab mod_spatialite-<version>-win-amd64.7z from gaia-gis.it and extract it anywhere — C:\sqlite-ext\spatialite\ is as good as anywhere. Inside you’ll find mod_spatialite.dll sitting next to 27 dependency DLLs.

That count is the interesting part. Windows resolves a DLL’s dependencies starting from the application’s directory, never from the directory the DLL itself lives in — so the usual advice for loading SpatiaLite on Windows is to put the folder on your PATH and restart the app. That’s the step everyone hits, and it’s why “just point the tool at the DLL” normally doesn’t work.

Chwilio doesn’t need it. It puts the extension’s own folder on the DLL search path for the duration of the load, so the 27 siblings resolve from where they already are. Point it at mod_spatialite.dll and press Test Now:

Tested OK   Loaded successfully into the current database.

Nothing on PATH, nothing copied next to the app, nothing to restart. (Leaving it off PATH is also the safer choice: that bundle ships its own libcurl-4.dll, and a stray copy of that on PATH is exactly the kind of thing that breaks other software in confusing ways.)

Load it in Chwilio

Open Extension ▸ Extension Manager (⇧⌘E on macOS, Ctrl+Shift+E on Windows), click Load Extension…, and point it at your mod_spatialite.dylib or mod_spatialite.dll. Leave the entry point on Automatic (SQLite detects sqlite3_modspatialite_init itself) and set the database scope to All Databases.

Chwilio's Extension Manager with mod_spatialite selected — Automatic entry point, All Databases scope, File available status, and load order, alongside other loaded extensionsChwilio's Extension Manager with mod_spatialite selected — Automatic entry point, All Databases scope, File available status, and load order, alongside other loaded extensionsChwilio's Extension Manager with mod_spatialite selected — Automatic entry point, All Databases scope, File available status, and load order, alongside other loaded extensionsChwilio's Extension Manager with mod_spatialite selected — Automatic entry point, All Databases scope, File available status, and load order, alongside other loaded extensions

Either way, Chwilio turns the platform’s unhelpful error into something you can act on. On macOS it also preflights the load before any native code goes near your real connection: an architecture check (an arm64/x86_64 mismatch is reported, not crashed) plus a separate helper-process load, and dyld’s cryptic “Library not loaded / image not found” rewritten into a hint — exactly the failure the dylibbundler step above prevents. On Windows there is no separate preflight pass; what you get is the diagnosis — telling apart the two things that both surface as “The specified module could not be found”: the file isn’t there, versus the file is there but something it depends on isn’t. Once it’s in, the connection bar reports the extension is ready and the full ST_* family autocompletes in the SQL editor.

Step 2: Build a spatial database

A geometry column isn’t a plain blob you stuff coordinates into. SpatiaLite tracks its type and SRID in metadata tables and guards it with triggers. The setup is a few function calls:

-- 1. Seed the spatial metadata (spatial_ref_sys, geometry_columns, …)
SELECT InitSpatialMetadata(1);

-- 2. A normal table…
CREATE TABLE cities (
  id         INTEGER PRIMARY KEY,
  name       TEXT NOT NULL,
  country    TEXT NOT NULL,
  population INTEGER
);

-- 3. …gets a typed, SRID-4326 (WGS84 lon/lat) POINT column
SELECT AddGeometryColumn('cities', 'geom', 4326, 'POINT', 'XY');

-- 4. Insert points with MakePoint(longitude, latitude, srid)
INSERT INTO cities (name, country, population, geom) VALUES
  ('London', 'UK', 8982000, MakePoint(-0.1276, 51.5074, 4326)),
  ('Paris',  'FR', 2161000, MakePoint( 2.3522, 48.8566, 4326)),
  ('Tokyo',  'JP', 13960000, MakePoint(139.6917, 35.6895, 4326));
  -- …and so on

-- 5. Build the R*Tree spatial index on the geometry column
SELECT CreateSpatialIndex('cities', 'geom');

Run that in Chwilio’s SQL editor and the Structure tab shows the result: a geom column typed POINT, alongside the spatial-constraint triggers SpatiaLite created to keep the geometry and its index consistent.

Chwilio's Structure tab for the cities table — the geom column carrying SpatiaLite's POINT type, plus the eight geometry triggersChwilio's Structure tab for the cities table — the geom column carrying SpatiaLite's POINT type, plus the eight geometry triggersChwilio's Structure tab for the cities table — the geom column carrying SpatiaLite's POINT type, plus the eight geometry triggersChwilio's Structure tab for the cities table — the geom column carrying SpatiaLite's POINT type, plus the eight geometry triggers

A note for CLI refugees: in the bare sqlite3 shell these inserts fail with “unsafe use of GeometryConstraints()” unless you remember PRAGMA trusted_schema=ON. In Chwilio they just work: one less papercut.

Step 3: Read the geometry

Geometry is stored as a compact binary blob, so browsing the table the normal way shows you exactly that, an opaque BLOB:

Chwilio's data grid showing the cities table — id, name, country, population, and geom shown as a 60-byte BLOBChwilio's data grid showing the cities table — id, name, country, population, and geom shown as a 60-byte BLOBChwilio's data grid showing the cities table — id, name, country, population, and geom shown as a 60-byte BLOBChwilio's data grid showing the cities table — id, name, country, population, and geom shown as a 60-byte BLOB

To read it, ask SpatiaLite to format it. AsText returns WKT; AsGeoJSON returns GeoJSON you can drop straight onto a map:

SELECT name, country, AsText(geom) AS wkt, AsGeoJSON(geom) AS geojson
FROM cities
ORDER BY name
LIMIT 6;
Chwilio SQL editor showing AsText and AsGeoJSON decoding the geometry blobs into readable WKT and GeoJSONChwilio SQL editor showing AsText and AsGeoJSON decoding the geometry blobs into readable WKT and GeoJSONChwilio SQL editor showing AsText and AsGeoJSON decoding the geometry blobs into readable WKT and GeoJSONChwilio SQL editor showing AsText and AsGeoJSON decoding the geometry blobs into readable WKT and GeoJSON

Step 4: Spatial queries you can actually use

This is where Spatial SQL earns its keep. Every query below is plain SQL you run in the editor; the spatial part is just functions.

Nearest neighbour: “what’s near me?”

The bread and butter of every “stores near you” feature. ST_Distance with the ellipsoidal flag (1) returns metres, so ordering by it gives you the closest rows:

-- 5 cities nearest to Brussels (lon 4.3517, lat 50.8503), distance in km
SELECT name, country,
       ROUND(ST_Distance(geom, MakePoint(4.3517, 50.8503, 4326), 1) / 1000.0, 1) AS km
FROM cities
ORDER BY ST_Distance(geom, MakePoint(4.3517, 50.8503, 4326), 1)
LIMIT 5;
Chwilio result grid: the five cities nearest Brussels — Paris 264.3 km, London 321.6 km, Berlin 652.6 km, Rome 1173.9 km, Madrid 1317 kmChwilio result grid: the five cities nearest Brussels — Paris 264.3 km, London 321.6 km, Berlin 652.6 km, Rome 1173.9 km, Madrid 1317 kmChwilio result grid: the five cities nearest Brussels — Paris 264.3 km, London 321.6 km, Berlin 652.6 km, Rome 1173.9 km, Madrid 1317 kmChwilio result grid: the five cities nearest Brussels — Paris 264.3 km, London 321.6 km, Berlin 652.6 km, Rome 1173.9 km, Madrid 1317 km

Paris at 264 km, London at 322 km, Berlin at 653 km: real great-circle distances, computed in the database. Swap the LIMIT for a WHERE and you have a radius search instead:

-- Everything within 1500 km of Brussels
SELECT name, ROUND(ST_Distance(geom, MakePoint(4.3517, 50.8503, 4326), 1) / 1000.0, 1) AS km
FROM cities
WHERE ST_Distance(geom, MakePoint(4.3517, 50.8503, 4326), 1) <= 1500000
ORDER BY km;

Point-in-polygon: “which region is this in?”

Geofencing, territory assignment, “which delivery zone does this address fall in”: all the same question, really. Is a point inside a polygon? ST_Within answers it, and you can join straight on it:

-- Which region polygon contains each city?
SELECT c.name AS city, c.country, r.name AS region
FROM cities c
JOIN regions r ON ST_Within(c.geom, r.geom)
ORDER BY r.name, c.name;
Chwilio result grid: each European city tagged Europe (bbox) and each US city tagged North America (bbox) via ST_WithinChwilio result grid: each European city tagged Europe (bbox) and each US city tagged North America (bbox) via ST_WithinChwilio result grid: each European city tagged Europe (bbox) and each US city tagged North America (bbox) via ST_WithinChwilio result grid: each European city tagged Europe (bbox) and each US city tagged North America (bbox) via ST_Within

The European cities resolve to the Europe polygon and the US cities to North America, while Tokyo, Sydney, and Cairo, which fall in neither box, simply drop out of the join.

Measure and reproject

Areas and lengths (ellipsoidal, in m² and metres), buffers, and coordinate-system conversions are one function call each:

-- Area (km²) and perimeter (km) of each region, on the ellipsoid
SELECT name,
       ROUND(ST_Area(geom, 1) / 1000000.0)  AS area_km2,
       ROUND(Perimeter(geom, 1) / 1000.0)   AS perimeter_km
FROM regions;

-- Reproject WGS84 (lon/lat) → Web Mercator (the projection web maps use)
SELECT name, AsText(Transform(geom, 3857)) AS web_mercator
FROM cities
WHERE name = 'London';

Step 5: Make it fast with the spatial index

CreateSpatialIndex built an R*Tree, but SQLite won’t use it automatically. You opt in by filtering row IDs through the SpatialIndex virtual table with a bounding box (BuildCircleMbr here builds one around a point):

SELECT c.name, c.country
FROM cities c
WHERE c.ROWID IN (
  SELECT ROWID FROM SpatialIndex
  WHERE f_table_name = 'cities'
    AND search_frame = BuildCircleMbr(2.3522, 48.8566, 6.0, 4326)
);

Does it actually hit the index? Chwilio’s EXPLAIN QUERY PLAN answers that without you leaving the editor:

Chwilio's EXPLAIN QUERY PLAN tree showing SCAN SpatialIndex VIRTUAL TABLE INDEX — the R*Tree spatial index in useChwilio's EXPLAIN QUERY PLAN tree showing SCAN SpatialIndex VIRTUAL TABLE INDEX — the R*Tree spatial index in useChwilio's EXPLAIN QUERY PLAN tree showing SCAN SpatialIndex VIRTUAL TABLE INDEX — the R*Tree spatial index in useChwilio's EXPLAIN QUERY PLAN tree showing SCAN SpatialIndex VIRTUAL TABLE INDEX — the R*Tree spatial index in use

SCAN SpatialIndex VIRTUAL TABLE INDEX is the R*Tree doing its job: instead of measuring distance to every row, SpatiaLite narrows to candidates inside the bounding box first. On ten cities it’s academic; on a few million points it’s the difference between instant and unusable.

What Chwilio adds

Nothing above is exotic SQL, but you couldn’t run any of it from a desktop GUI before, because none of them load the extension. With Chwilio you get the whole loop in one window:

  • Extension loading as a real feature. One of the very few macOS SQLite browsers that loads extensions at all, with preflight checks and explicit load states rather than a crash or a silent failure.
  • ST_* autocomplete in the editor, a result grid for inspecting output, and EXPLAIN QUERY PLAN to confirm the index is used.
  • AsGeoJSON → export. Copy or export a spatial query’s results as GeoJSON/CSV and drop them onto a map.
  • Change-set-safe edits. Edit spatial data through Chwilio’s reviewable, preview-first change set instead of firing raw UPDATEs.

Where Spatial SQL helps SQLite users

If you’ve never reached for geometry in SQLite, here’s the kind of thing it unlocks, all from a single file, with no server and no PostGIS:

  • “Near me” / store locators: nearest-neighbour over your own data.
  • Geofencing and territory rules: point-in-polygon to assign zones, regions, or sales territories.
  • Logistics and routing prep: distances, buffers, and catchment areas for planning.
  • Offline and mobile maps: SQLite is the on-device database, and SpatiaLite makes it spatial, fully offline.
  • Geospatial data science: join coordinates to business data and query it in plain SQL.
  • Asset, fleet, and IoT tracking: store positions, ask which ones are inside a region or within range.
  • Real-estate and catchment analysis: what’s within X km, which district a parcel sits in.

The whole thing is still one .sqlite file you can commit to a repo or email to a colleague. The piece that was missing on the desktop — actually opening it and running the queries — is the part Chwilio fills in.

You can try this right now, for free. Chwilio is in private beta, and the beta build unlocks every feature, including loading your own extensions, for the whole beta period at no cost. Grab a beta invite, then brew install libspatialite and load it from the Extension Manager. When the beta period ends, access reverts to the free trial, and keeping these features needs a license. Questions about a specific extension? Get in touch.