TinySQL

August 26, 2026 · View on GitHub

CI Go Reference DOI

TinySQL is an embeddable SQL database engine written in Go. It is designed for learning database internals, local tools, tests, browser/WASM applications, and single-process services that need a capable SQL layer without operating a database server.

Try it: browser playground · interactive map demo · video

TinySQL is not a drop-in replacement for PostgreSQL, MySQL, or a clustered production database. Review the limitations before using it for critical workloads.

Contents

Quick start

Requires Go 1.26.5+.

go get github.com/SimonWaldherr/tinySQL@latest

Create an in-memory database, execute SQL, and read rows:

package main

import (
	"context"
	"fmt"

	tinysql "github.com/SimonWaldherr/tinySQL"
)

func main() {
	ctx := context.Background()
	db := tinysql.NewDB()

	for _, query := range []string{
		`CREATE TABLE users (id INT PRIMARY KEY, name TEXT)`,
		`INSERT INTO users VALUES (1, 'Ada'), (2, 'Grace')`,
	} {
		stmt, err := tinysql.ParseSQL(query)
		if err != nil {
			panic(err)
		}
		if _, err := tinysql.Execute(ctx, db, "default", stmt); err != nil {
			panic(err)
		}
	}

	stmt, err := tinysql.ParseSQL(`SELECT id, name FROM users ORDER BY id`)
	if err != nil {
		panic(err)
	}
	result, err := tinysql.Execute(ctx, db, "default", stmt)
	if err != nil {
		panic(err)
	}
	for _, row := range result.Rows {
		id, _ := tinysql.GetVal(row, "id")
		name, _ := tinysql.GetVal(row, "name")
		fmt.Println(id, name)
	}
}

For large scans, ExecSQLStream exposes rows while the query is still running instead of materializing the complete result first:

stream, err := tinysql.ExecSQLStream(ctx, db, "default",
	`SELECT id, name FROM users WHERE active = true`)
if err != nil { panic(err) }
defer stream.Close() // important when iteration stops early

for stream.Next() {
	row := stream.Row()
	fmt.Println(row["id"], row["name"])
}
if err := stream.Err(); err != nil { panic(err) }

Simple table scans, filters, projections, index seeks, and LIMIT/OFFSET stream incrementally. Operations that require the complete input—such as ORDER BY, GROUP BY, DISTINCT, joins, and set operations—preserve exact SQL semantics and start yielding after their result has been materialized.

For applications that already use database/sql, use github.com/SimonWaldherr/tinySQL/driver.

What it can do

SQL engine

  • SELECT, INSERT, UPDATE, DELETE, RETURNING, CTEs, subqueries, joins, grouping, window functions, PIVOT, EXPLAIN, and common SQLite-compatible PRAGMAs.
  • Views, materialized views, triggers, table-valued functions, stored procedures, jobs, multi-tenancy, and system catalog views.
  • Constraints including single-column primary keys, unique keys, foreign keys, NOT NULL, and literal defaults.
  • Secondary indexes, exact persisted statistics through ANALYZE, and planner selectivity estimates.
  • JSON, YAML, text, regex, math, date, URL, hash, bitmap, full-text, vector, hybrid-search, and RAG helper functions.

Embedding and delivery

  • Pure-Go, in-process API and a database/sql driver.
  • Memory, WAL, disk, JSON, index, hybrid, and paged-index storage modes.
  • Browser/WASM builds and a local-first SQL playground.
  • CLI, HTTP server, file-query tools, and health/lifecycle hooks.
  • Optional audit logging, RBAC, and encryption at rest for supported table-file backends.

Data and maps

  • CSV, TSV, JSON/NDJSON, XML, YAML, Excel, GeoJSON, TopoJSON, KML, OSM XML, routing graphs, Shapefiles, and MBTiles import paths.
  • GeoJSON measurement, containment, relationship, editing, cleanup, region (dissolve/clip), and inspection functions, plus a spatial search index and choropleth classification (equal-interval, natural breaks, quantile) for location-based BI dashboards.
  • GeoJSON and TopoJSON export from query results (-mode geojson|topojson), the format Power BI Shape Maps and most mapping tools prefer.
  • Web Mercator tile addressing, MBTiles import/export, in-place tile access, and an optional XYZ tile endpoint.

See FUNCTIONS.sql and example_showcase.sql for the broader SQL reference.

Using SQL from Go

ParseSQL + Execute is the basic API. Use the fluent builder when composing queries programmatically:

query := tinysql.Select(tinysql.Col("name")).
	From("users").
	Where(tinysql.Eq(tinysql.Col("active"), tinysql.Val(true))).
	OrderBy("name").
	Build()

result, err := tinysql.Execute(ctx, db, "default", query)

The builder supports projections, joins, CTEs, ordering, limits, expressions, and Exists/NotExists predicates. See ExampleExists for a runnable example.

Transactions and triggers

The Go driver supports cross-statement transactions with BEGIN, COMMIT, and ROLLBACK (or BeginTx). A transaction sees its own writes; a concurrent write conflict is returned as a retryable ErrTransactionConflict.

Row triggers run for BEFORE/AFTER INSERT, UPDATE, and DELETE, including DELETE without a WHERE clause. Direct multi-row mutations and their trigger effects are statement-atomic.

import (
	"context"

	tsqldriver "github.com/SimonWaldherr/tinySQL/driver"
)

db, err := tsqldriver.OpenInMemory("default")
if err != nil { panic(err) }
defer db.Close()

tx, err := db.BeginTx(context.Background(), nil)
if err != nil { panic(err) }
if _, err := tx.Exec(`INSERT INTO users VALUES (3, 'Lin')`); err != nil {
	_ = tx.Rollback()
	panic(err)
}
if err := tx.Commit(); err != nil { panic(err) }

SAVEPOINT and nested transactions are not implemented.

GIS and GeoJSON

TinySQL stores geometry as ordinary GeoJSON, either in a TEXT/JSON column or in the dedicated GEOMETRY column type, which validates on write (a bare number or a Feature/FeatureCollection is rejected — a GEOMETRY column holds a Geometry) and canonicalizes to stable, byte-identical text. Most geometry functions return GeoJSON too, so results can be passed directly into later SQL calls or drawn in a map client.

CREATE TABLE places (name TEXT, geometry GEOMETRY);
INSERT INTO places VALUES
  ('Berlin', GEO_POINT(13.4050, 52.5200)),
  ('Munich', GEO_POINT(11.5755, 48.1372));

SELECT GEO_DISTANCE(a.geometry, b.geometry) AS meters,
       GEO_BEARING(a.geometry, b.geometry) AS bearing,
       GEO_MIDPOINT(a.geometry, b.geometry) AS midpoint
FROM places a JOIN places b
  ON a.name = 'Berlin' AND b.name = 'Munich';

GEOMETRY/GEOM is additive — existing TEXT/JSON geometry columns and the query above keep working unchanged. GEOMETRY(SRID)-style parameters are not supported; use the bare keyword. CAST(x AS GEOMETRY) validates and canonicalizes the same way a column write does.

Measurement and predicates

TaskFunctionPostGIS-style alias
Create/read a pointGEO_POINT, GEO_LON, GEO_LATST_POINT, ST_X, ST_Y
Great-circle distanceGEO_DISTANCEST_DISTANCE, HAVERSINE
Radius checkGEO_DWITHINST_DWITHIN
Bounding-box checkGEO_WITHIN_BBOXST_WITHIN_BBOX
Bearing / midpoint / destinationGEO_BEARING, GEO_MIDPOINT, GEO_DESTINATIONST_AZIMUTH, ST_MIDPOINT, ST_PROJECT
Point in polygon/multipolygonGEO_WITHIN_POLYGONST_WITHIN, ST_CONTAINS
Polygon area / line lengthGEO_POLYGON_AREA, GEO_LENGTHST_AREA, ST_LENGTH
Any shared point (point/line/polygon, any combination)GEO_INTERSECTSST_INTERSECTS
No shared pointGEO_DISJOINTST_DISJOINT
Same coordinates (order/rotation/winding independent)GEO_EQUALSST_EQUALS
Circular buffer around a pointGEO_BUFFER(point, meters[, segments])ST_BUFFER
Convex hull of a geometry's verticesGEO_CONVEX_HULLST_CONVEXHULL
Bounding box as a polygonGEO_ENVELOPEST_ENVELOPE
Point at a fraction along a lineGEO_LINE_INTERPOLATE(line, fraction)ST_LINE_INTERPOLATE_POINT
Clip a geometry to a convex boundaryGEO_CLIP(geometry, boundary[, allow_nonconvex])ST_CLIP

Distance, bearing, destination, length, area, and buffer calculations operate on the sphere; distances are meters and polygon areas are square meters. Coordinates in raw four-number forms use (lat, lon, lat, lon); GeoJSON remains [lon, lat]. GEO_WITHIN_POLYGON/ST_CONTAINS/GEO_POLYGON_AREA accept a GeoJSON MultiPolygon as well as a Polygon — membership in any part counts as membership in the whole, and area sums every part. GEO_LINE_INTERPOLATE splits by actual distance along the line, not by vertex count. GEO_CONVEX_HULL computes the hull in plain lon/lat space (a standard planar approximation, not a rigorous spherical hull).

GEO_INTERSECTS/GEO_DISJOINT cover point/line/polygon in any combination, respecting polygon holes (a shape nested inside another polygon's hole is disjoint from it). There is no ST_TOUCHES/ST_CROSSES/ST_OVERLAPS: rigorously distinguishing boundary-only contact from interior overlap needs a full DE-9IM computation, which is out of scope — a naive attempt would be silently wrong on exactly the shared-edge/hole cases real GIS data hits constantly. GEO_EQUALS is scoped to coordinate/shape equality (matching after any rotation, reversal, or Polygon-vs-single-part-MultiPolygon wrapping), not full OGC point-set equality — two polygons covering the same area with different vertexization are not detected as equal.

GEO_CLIP uses Sutherland-Hodgman polygon clipping, which is only guaranteed correct against a convex boundary; it validates convexity by default and errors otherwise, with allow_nonconvex=true as an explicit best-effort opt-out. GEO_CLIP supports Point/MultiPoint and Polygon/MultiPolygon subjects; LineString clipping needs a different algorithm and isn't supported.

Geometry editing and quality

TaskFunctionPostGIS-style alias
Simplify a geometryGEO_SIMPLIFY(geometry, tolerance[, method])ST_SIMPLIFY
Inspect bbox / centroidGEO_BBOX, GEO_CENTROIDST_BBOX, ST_CENTROID
Shift, scale, rotateGEO_AFFINEST_AFFINE
Chaikin smoothingGEO_SMOOTHST_SMOOTH
Remove polygon holesGEO_DROP_HOLESST_REMOVE_HOLES
Clean duplicate vertices and close ringsGEO_CLEANST_CLEAN
Snap x/y to a gridGEO_SNAP(geometry, gridSize)ST_SNAPTOGRID
Structural GeoJSON checkGEO_IS_VALIDST_ISVALID

GEO_SIMPLIFY accepts Douglas-Peucker (dp, the default), visvalingam-effective, and visvalingam-weighted. Simplification, affine, smoothing, cleanup, and snapping operate in source coordinate units. GEO_SNAP and GEO_CLEAN reject a result that collapses a line or polygon ring below the GeoJSON minimum vertex count.

GEO_IS_VALID checks supported GeoJSON structure and vertex requirements. It does not yet detect topology issues such as self-intersections. There is no R-tree, and an ordinary WHERE GEO_DWITHIN(...)/GEO_WITHIN_BBOX(...) predicate is not planner-accelerated — it filters rows after ordinary index narrowing. For BI-scale point tables, GEO_SEARCH (below) provides an explicit, indexed alternative.

The interactive map demo can edit built-in or uploaded GeoJSON locally, display source and result, tune parameters, check validity, and download the result.

Region operations, spatial search, and choropleth classification

Mapshaper-inspired region-editing verbs and BI-oriented helpers for turning raw geometry into location-based KPIs and dashboards:

TaskFunction
Merge a group's polygons into one (shared-edge dissolve)GEO_DISSOLVE(geometry[, snap_grid_degrees])
Same operation, aggregate-style namesGEO_UNION_AGG, ST_UNION
Bounding box across a groupGEO_BBOX_AGG(geometry)
(Optionally weighted) centroid across a groupGEO_CENTROID_AGG(geometry[, weight])
Indexed bbox/radius search over a tableGEO_SEARCH(table, geom_col, 'bbox'|'radius', ...)
Equal-interval choropleth classificationEQUAL_INTERVAL(n) OVER (ORDER BY kpi)
Natural-breaks (Jenks) choropleth classificationNATURAL_BREAKS(n) OVER (ORDER BY kpi)
Quantile choropleth classificationNTILE(n) OVER (ORDER BY kpi) (already existed)
-- Dissolve adjacent building footprints into one district boundary per region,
-- then bucket a KPI (e.g. building count) into 5 choropleth classes.
SELECT region, GEO_DISSOLVE(footprint) AS boundary, COUNT(*) AS buildings
FROM parcels
GROUP BY region;

SELECT region, buildings,
       NATURAL_BREAKS(5) OVER (ORDER BY buildings) AS class
FROM region_stats;

GEO_DISSOLVE/GEO_UNION_AGG/ST_UNION merge polygons by cancelling shared directed edges — correct for topologically-clean, vertex-aligned adjacent input (real GIS boundary data, or this project's own dissolve output fed back in), not a general polygon-boolean-union for overlapping-but-misaligned input. Points/lines in a group are concatenated into a MultiPoint/ MultiLineString instead of dissolved. GEO_CENTROID_AGG's optional weight combines with GEO_CENTROID's own area/length weighting, so GEO_CENTROID_AGG(geom, population) is a population-weighted centroid of already-area-weighted per-row centroids.

GEO_SEARCH builds a lazy, per-table grid index (invalidated automatically on writes) and is exact for Point columns; for polygon/line columns it indexes by centroid, so a large shape whose edge — not its centroid — clips into the query window is a false negative there. ST_INTERSECTS (above) remains the exact, unindexed way to test shape overlap directly.

Quantile classification (NTILE) already existed; EQUAL_INTERVAL and NATURAL_BREAKS are new. NATURAL_BREAKS's Jenks optimization is O(rows × classes²) per partition (computed once, not per row) — fine for realistic choropleth partition sizes (municipalities, postal codes, districts), not tuned for tens of thousands of rows.

Map tiles and MBTiles

For multi-gigabyte, read-mostly datasets, see docs/mbtiles-artifacts.md for the bounded dataset.tinysql importer, validated artifact format, TMS reader API and SQLite comparison procedure.

The map demo fetches every tile with live SQL in WebAssembly. Its source is in cmd/mbtilesdemo.

Web maps use top-origin XYZ rows while MBTiles stores bottom-origin TMS rows. Use TILE_FLIP_Y at the SQL boundary:

SELECT tile_data FROM tiles
WHERE zoom_level = 14
  AND tile_column = TILE_X(13.405, 14)
  AND tile_row = TILE_FLIP_Y(TILE_Y(52.520, 14), 14);

Other tile helpers include TILE_ZXY, TILE_BBOX, TILE_LON, TILE_LAT, TILE_QUADKEY, TILE_FROM_QUADKEY, TILE_PARENT, TILE_CONTAINS, and TILE_COUNT. Add an index before serving a regular tiles table:

CREATE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);

MBTiles import/export uses the optional sqliteimport build tag:

importer.ImportMBTiles(ctx, db, "default", "tiles", "city.mbtiles",
	&importer.ImportOptions{CreateTable: true, BatchSize: 1000})

importer.ExportMBTiles(ctx, db, "default", "out.mbtiles",
	&importer.ExportMBTilesOptions{TileRowIsTMS: true})

For HTTP delivery, run tinysqld -tiles:

GET /tiles/{tileset}/{z}/{x}/{y}.{ext}
GET /tiles/{tileset}.json
GET /tiles/{tileset}/metadata

Tile routes are intentionally unauthenticated because normal map clients cannot attach a bearer token to tile requests. Put an authenticating proxy in front if access restrictions are required. See the storage guide for large, paged-index tilesets.

Imports, exports, and optional build tags

The core engine has no SQLite or Shapefile runtime dependency. Enable those read/write paths only in builds that need them:

# SQLite files and MBTiles via pure-Go modernc SQLite (also required for
# ModeSQLite — a real .sqlite file as tinySQL's native storage; see the
# storage guide)
go build -tags=sqliteimport ./...

# ESRI Shapefile and Shapefile ZIP imports
go build -tags=shapefile ./...

# Both profiles
go build -tags=sqliteimport,shapefile ./...

Without a tag, the corresponding import API remains available and returns a feature-disabled error. The tags are not needed to serve a tileset already loaded into TinySQL.

The exporter package writes result sets as CSV, TSV, JSON, NDJSON, XML, GOB, SQL, Excel (XLSX), GeoJSON, or TopoJSON. It preserves binary values with self-identifying encodings and can emit a table manifest with schema, row count, and a typed-row SHA-256 fingerprint. See ExampleExportJSON.

ExportGeoJSON/ExportTopoJSON turn a query result's geometry column (named explicitly, or auto-detected when exactly one candidate exists) plus every other selected column into a GeoJSON FeatureCollection or TopoJSON Topology — the format Power BI Shape Maps, D3, and most mapping tools expect, ready to drop a computed KPI straight onto a map. The tinysql CLI exposes both as -mode geojson/-mode topojson (with -geom-col to override auto-detection). TopoJSON export deduplicates whole shared-boundary rings into one arc (referenced forward by one feature, reversed by its neighbor) — cheap once every ring is hashed for the arcs table anyway, and exactly what a GEO_DISSOLVE-produced or otherwise topologically-clean dataset benefits from — but does not split a boundary that only partially coincides with another (real topology-building tools like mapshaper do; this is a documented v1 scope cut, not a bug). ImportTopoJSON (or ImportFile/ .import on a .topojson file) resolves arc references — including the reversed (~i) convention and multi-arc-per-ring stitching from topologies built by other tools — back into ordinary geometry rows.

Storage, transactions, and operations

ModeBest fit
ModeMemoryTests, browser/WASM, and temporary local data
ModeWALIn-memory tables with write-ahead-log recovery
ModeDiskPer-table GOB files with lazy loading
ModeJSONHuman-readable, diffable per-table files
ModeIndex / ModeHybridDisk-backed tables with bounded caching
ModePagedIndexLarge equality-lookup workloads such as MBTiles
ModeSQLiteA real .sqlite file, readable by any SQLite tool (requires the sqliteimport build tag)

Use OpenDB with a StorageConfig for persistent storage. Health checks, read-only operation, audit logging, and lifecycle helpers are available for embedded services. Encryption at rest covers table files in supported disk backends; see the storage guide for exact scope.

For high-repeat vector searches, ConfigureVectorCache can enable a bounded, process-local result cache and anonymous shape/timing analytics. See the RAG guide before combining vector metrics and reranking.

Guides and development

GuideUse it for
Developer integrationGo, database/sql, and browser embedding
CLI guideREPL, servers, and file-query tools
Storage guideBackends, DSNs, read-only mode, large tilesets
RAG guideVector, hybrid retrieval, reranking, and context
TinyGo guideTinyGo, embedded targets, and WASM
ArchitectureParser, executor, storage, and invariants
Development guideTests, Make targets, and releasing demos
BenchmarksReproducible performance measurements

Run the full test suite:

go test ./...

Build the browser playground:

cd cmd/query_files_wasm
./build.sh --build-only

Limitations

  • Single-process only: no built-in replication, clustering, sharding, distributed transactions, or failover.
  • No composite primary/foreign keys, CHECK, UPSERT/ON CONFLICT, SAVEPOINT, ATTACH/DETACH, VACUUM, partial indexes, generated columns, or persistent ANN vector index files.
  • Secondary indexes optimize equality/prefix seeks and numeric ranges; text and BLOB range predicates still scan. There is no R-tree, and an ordinary WHERE GEO_DWITHIN(...)-style predicate is not planner-accelerated; the explicit GEO_SEARCH table function is the indexed alternative for point-column bbox/radius queries at scale.
  • ModeIndex and ModeHybrid still use a full-table legacy codec on cache miss; use SQLite or ModePagedIndex for strict large-tile serving needs.
  • RBAC is coarse and single-table oriented. Encryption does not yet cover WAL-backed modes or metadata files.
  • GIS validity is structural, not full topology validation (no self-intersection detection). GEO_DISSOLVE/GEO_UNION_AGG only handle topologically-clean, vertex-aligned adjacent polygons, not a general polygon-boolean-union; there is no CRS/projection transform support, and ST_TOUCHES/ST_CROSSES/ST_OVERLAPS (which need a full DE-9IM computation) are not implemented.

TinySQL is primarily an educational and embeddable SQL engine. It aims to make the parser, planner, executor, storage backends, and practical extensions easy to inspect, test, and adapt.