{"id":30769370,"url":"https://github.com/richardknop/minisql","last_synced_at":"2026-05-23T03:15:15.768Z","repository":{"id":283907356,"uuid":"864161742","full_name":"RichardKnop/minisql","owner":"RichardKnop","description":"Embedded single file SQL database written in Golang","archived":false,"fork":false,"pushed_at":"2026-05-04T03:19:56.000Z","size":2182,"stargazers_count":37,"open_issues_count":1,"forks_count":2,"subscribers_count":3,"default_branch":"main","last_synced_at":"2026-05-04T03:29:39.388Z","etag":null,"topics":["agentic-ai","agentic-workflow","database","golang","mvcc","occ","single-file","sql","sqlite"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/RichardKnop.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":"AUTHORS","dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2024-09-27T15:54:01.000Z","updated_at":"2026-05-04T01:41:25.000Z","dependencies_parsed_at":"2026-03-26T03:03:48.196Z","dependency_job_id":null,"html_url":"https://github.com/RichardKnop/minisql","commit_stats":null,"previous_names":["richardknop/minisql"],"tags_count":44,"template":false,"template_full_name":null,"purl":"pkg:github/RichardKnop/minisql","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RichardKnop%2Fminisql","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RichardKnop%2Fminisql/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RichardKnop%2Fminisql/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RichardKnop%2Fminisql/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/RichardKnop","download_url":"https://codeload.github.com/RichardKnop/minisql/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/RichardKnop%2Fminisql/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32595216,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-03T22:12:39.696Z","status":"online","status_checked_at":"2026-05-04T02:00:06.625Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agentic-ai","agentic-workflow","database","golang","mvcc","occ","single-file","sql","sqlite"],"created_at":"2025-09-04T22:09:47.165Z","updated_at":"2026-05-17T02:21:09.973Z","avatar_url":"https://github.com/RichardKnop.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# minisql\n\n[![CI Status](https://github.com/RichardKnop/minisql/actions/workflows/go.yml/badge.svg)](https://github.com/RichardKnop/minisql/actions/workflows/go.yml)\n[![Go Report Card](https://goreportcard.com/badge/github.com/RichardKnop/minisql)](https://goreportcard.com/report/github.com/RichardKnop/minisql)\n[![Donate Bitcoin](https://img.shields.io/badge/donate-bitcoin-orange.svg)](https://richardknop.github.io/donate/)\n\n`MiniSQL` is an embedded single file database written in Golang, inspired by `SQLite` but borrows ideas from other databases such as `Postgres` too. It can differentiate itself from `SQLite` in several areas: \n\n1. Pure Go / zero CGO\n2. MVCC snapshot isolation (for reads, OCC for writes)\n3. Parallel scan\n4. JSON + UUID as native types\n5. Built-in full-text search\n6. Built-in JSON inverted index\n\nTo use minisql in your Go code, import the driver:\n\n```go\nimport (\n  _ \"github.com/RichardKnop/minisql\"\n)\n```\n\nAnd create a database instance:\n\n```go\n// Simple path\ndb, err := sql.Open(\"minisql\", \"./my.db\")\n\n// With connection parameters\ndb, err := sql.Open(\"minisql\", \"./my.db?log_level=debug\")\n\n// Multiple parameters\ndb, err := sql.Open(\"minisql\", \"./my.db?log_level=debug\u0026max_cached_pages=500\")\n```\n\n## Connection Pooling\n\n**MiniSQL is an embedded, single-file database (similar to SQLite).** However, it can support multiple connections for reads so it is not necessary to set max connections to 1, it depends on your workloads:\n\n- Write-heavy workloads: SetMaxOpenConns(1) still makes sense — writes serialize internally on dbLock anyway, multiple connections just add OCC conflict noise without throughput gain.                                                                                              \n - Read-heavy or mixed workloads: multiple connections are beneficial — read-only transactions run concurrently via MVCC snapshot isolation without holding the database lock.\n\n## Connection String Parameters\n\nMiniSQL supports optional connection string parameters:\n\n| Parameter | Values | Default | Description |\n|-----------|--------|---------|-------------|\n| `wal_checkpoint_threshold` | non-negative integer | `1000` | Auto-checkpoint after N WAL frames (0 = disabled) |\n| `log_level` | `debug`, `info`, `warn`, `error` | `warn` | Set logging verbosity level |\n| `max_cached_pages` | positive integer | `2000` | Maximum number of pages to keep in memory cache |\n| `slow_query_threshold` | Go duration, e.g. `50ms`, `2s` | `0` | Log queries taking at least this long at WARN level (0 = disabled) |\n| `synchronous` | `off`, `normal`, `full` | `normal` | WAL fsync mode (see [WAL durability](#wal-durability-modes) below) |\n| `parallel_scan` | `on`, `off` | `off` | Enable concurrent leaf-page scanning for full table scans (see [Parallel Full Table Scan](#parallel-full-table-scan) below) |\n\n**Examples:**\n```go\n// Enable debug logging\ndb, err := sql.Open(\"minisql\", \"./my.db?log_level=debug\")\n\n// Set cache size to 500 pages (~2MB memory)\ndb, err := sql.Open(\"minisql\", \"./my.db?max_cached_pages=500\")\n\n// Disable auto-checkpoint (manual checkpoint only)\ndb, err := sql.Open(\"minisql\", \"./my.db?wal_checkpoint_threshold=0\")\n\n// Maximum write durability (fsync after every commit)\ndb, err := sql.Open(\"minisql\", \"./my.db?synchronous=full\")\n\n// Log queries that take at least 50ms\ndb, err := sql.Open(\"minisql\", \"./my.db?slow_query_threshold=50ms\")\n\n// Enable parallel full table scans\ndb, err := sql.Open(\"minisql\", \"./my.db?parallel_scan=on\")\n\n// Combine multiple parameters\ndb, err := sql.Open(\"minisql\", \"/path/to/db.db?log_level=info\u0026max_cached_pages=2000\")\n```\n\n## Write-Ahead Log (WAL)\n\nMiniSQL uses a Write-Ahead Log (`{dbpath}-wal`) for crash recovery and atomic commits. All page modifications are appended to the WAL before the main database file is updated.\n\nCommit protocol:\n\n1. Serialise all modified pages as WAL frames and write them to the WAL file.\n2. Optionally `fsync()` the WAL file (controlled by the `synchronous` setting).\n3. The in-memory WAL index is updated so subsequent reads see the new pages immediately.\n4. The main database file is **not written** during a commit — it is updated only during a checkpoint.\n\nOn startup, if a WAL file exists, MiniSQL replays all valid committed frames into the in-memory WAL index so the data is visible immediately without a checkpoint.\n\nCheckpoint (`PRAGMA wal_checkpoint`):\n\n1. Copies every WAL page into the main database file.\n2. `Sync()`s the database file (skipped in `synchronous=off`).\n3. Truncates the WAL file to its header (32 bytes).\n4. Resets the in-memory WAL index.\n\nAn automatic checkpoint is triggered after `wal_checkpoint_threshold` WAL frames (default 1000). Set `wal_checkpoint_threshold=0` to disable auto-checkpoint and run `PRAGMA wal_checkpoint` manually.\n\n### WAL Durability Modes\n\nThe `synchronous` setting controls when `fsync()` is called, trading durability for write performance. This matches SQLite's `PRAGMA synchronous` for WAL mode.\n\n| Mode | Connection string | PRAGMA | Description |\n|------|------------------|--------|-------------|\n| `normal` | `synchronous=normal` | `PRAGMA synchronous = normal` | **Default.** No fsync per commit. fsync only at checkpoint. Matches SQLite WAL default. |\n| `full` | `synchronous=full` | `PRAGMA synchronous = full` | fsync after every WAL commit. Maximum durability — survives an OS crash between commits. |\n| `off` | `synchronous=off` | `PRAGMA synchronous = off` | No fsyncs at all. Fastest, but uncommitted data may be lost on OS crash or power failure. |\n\nThe default (`normal`) matches SQLite's WAL default behaviour. In practice, data committed under `normal` mode survives application crashes and most OS crashes — the only scenario where data is lost is a power failure or kernel panic occurring in the narrow window after a commit write but before the next checkpoint fsync.\n\nYou can read the current mode at runtime:\n\n```sql\nPRAGMA synchronous;   -- returns 0 (off), 1 (normal), or 2 (full)\n```\n\nAnd change it for the current connection:\n\n```sql\nPRAGMA synchronous = full;\nPRAGMA synchronous = normal;\nPRAGMA synchronous = off;\n```\n\n## Parallel Full Table Scan\n\nWhen a query requires a full table scan (no usable index, or explicit sequential scan), MiniSQL normally reads leaf pages one at a time in a single goroutine. **Parallel scan** splits the leaf-page chain across up to `runtime.NumCPU()` goroutines so that multiple pages are decoded and filtered concurrently.\n\nParallel scan is **off by default** because it adds overhead for small tables and single-CPU environments. It is most beneficial for large tables on multi-core machines running filter-heavy queries that touch many pages.\n\n**Note:** Parallel scan does **not** guarantee row-ID ordering. Queries that rely on insertion order without an explicit `ORDER BY` may observe a different row sequence.\n\nEnable at connection open time via the connection string:\n\n```go\ndb, err := sql.Open(\"minisql\", \"./my.db?parallel_scan=on\")\n```\n\nOr toggle at runtime with PRAGMA (affects all existing tables on the connection immediately):\n\n```sql\nPRAGMA parallel_scan = on;\nPRAGMA parallel_scan;     -- returns 0 (off) or 1 (on)\nPRAGMA parallel_scan = off;\n```\n\n| Mode | Connection string | PRAGMA | Description |\n|------|------------------|--------|-------------|\n| off | _(default)_ | `PRAGMA parallel_scan = off` | Single-goroutine sequential leaf scan. Best for small tables or single-CPU environments. |\n| on | `parallel_scan=on` | `PRAGMA parallel_scan = on` | Leaf pages partitioned across `runtime.NumCPU()` goroutines. Rows delivered in arrival order (not row-ID order). |\n\n## Storage\n\nEach page size is `4096 bytes`. Rows larger than page size are not supported. Therefore, the largest allowed inline row size is `4065 bytes` (with exception of root page 0 which has first 100 bytes reserved for config). Variable text colums can use overflow pages and are not limited by page size.\n\n```\n4096 (page size) \n- 7 (base header size) \n- 8 (internal / leaf node header size) \n- 8 (null bit mask) \n- 8 (internal row ID / key) \n= 4065\n```\n\nAll tables are kept track of via a system table `minisql_schema` which contains table name, `CREATE TABLE` SQL to document table structure and a root page index indicating which page contains root node of the table B+ Tree.\n\nEach row has an internal row ID which is an unsigned 64 bit integer starting at 0. These are used as keys in B+ Tree data structure. \n\nMoreover, each row starts with 64 bit null mask which determines which values are NULL. Because of the NULL bit mask being an unsigned 64 bit integer, there is a limit of `maximum 64 columns per table`.\n\n### Storage Data Structures\n\nMiniSQL currently uses a few related page-backed trees:\n\n- Tables use a B+ tree keyed by MiniSQL's internal row ID. Leaf pages store rows; internal pages store routing keys and child page references.\n- Primary, unique and secondary indexes use the existing B-tree-style index pages. Secondary index keys can point to multiple row IDs.\n- Full-text and JSON inverted indexes use dedicated inverted-index pages. An entry tree maps each generated term, such as a text token or JSON key/value term, to postings. Small posting lists are stored inline in the entry leaf. Larger posting lists are promoted to compressed posting leaf pages, with internal posting-tree routing pages keyed by row-id ranges.\n\nThe inverted index is therefore not just a regular secondary index with larger value lists. It has two levels of structure: term lookup in the entry tree, then posting lookup/iteration in a posting tree. This keeps high-frequency terms from forcing huge values into entry pages and gives the storage layer room for future optimisations such as better posting compression, posting-tree skipping, and eventually pending-list style batched updates.\n\n### Database Header Format\n\nThe first `100` bytes of page `0` are reserved for the MiniSQL database header. This is part of the on-disk file format.\n\nCurrent header fields:\n\n| Offset | Size | Field | Description |\n|---|---:|---|---|\n| `0` | `8` | magic | `minisql\\0` file signature |\n| `8` | `4` | format version | Current value: `1` |\n| `12` | `4` | page size | Current value: `4096` |\n| `16` | `4` | first free page | Head of the free-page linked list |\n| `20` | `4` | free page count | Number of free pages currently tracked |\n| `24` | `76` | reserved | Reserved for future file-format metadata |\n\nNotes:\n\n- MiniSQL now requires the header magic/version/page size to be present when opening a database file.\n- The remaining bytes are reserved so the header can grow without immediately changing the page layout again.\n- The rest of page `0` after the first `100` bytes is used as a normal root B+ tree page.\n\n## Concurrency\n\nMiniSQL implements two complementary concurrency control mechanisms:\n\n### Write Transactions — Optimistic Concurrency Control (OCC)\n\nWrite transactions use `Optimistic Concurrency Control`. The transaction manager follows a simple process:\n\n1. Track read versions — Record the page version at the time each page is first read (captured before the LRU cache read to avoid TOCTOU races with concurrent commits).\n2. Check at commit time — Verify no pages were modified between the first read and the commit.\n3. Abort on conflict — If any tracked page has a newer version at commit time, abort with `ErrTxConflict`.\n\nYou can use `ErrTxConflict` to decide whether to retry or surface the error to the caller.\n\n### Read-Only Transactions — Snapshot Isolation (MVCC)\n\nRead-only transactions use in-memory `MVCC` (`Multi-Version Concurrency Control`) to provide snapshot isolation: a reader sees the database exactly as it was at the moment `BeginReadOnlyTransaction` was called, regardless of writes that commit afterward.\n\nThis is similar to how [SQLite handles isolation](https://sqlite.org/isolation.html). Under the hood:\n\n- A monotonically increasing `commitSeq` counter is incremented on every write commit.\n- Each read-only transaction captures the current `commitSeq` as its `SnapshotSeq` at start time.\n- At write commit time, the pre-modification copy of each modified page is saved in an in-memory version history (`pageVersionHistory`).\n- When a snapshot reader accesses a page whose cached version is newer than its `SnapshotSeq`, it retrieves the appropriate historical version from the version history.\n- Historical versions are garbage-collected once all snapshot readers that needed them have committed.\n\n```\nTime 0: Read TX1 starts — SnapshotSeq = 1\nTime 1: Write TX2 modifies page, commits — commitSeq advances to 2; old page saved in version history\nTime 2: TX1 reads the page → sees the historical version at seq 1, not TX2's change\nTime 3: TX1 commits; version history for seq 1 is GC'd\n```\n\nCheckpoint (WAL truncation) is blocked while any snapshot reader is active, since old page versions are held only in the in-memory version history rather than the WAL.\n\n## System Table\n\nAll tables and indexes are tracked in the system table `minisql_schema`. For empty database, it would contain only its own reference:\n\n```sh\n type   | name               | table_name         | root_page   | sql                                                \n--------+--------------------+--------------------+-------------+----------------------------------------\n 1      | minisql_schema     |                    | 0           | create table \"minisql_schema\" (        \n        |                    |                    |             | \ttype int4 not null,                  \n        |                    |                    |             | \tname varchar(255) not null,          \n        |                    |                    |             | \ttable_name varchar(255),             \n        |                    |                    |             | \troot_page int4,                      \n        |                    |                    |             | \tsql text                             \n        |                    |                    |             | )                                      \n```\n\nLet's say you create a table such as:\n\n```sql\ncreate table \"users\" (\n\tid int8 primary key autoincrement,\n\temail varchar(255) unique,\n\tname text,\n\tage int4,\n\tcreated timestamp default now()\n);\ncreate index \"idx_created\" on \"users\" (\n\tcreated\n);\n```\n\nIt will be added to the system table as well as its primary key and any unique or secondary indexes. Secondary index on `created TIMESTAMP` column created separately will also be added to the system table.\n\nYou can check current objects in the `minisql_schema` system table by a simple `SELECT` query.\n\n```go\n// type schema struct {\n// \tType      int\n// \tName      string\n// \tTableName *string\n// \tRootPage  int\n// \tSql       *string\n// }\n\nrows, err := db.QueryContext(context.Background(), `select * from minisql_schema;`)\nif err != nil {\n\treturn err\n}\ndefer rows.Close()\n\nvar schemas []schema\nfor rows.Next() {\n\tvar aSchema schema\n\tif err := rows.Scan(\u0026aSchema.Type, \u0026aSchema.Name, \u0026aSchema.TableName, \u0026aSchema.RootPage, \u0026aSchema.SQL); err != nil {\n\t\treturn err\n\t}\n\tschemas = append(schemas, aSchema)\n}\nif err := rows.Err(); err != nil {\n\treturn err\n}\n```\n\n```sh\n type   | name               | table_name         | root_page   | sql                                                \n--------+--------------------+--------------------+-------------+----------------------------------------\n 1      | minisql_schema     |                    | 0           | create table \"minisql_schema\" (        \n        |                    |                    |             | \ttype int4 not null,                  \n        |                    |                    |             | \tname varchar(255) not null,          \n        |                    |                    |             | \ttable_name varchar(255),             \n        |                    |                    |             | \troot_page int4,                      \n        |                    |                    |             | \tsql text                             \n        |                    |                    |             | )                                      \n 1      | users              |                    | 1           | create table \"users\" (                 \n        |                    |                    |             | \tid int8 primary key autoincrement,   \n        |                    |                    |             | \temail varchar(255) unique,           \n        |                    |                    |             | \tname text,                           \n        |                    |                    |             | \tage int4,                            \n        |                    |                    |             | \tcreated timestamp default now()      \n        |                    |                    |             | );                                     \n 2      | pkey__users        | users              | 2           | NULL                                   \n 3      | key__users_email   | users              | 3           | NULL                                   \n 4      | idx_users          | users              | 4           | create index \"idx_created\" on \"users\" (             \n        |                    |                    |             | \tcreated,                             \n        |                    |                    |             | );                                     \n```\n\n## Data Types And Storage\n\n| Data type    | Description |\n|--------------|-------------|\n| `BOOLEAN`    | 1-byte boolean value (true/false). |\n| `INT4`       | 4-byte signed integer (-2,147,483,648 to 2,147,483,647). |\n| `INT8`       | 8-byte signed integer (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807). |\n| `REAL`       | 4-byte single-precision floating-point number. |\n| `DOUBLE`     | 8-byte double-precision floating-point number. |\n| `TEXT`       | Variable-length text. If length is \u003c= 255, the text is stored inline, otherwise text is stored in overflow pages (with UTF-8 encoding). |\n| `VARCHAR(n)` | Storage works the same way as `TEXT` but allows limiting length of inserted/updated text to max value. |\n| `TIMESTAMP`  | 8-byte signed integer representing number of microseconds from `2000-01-01 00:00:00 UTC` (`Postgres epoch`). Supported range is from `4713 BC` to `294276 AD` inclusive. |\n| `JSON`       | Variable-length JSON document. Stored as compact text (whitespace stripped on write). Validated on insert/update — invalid JSON is rejected. Supports path extraction via `-\u003e` / `-\u003e\u003e` operators and `JSON_*` functions. See [JSON Type](#json-type). |\n| `UUID`       | Fixed 16-byte binary UUID stored inline in B-tree pages. Accepts the standard hyphenated form `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. Upper-case input is normalised to lowercase on write. Invalid values are rejected at insert/update time. Returned as a lowercase hyphenated string. See [UUID Type](#uuid-type). |\n\n## TIMESTAMP Spec\n\nMiniSQL `TIMESTAMP` is a timestamp-without-time-zone type. It stores a calendar date and wall-clock time with microsecond precision, but it does not store or interpret any timezone offset.\n\n- Storage format: signed 64-bit integer counting microseconds since `2000-01-01 00:00:00 UTC` (the PostgreSQL epoch).\n- Precision: microseconds. Fractional seconds from 1 to 6 digits are accepted and are scaled to microseconds.\n- Calendar model: proleptic Gregorian calendar for the full supported range.\n- Supported range: `4713-01-01 00:00:00 BC` through `294276-12-31 23:59:59.999999`.\n- BC handling: input and output use PostgreSQL-style ` BC` suffix. Internally, astronomical year numbering is used (`1 BC` = year `0`, `2 BC` = year `-1`).\n- `NOW()`: evaluated in UTC and stored as a timezone-naive timestamp value.\n\nAccepted literal forms:\n\n- `YYYY-MM-DD HH:MM:SS`\n- `YYYY-MM-DD HH:MM:SS.f`\n- `YYYY-MM-DD HH:MM:SS.ff`\n- `YYYY-MM-DD HH:MM:SS.ffffff`\n- Any of the above with trailing ` BC`\n\nExamples:\n\n```sql\n'2024-03-15 10:30:45'\n'2024-03-15 10:30:45.1'\n'2024-03-15 10:30:45.123456'\n'0001-12-31 23:59:59.999999 BC'\n```\n\nImportant behavior and current non-goals:\n\n- Timezone-qualified values are rejected. Examples: `Z`, `UTC`, `GMT`, `+01:00`, `-05:30`.\n- Leap seconds are not supported. Seconds must be in the range `00` to `59`.\n- Year `0000` is rejected in input. Use `0001 ... BC` for 1 BC.\n- MiniSQL does not currently support `TIMESTAMP WITH TIME ZONE`.\n- String formatting normalizes fractional precision to either no fractional part or exactly 6 fractional digits.\n\n## JSON Type\n\nThe `json` column type stores any valid JSON document — object, array, string, number, boolean, or `null`. Values are validated and compacted on write (whitespace stripped, key insertion order preserved). Invalid JSON is rejected at insert/update time.\n\n```sql\nCREATE TABLE events (\n    id      int8 primary key autoincrement,\n    name    varchar(100) not null,\n    payload json\n);\n\nINSERT INTO events (name, payload) VALUES ('login', '{\"user\":\"alice\",\"uid\":42}');\nINSERT INTO events (name, payload) VALUES ('tags',  '[\"go\",\"sql\",\"json\"]');\n```\n\n### Path Operators\n\n| Operator | Returns | Description |\n|----------|---------|-------------|\n| `col -\u003e 'key'` | JSON fragment | Extracts a field and returns it as a JSON-encoded string (the value is still quoted/wrapped). |\n| `col -\u003e\u003e 'key'` | SQL scalar | Extracts a field and returns it as a plain SQL value (string unquoted, number as integer or float). |\n| `col -\u003e 0` | JSON fragment | Indexes into a JSON array by position (0-based). |\n| `col -\u003e\u003e 0` | SQL scalar | Same as above but as a scalar. |\n\n```sql\n-- Returns the JSON fragment: \"alice\"  (quoted)\nSELECT payload -\u003e 'user' FROM events WHERE name = 'login';\n\n-- Returns the scalar string: alice  (unquoted)\nSELECT payload -\u003e\u003e 'user' FROM events WHERE name = 'login';\n\n-- Returns integer: 42\nSELECT payload -\u003e\u003e 'uid' FROM events WHERE name = 'login';\n\n-- Array index: returns \"go\"\nSELECT payload -\u003e\u003e 0 FROM events WHERE name = 'tags';\n\n-- Filter by JSON field value\nSELECT name FROM events WHERE payload -\u003e\u003e 'user' = 'alice';\nSELECT name FROM events WHERE payload -\u003e\u003e 'uid' = 42;\n```\n\n### JSON Functions\n\n| Function | Returns | Description |\n|----------|---------|-------------|\n| `JSON_EXTRACT(doc, path)` | scalar | Extracts the value at a JSONPath expression as a SQL scalar. Equivalent to `doc -\u003e\u003e path`. Path syntax: `$` (root), `$.key`, `$['key']`, `$[n]`, chainable. |\n| `JSON_VALID(val)` | `1` or `0` | Returns `1` if `val` is syntactically valid JSON, `0` otherwise. Useful for validating text columns. |\n| `JSON_TYPE(doc[, path])` | text | Returns the JSON type name of the document root, or of the value at `path`. Values: `object`, `array`, `text`, `integer`, `real`, `true`, `false`, `null`. |\n| `JSON_ARRAY_LENGTH(doc)` | integer | Returns the number of elements in a JSON array. Returns `NULL` if the document is not an array. |\n| `JSON_CONTAINS(doc, query)` | boolean | Returns `true` when `doc` contains `query` as a JSON subset. Object keys are matched recursively, arrays use element containment, and scalar values compare by JSON type/value. |\n\n```sql\n-- Extract with JSONPath\nSELECT JSON_EXTRACT(payload, '$.user') FROM events WHERE name = 'login';\n-- Returns: alice\n\n-- Type inspection\nSELECT JSON_TYPE(payload) FROM events WHERE name = 'login';  -- object\nSELECT JSON_TYPE(payload) FROM events WHERE name = 'tags';   -- array\n\nSELECT JSON_TYPE(payload, '$.uid') FROM events WHERE name = 'login'; -- integer\n\n-- Array length\nSELECT JSON_ARRAY_LENGTH(payload) FROM events WHERE name = 'tags'; -- 3\n\n-- Validate arbitrary text\nSELECT JSON_VALID('{\"x\":1}');  -- 1\nSELECT JSON_VALID('bad json'); -- 0\n\n-- JSON containment\nSELECT name FROM events WHERE JSON_CONTAINS(payload, '{\"user\":\"alice\"}');\n```\n\n### JSON Inverted Indexes\n\nMiniSQL supports a v1 JSON inverted index for accelerating literal `JSON_CONTAINS` predicates on one `json` column:\n\n```sql\nCREATE INVERTED INDEX idx_events_payload\nON events (payload);\n\nSELECT name\nFROM events\nWHERE JSON_CONTAINS(payload, '{\"type\":\"click\",\"tags\":[\"web\"]}');\n```\n\nThe v1 index stores generated JSON terms in MiniSQL's dedicated inverted-index storage. Terms include key existence (`k:user.id`) and scalar key/value entries (`kv:type:s:\"click\"`, `kv:tags[]:s:\"web\"`), with each term pointing at row-id postings. Small posting lists are stored inline; larger posting lists are promoted to compressed posting pages with internal posting-tree routing pages. Generated terms longer than the current 255-byte index-key limit are skipped; indexed queries are always rechecked against the full row, and queries that cannot produce any indexable terms fall back to sequential evaluation. It does not support path-specific operators or dynamic query expressions yet.\n\n### CAST AS JSON\n\n`CAST(expr AS JSON)` validates and compacts an expression as JSON. Useful for casting a text column or literal to a JSON value.\n\n```sql\nSELECT CAST('{\"a\": 1}' AS JSON);  -- Returns: {\"a\":1}  (compacted)\n```\n\n### Null and Missing Keys\n\n- Inserting `NULL` into a `json` column is allowed (the column stores SQL `NULL`, not the JSON string `\"null\"`).\n- Extracting a key that does not exist returns SQL `NULL`.\n- Applying `-\u003e` or `-\u003e\u003e` to a SQL `NULL` returns `NULL`.\n\n## UUID Type\n\nThe `uuid` column type stores a standard UUID in fixed 16-byte binary form, inline in the B-tree page. No overflow pages are used.\n\n- Input is accepted in the standard hyphenated form: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.\n- Upper-case hex digits are normalised to lower-case on write.\n- Invalid UUID strings are rejected at insert/update time with an error.\n- Values are returned as lowercase hyphenated strings via the `database/sql` driver.\n- UUID columns can be used as primary keys, unique indexes, and secondary indexes.\n\n```sql\nCREATE TABLE widgets (\n    id    uuid primary key,\n    name  varchar(100) not null,\n    owner uuid\n);\n```\n\n### Inserting UUIDs\n\nPass UUID values as strings via prepared statements:\n\n```go\nconst uuid1 = \"550e8400-e29b-41d4-a716-446655440000\"\n\n_, err := db.Exec(\n    `INSERT INTO widgets (id, name) VALUES (?, ?)`,\n    uuid1, \"Widget Alpha\",\n)\n```\n\nUpper-case input is accepted and silently normalised:\n\n```go\n_, err := db.Exec(\n    `INSERT INTO widgets (id, name) VALUES (?, ?)`,\n    \"6BA7B810-9DAD-11D1-80B4-00C04FD430C8\", \"Widget Beta\",\n)\n// Stored and returned as: 6ba7b810-9dad-11d1-80b4-00c04fd430c8\n```\n\n### Querying UUIDs\n\n```go\nrows, err := db.Query(`SELECT id, name FROM widgets WHERE id = ?`, uuid1)\n// ...\nvar gotID, gotName string\nrows.Scan(\u0026gotID, \u0026gotName)\n// gotID == \"550e8400-e29b-41d4-a716-446655440000\"\n```\n\n### CAST with UUID\n\n```sql\n-- Parse a text literal as UUID (validates and stores in binary form)\nSELECT CAST('550e8400-e29b-41d4-a716-446655440000' AS UUID);\n\n-- Format a UUID column back to text\nSELECT CAST(id AS TEXT) FROM widgets WHERE name = 'Widget Alpha';\n```\n\n### Nullable UUID columns\n\n```go\nvar owner *string\nrows.Scan(\u0026owner)\n// owner == nil when the column value is NULL\n```\n\n## SQL Features\n\n| Feature | Notes |\n|---------|-------|\n| `CREATE TABLE`, `CREATE TABLE IF NOT EXISTS` | |\n| `PRIMARY KEY` | Single column only; no composite primary keys |\n| `AUTOINCREMENT` | Primary key must be of type `INT8` |\n| `UNIQUE` | Can be specified when creating a table |\n| `CHECK` | Constraints to test values whenever they are inserted or updated in a column |\n| `FOREIGN KEY` | Single-column FK constraints with `RESTRICT` / `NO ACTION`; declared inside `CREATE TABLE`. `PRAGMA foreign_keys = on\\|off` (default on). See [Foreign Keys](#foreign-keys). |\n| Composite primary key or unique constraint | As part of `CREATE TABLE` |\n| `NULL` and `NOT NULL` | Via null bit mask included in each row/cell |\n| `DEFAULT` | Supported for all columns, including `NOW()` for `TIMESTAMP` |\n| `DROP TABLE` | |\n| `CREATE INDEX`, `DROP INDEX` | Secondary non-unique indexes; primary and unique indexes are declared as part of `CREATE TABLE`. Supports composite (multi-column), partial (`WHERE` clause), and expression indexes. See [Indexes](#indexes). |\n| `INSERT` | Single row or multiple rows via a tuple of values separated by commas |\n| `ON CONFLICT` | Both `DO NOTHING` and `DO UPDATE` supported (with `EXCLUDED` pseudo table syntax for updating) |\n| `SELECT` | All fields with `*`, specific fields, or row count with `COUNT(*)`, derived tables support |\n| `SELECT DISTINCT` | |\n| `WITH` | Basic support for `CTEs`, SELECT only currently |\n| `EXPLAIN`, `EXPLAIN ANALYZE` | Query plan inspection for `SELECT` statements. `EXPLAIN ANALYZE` also executes the query and returns actual row counts and timing |\n| `JOIN` | `INNER`, `LEFT` and `RIGHT` joins supported |\n| `UPDATE` | Standard `UPDATE t SET col = val WHERE …` |\n| `UPDATE … FROM` | PostgreSQL-style multi-table update: `UPDATE t1 [AS alias] SET col = t2.val FROM t2 [AS alias] WHERE join_condition`. The `FROM` source can be a table name or a subquery (`FROM (SELECT …) AS alias`). Each target row may match at most one FROM row; zero matches leaves the row unchanged. SET expressions can reference columns from both tables (e.g. `SET salary = dept.budget / 10`). |\n| `DELETE` | |\n| `RETURNING` | Can be used to return columns from `INSERT` or `DELETE` queries, common use case is to return auto incremented primary key |\n| `WHERE` | Operators: `=`, `!=`, `\u003e`, `\u003e=`, `\u003c`, `\u003c=`, `IN`, `NOT IN`, `LIKE`, `NOT LIKE`, `BETWEEN`, support for SELECT only non-correlated scalar subqueries |\n| `LIKE`, `NOT LIKE` | `%` matches any sequence of zero or more characters; `_` matches any single character |\n| `LIMIT` and `OFFSET` | Basic pagination |\n| `ORDER BY` | Single column only |\n| `GROUP BY` and `HAVING` | Aggregate functions: `COUNT`, `MAX`, `MIN`, `SUM`, `AVG` |\n| Arithmetic expressions | `+`, `-`, `*`, `/` in `SELECT` and `UPDATE SET` (e.g. `price * 1.1`, `count + 1`) |\n| Scalar functions | `COALESCE(a, b, ...)` returns first non-NULL argument; `NULLIF(a, b)` returns NULL when `a = b`, else `a`. Both usable in `SELECT`, `UPDATE SET`, and nested inside arithmetic |\n| String functions | `UPPER(s)`, `LOWER(s)` — case conversion; `TRIM(s[, chars])`, `LTRIM(s[, chars])`, `RTRIM(s[, chars])` — strip whitespace or custom characters; `LENGTH(s)` — byte length; `SUBSTR(s, start[, len])` — 1-based substring; `REPLACE(s, from, to)` — replace all occurrences; `CONCAT(a, b, ...)` — concatenate (NULLs skipped). All usable in `SELECT`, `UPDATE SET`, and composable with each other and arithmetic |\n| Numeric functions | `ABS(n)` — absolute value (preserves input type); `FLOOR(n)`, `CEIL(n)` — floor/ceiling; `ROUND(n[, d])` — round to `d` decimal places (default 0); `MOD(a, b)` — modulo (integer or float). All usable in `SELECT`, `UPDATE SET`, composable with each other and arithmetic |\n| Date/time functions | `NOW()` — current UTC timestamp; `DATE_TRUNC('unit', ts)` — truncate to `year`/`month`/`week`/`day`/`hour`/`minute`/`second`; `EXTRACT('field', ts)` / `DATE_PART('field', ts)` — extract numeric field (`year`, `month`, `day`, `hour`, `minute`, `second`, `dow`); `TO_TIMESTAMP('str')` — parse timestamp string into a TIMESTAMP value. All usable in `SELECT`, `UPDATE SET`, composable with other expressions |\n| Full-text search functions | `MATCH(doc, query)` and `TS_RANK(doc, query)` provide initial full-text semantics. `CREATE FULLTEXT INDEX` can accelerate literal `MATCH` predicates on one `TEXT`/`VARCHAR` column. |\n| `CASE WHEN` | Searched form: `CASE WHEN cond THEN result … ELSE default END`; simple form: `CASE expr WHEN val THEN result … ELSE default END`. Multiple WHEN clauses, optional ELSE (omitting returns NULL). Usable in `SELECT` (including nested in arithmetic), `UPDATE SET`, supports `IS NULL` / `IS NOT NULL` / all comparison operators in conditions |\n| `UNION` / `UNION ALL` | Combine results of two or more `SELECT` statements. `UNION ALL` concatenates all rows (duplicates kept); `UNION` deduplicates the combined result. Chains of three or more branches supported (e.g. `SELECT … UNION ALL SELECT … UNION SELECT …`). Each branch may have its own `WHERE` clause. |\n| `CAST(expr AS type)` | Standard SQL type coercion. Supported target types: `BOOLEAN`, `INT4`, `INT8`, `REAL`, `DOUBLE`, `TEXT`, `VARCHAR(n)`, `TIMESTAMP`, `JSON`, `UUID`. Follows SQLite semantics: float→int truncates toward zero; text→int/float parses leading digits (non-numeric input → 0). `CAST(x AS JSON)` validates and compacts the value. `CAST(x AS UUID)` parses a UUID string and stores it in binary form. `CAST(uuid_col AS TEXT)` formats the 16-byte value back to a hyphenated lowercase string. NULL propagates. Usable anywhere an expression is valid (e.g. `SELECT CAST(price AS INT8)`, `SELECT CAST(n AS TEXT) AS label`, `SELECT CAST(id AS TEXT) FROM widgets`). |\n| JSON operators | `col -\u003e 'key'` — extract a JSON field and return it as a JSON fragment (quoted string, array, object). `col -\u003e\u003e 'key'` — extract a JSON field and return it as a SQL scalar (unquoted string, integer, or float). Integer keys index into arrays (e.g. `col -\u003e 0`). Both operators work in `SELECT` and `WHERE`. See [JSON Type](#json-type). |\n| JSON functions | `JSON_EXTRACT(doc, path)` — extract value at JSON path as a scalar (equivalent to `-\u003e\u003e`). `JSON_VALID(val)` — returns `1` if the value is valid JSON, `0` otherwise. `JSON_TYPE(doc[, path])` — returns the JSON type name. `JSON_ARRAY_LENGTH(doc)` — returns the number of elements in a JSON array. `JSON_CONTAINS(doc, query)` — tests JSON subset containment and can use `CREATE INVERTED INDEX` for literal predicates. See [JSON Type](#json-type). |\n| `INTERVAL` arithmetic | PostgreSQL-style interval expressions. Supported units: `year`, `month`, `week`, `day`, `hour`, `minute`, `second`, `microsecond` (singular or plural). Supports compound intervals (`'1 year 3 months'`) and negative values (`'-2 days'`). Operations: `timestamp + interval → timestamp`, `timestamp - interval → timestamp`, `interval + interval → interval`, `interval - interval → interval`, `timestamp - timestamp → interval`. Month arithmetic is calendar-aware — adding 1 month to Jan 31 yields the last day of February. Usable in `SELECT` and `UPDATE SET`, composable with `AS` aliases. Examples: `SELECT created_at + INTERVAL '7 days' AS expires_at`, `SELECT ts - INTERVAL '1 year 6 months'`. |\n| `VACUUM` | Rebuilds the database file, repacking it into a minimal amount of disk space (similar to SQLite) |\n| `PRAGMA quick_check` | A cheap structural health check of the open database. |\n| `PRAGMA integrity_check` | A deeper structural and logical check: page graph, overflow chains, and table/index consistency. Prefer offline use for large databases |\n| `PRAGMA wal_checkpoint` | Manually flush WAL frames to the main database file and truncate the WAL. |\n| `PRAGMA synchronous` | Read current WAL fsync mode (returns 0/1/2). |\n| `PRAGMA synchronous = off\\|normal\\|full` | Set WAL fsync mode for the current connection. See [WAL Durability Modes](#wal-durability-modes). |\n| `PRAGMA parallel_scan` | Read current parallel scan state (returns 0 = off, 1 = on). |\n| `PRAGMA parallel_scan = on\\|off` | Enable or disable concurrent leaf-page scanning for full table scans. See [Parallel Full Table Scan](#parallel-full-table-scan). |\n\n\n### Scalar Functions Reference\n\n#### String Functions\n\n| Function | Description |\n|----------|-------------|\n| `UPPER(s)` | Convert string to upper case. |\n| `LOWER(s)` | Convert string to lower case. |\n| `TRIM(s[, chars])` | Strip leading and trailing whitespace, or the given characters. |\n| `LTRIM(s[, chars])` | Strip leading whitespace or characters. |\n| `RTRIM(s[, chars])` | Strip trailing whitespace or characters. |\n| `LENGTH(s)` | Byte length of the string. |\n| `SUBSTR(s, start[, len])` | 1-based substring extraction. |\n| `REPLACE(s, from, to)` | Replace all occurrences of `from` with `to`. |\n| `CONCAT(a, b, ...)` | Concatenate arguments, skipping NULLs. |\n\n#### Numeric Functions\n\n| Function | Description |\n|----------|-------------|\n| `ABS(n)` | Absolute value; preserves input type (`INT8` or `DOUBLE`). |\n| `FLOOR(n)` | Largest integer not greater than `n`. |\n| `CEIL(n)` | Smallest integer not less than `n`. |\n| `ROUND(n[, d])` | Round to `d` decimal places (default 0). |\n| `MOD(a, b)` | Modulo; integer or float depending on inputs. |\n\n#### Date / Time Functions\n\n| Function | Description |\n|----------|-------------|\n| `NOW()` | Current UTC timestamp. |\n| `DATE_TRUNC('unit', ts)` | Truncate timestamp to `year`, `month`, `week`, `day`, `hour`, `minute`, or `second`. |\n| `EXTRACT('field', ts)` | Extract numeric field from timestamp: `year`, `month`, `day`, `hour`, `minute`, `second`, `dow`. |\n| `DATE_PART('field', ts)` | Alias for `EXTRACT`. |\n| `TO_TIMESTAMP('str')` | Parse a timestamp string into a `TIMESTAMP` value. |\n\n#### Conditional Functions\n\n| Function | Description |\n|----------|-------------|\n| `COALESCE(a, b, ...)` | Return the first non-NULL argument. |\n| `NULLIF(a, b)` | Return `NULL` when `a = b`, otherwise return `a`. |\n\n#### JSON Functions\n\n| Function | Description |\n|----------|-------------|\n| `JSON_EXTRACT(doc, path)` | Extract value at JSONPath as a SQL scalar. |\n| `JSON_VALID(val)` | `1` if `val` is valid JSON, `0` otherwise. |\n| `JSON_TYPE(doc[, path])` | JSON type name of the value (`object`, `array`, `text`, `integer`, `real`, `true`, `false`, `null`). |\n| `JSON_ARRAY_LENGTH(doc)` | Number of elements in a JSON array. |\n| `JSON_CONTAINS(doc, query)` | Boolean JSON subset containment, indexable with `CREATE INVERTED INDEX` when the query JSON is a literal. |\n\n#### Full-Text Search Functions\n\nMiniSQL supports initial full-text search semantics with an optional v1 full-text index. Without an index, `MATCH` scans candidate rows, tokenizes the document and query in memory, and evaluates the match during the normal `WHERE` filter.\n\n```sql\nCREATE FULLTEXT INDEX idx_articles_body\nON articles (body)\nWITH (tokenizer = 'simple');\n```\n\nThe v1 index uses MiniSQL's dedicated inverted-index storage: an entry tree maps each unique token to ordered positional postings `(row ID, token position)`. Small posting lists are stored inline in the entry leaf; larger posting lists are promoted to compressed posting pages with internal posting-tree routing pages. Literal `MATCH(body, 'mini database')` predicates can use the index by intersecting posting rows for all query tokens; quoted phrases such as `MATCH(body, '\"database pages\"')` additionally require adjacent token positions. Dynamic query expressions and queries containing tokens longer than the current 255-byte index-key limit fall back to the sequential semantics.\n\n| Function | Description |\n|----------|-------------|\n| `MATCH(doc, query)` | Returns `true` when every non-stop-word query token appears in `doc`. Double-quoted phrases require adjacent indexed token positions, e.g. `WHERE MATCH(body, '\"mini database\"')`. |\n| `TS_RANK(doc, query)` | Returns a relevance score that combines saturated term frequency, query coverage, mild document-length normalization, exact phrase boosts, and token-proximity boosts. |\n\nTokenizer v1 lowercases text, splits on non-letter/non-digit boundaries, removes a small built-in English stop-word list, and does not perform stemming. For example, `database` and `databases` are different tokens.\n\n```sql\nSELECT id, TS_RANK(body, 'mini database') AS score\nFROM articles\nWHERE MATCH(body, 'mini database')\nORDER BY score DESC;\n\nSELECT id\nFROM articles\nWHERE MATCH(body, 'mini \"database pages\"');\n```\n\n### Operators Reference\n\n#### Comparison Operators\n\n| Operator | Description |\n|----------|-------------|\n| `=` | Equal. |\n| `!=` | Not equal. |\n| `\u003e` | Greater than. |\n| `\u003e=` | Greater than or equal. |\n| `\u003c` | Less than. |\n| `\u003c=` | Less than or equal. |\n| `IS NULL` | Value is NULL. |\n| `IS NOT NULL` | Value is not NULL. |\n| `IN (...)` | Value is in a list or subquery result. |\n| `NOT IN (...)` | Value is not in a list or subquery result. |\n| `BETWEEN a AND b` | Value is between `a` and `b` inclusive. |\n| `NOT BETWEEN a AND b` | Value is outside `a` and `b`. |\n| `LIKE pattern` | String matches pattern (`%` = any sequence, `_` = single char, case-sensitive). |\n| `NOT LIKE pattern` | String does not match pattern. |\n\n#### Arithmetic Operators\n\n| Operator | Description |\n|----------|-------------|\n| `+` | Addition (numeric or `timestamp + interval`). |\n| `-` | Subtraction (numeric, `timestamp - interval`, or `timestamp - timestamp → interval`). |\n| `*` | Multiplication. |\n| `/` | Division (always returns `DOUBLE` when either side is fractional). |\n\n#### JSON Path Operators\n\n| Operator | Returns | Description |\n|----------|---------|-------------|\n| `col -\u003e key` | JSON fragment | Extract field or array element; result is JSON-encoded. |\n| `col -\u003e\u003e key` | SQL scalar | Extract field or array element; result is a plain SQL value. |\n\nPrepared statements are supported using `?` as a placeholder. For example:\n\n```sql\ninsert into users(\"name\", \"email\") values(?, ?), (?, ?);\n```\n\n## DDL SQL Commands\n\n### CREATE TABLE\n\nLet's start by creating your first table:\n\n```go\n_, err := db.Exec(`create table \"users\" (\n\tid int8 primary key autoincrement,\n\temail varchar(255) unique,\n\tname text,\n\tage int4,\n\tcreated timestamp default now()\n);`)\n```\n\n### DROP TABLE\n\n```go\n_, err := db.Exec(`drop table \"users\";`)\n```\n\n### Indexes\n\nMiniSQL supports several index types, each suited to a different access pattern. The query planner picks the best available index automatically using cost estimation based on statistics collected by `ANALYZE`.\n\n#### Index Types at a Glance\n\n| Type | Where declared | Example |\n|------|---------------|---------|\n| Primary key | `CREATE TABLE` column definition | `id INT8 PRIMARY KEY AUTOINCREMENT` |\n| Unique (single column) | `CREATE TABLE` column definition | `email VARCHAR(255) UNIQUE` |\n| Unique (composite) | `CREATE TABLE` table constraint | `UNIQUE (first_name, last_name)` |\n| Secondary | `CREATE INDEX` | `CREATE INDEX idx ON t (col)` |\n| Composite | `CREATE INDEX` | `CREATE INDEX idx ON t (col1, col2)` |\n| Partial | `CREATE INDEX … WHERE` | `CREATE INDEX idx ON t (col) WHERE active = true` |\n| Expression | `CREATE INDEX` with expression | `CREATE INDEX idx ON t (LOWER(col))` |\n| Full-text | `CREATE FULLTEXT INDEX` | `CREATE FULLTEXT INDEX idx ON articles (body) WITH (tokenizer = 'simple')` |\n| JSON inverted | `CREATE INVERTED INDEX` | `CREATE INVERTED INDEX idx ON events (payload)` |\n\n#### Primary Key Index\n\nDeclared inline as part of `CREATE TABLE`. Only a single-column primary key is supported. Using `AUTOINCREMENT` requires the type to be `INT8`.\n\n```sql\nCREATE TABLE users (\n    id    INT8 PRIMARY KEY AUTOINCREMENT,\n    email VARCHAR(255)\n);\n```\n\nThe primary key index is always a unique B+ tree keyed by the row ID.\n\n#### Unique Index\n\nA unique constraint creates a B+ tree index that rejects duplicate values. It can be declared inline on a single column or as a table-level constraint for multi-column uniqueness:\n\n```sql\n-- Inline single-column unique\nCREATE TABLE users (\n    id    INT8 PRIMARY KEY AUTOINCREMENT,\n    email VARCHAR(255) UNIQUE\n);\n\n-- Table-level composite unique constraint\nCREATE TABLE memberships (\n    user_id INT8  NOT NULL,\n    org_id  INT8  NOT NULL,\n    UNIQUE (user_id, org_id)\n);\n```\n\nAttempting to insert or update a row that would violate a unique constraint returns `ErrDuplicateKey`.\n\n#### Secondary Index (Non-Unique)\n\nA plain secondary index speeds up equality and range lookups on a column without enforcing uniqueness.\n\n```sql\nCREATE INDEX idx_users_created ON users (created);\nDROP INDEX idx_users_created;\n```\n\n```go\n_, err := db.Exec(`CREATE INDEX \"idx_users_created\" ON \"users\" (created);`)\n```\n\nUse `ANALYZE` after bulk inserts to update the row-count and cardinality statistics that the planner relies on when choosing between a sequential scan and an index scan.\n\n#### Composite Index\n\nA composite index covers multiple columns. The planner can use it for:\n\n- Equality or range filters on any **prefix** of the index columns.\n- `ORDER BY` — when the query orders by exactly the same columns in the same sequence and direction as the index, the planner uses the index to read rows in order and skips the in-memory sort.\n\n```sql\n-- Index supporting WHERE last_name = ? AND first_name = ?\n-- and ORDER BY last_name, first_name\nCREATE INDEX idx_users_name ON users (last_name, first_name);\n```\n\nMixed `ASC`/`DESC` on different columns still falls back to an in-memory sort. All columns must share the same direction for the ORDER BY optimisation to apply.\n\n#### Partial Index\n\nA partial index only stores entries for rows that satisfy a `WHERE` predicate. This makes the index smaller and faster when the interesting subset of rows is much smaller than the full table.\n\n```sql\n-- Only index active users — WHERE queries that include active = true can use it\nCREATE INDEX idx_active_users ON users (email) WHERE active = true;\n\n-- Only index high-value orders\nCREATE INDEX idx_large_orders ON orders (amount DESC) WHERE amount \u003e 1000;\n\n-- Compound predicate\nCREATE INDEX idx_pending_recent ON orders (created DESC)\n    WHERE status = 'pending' AND amount \u003e 0;\n```\n\nThe planner uses a partial index when every term in the index's `WHERE` clause also appears verbatim in the query's `WHERE` clause. This check is conservative (syntactic containment), so complex rewrites or equivalent but differently structured conditions will not trigger the optimisation; in those cases the planner falls back to a sequential scan or a full secondary index.\n\n```sql\n-- Uses idx_active_users ✓\nSELECT email FROM users WHERE active = true AND email LIKE 'a%';\n\n-- Falls back to sequential scan — predicate is not a superset of the index predicate\nSELECT email FROM users WHERE email LIKE 'a%';\n```\n\nRows that do not satisfy the partial index predicate are never stored in the index. `INSERT`, `UPDATE`, and `DELETE` automatically maintain the index for qualifying rows only.\n\n#### Expression Index\n\nAn expression index keys the B+ tree on the *result* of evaluating a SQL expression rather than a raw column value. The most common use case is case-insensitive search:\n\n```sql\n-- Create the index on the lower-cased name\nCREATE INDEX idx_users_lower_name ON users (LOWER(name));\n\n-- The planner automatically uses the index for this query\nSELECT * FROM users WHERE LOWER(name) = 'alice';\n```\n\nThe planner uses an expression index when the expression in the `WHERE` clause is structurally identical to the indexed expression — the function name, arguments, and any operators must match exactly.\n\n**Supported expression forms:**\n\n| Expression | Example | Key type |\n|-----------|---------|----------|\n| String functions | `LOWER(col)`, `UPPER(col)`, `TRIM(col)`, `SUBSTR(col, 1, 3)`, `REPLACE(col, 'a', 'b')`, `CONCAT(a, b)` | `VARCHAR` |\n| Numeric functions | `ABS(col)`, `FLOOR(col)`, `CEIL(col)`, `ROUND(col, 2)`, `MOD(col, 10)`, `LENGTH(col)` | `INT8` / `DOUBLE` |\n| Date/time functions | `DATE_TRUNC('month', ts)`, `TO_TIMESTAMP(col)` | `TIMESTAMP` |\n| Date extraction | `EXTRACT(year FROM ts)`, `DATE_PART('month', ts)` | `INT8` |\n| Arithmetic | `price * quantity`, `score + bonus`, `cost / 100` | `INT8` / `DOUBLE` |\n| JSON path | `payload -\u003e\u003e 'status'`, `data -\u003e 'meta' -\u003e\u003e 'id'` | `VARCHAR` |\n| Type cast | `CAST(col AS INT8)`, `CAST(col AS TEXT)` | target type |\n| Chained functions | `LOWER(TRIM(col))`, `ABS(price * discount)` | inferred |\n\n```sql\n-- Arithmetic expression index (e.g. for computed total)\nCREATE INDEX idx_line_total ON order_lines (price * quantity);\nSELECT * FROM order_lines WHERE price * quantity \u003e 500;\n\n-- JSON field expression index\nCREATE INDEX idx_event_type ON events (payload -\u003e\u003e 'type');\nSELECT * FROM events WHERE payload -\u003e\u003e 'type' = 'login';\n\n-- Date truncation index (monthly bucketing)\nCREATE INDEX idx_orders_month ON orders (DATE_TRUNC('month', created));\nSELECT * FROM orders WHERE DATE_TRUNC('month', created) = '2024-01-01 00:00:00';\n\n-- Year extraction\nCREATE INDEX idx_orders_year ON orders (EXTRACT(year FROM created));\nSELECT * FROM orders WHERE EXTRACT(year FROM created) = 2024;\n\n-- Chained functions\nCREATE INDEX idx_norm_email ON users (LOWER(TRIM(email)));\nSELECT * FROM users WHERE LOWER(TRIM(email)) = 'alice@example.com';\n```\n\nExpression indexes only store entries for rows where the expression evaluates to a non-NULL result. `INSERT`, `UPDATE`, and `DELETE` evaluate the expression automatically to keep the index up to date.\n\n`NOW()` and other non-deterministic functions are rejected at `CREATE INDEX` time.\n\n#### Covering Index (Index-Only Scan)\n\nWhen all columns referenced by a query are present in the index, MiniSQL performs an **index-only scan** — it reads the result entirely from the index pages without touching the main table. This avoids the extra I/O of looking up each row by its row ID.\n\n```sql\n-- Index covers both the filter column and the selected column\nCREATE INDEX idx_users_email_name ON users (email, name);\n\n-- Index-only scan: no table pages read\nSELECT name FROM users WHERE email = 'alice@example.com';\n```\n\nThe planner picks index-only scans automatically when the covering condition is satisfied.\n\n#### Updating Statistics for the Planner\n\nThe query planner uses per-table and per-index row-count estimates collected by `ANALYZE`. After bulk inserts or significant data changes, run `ANALYZE` to refresh statistics so the planner can make accurate cost comparisons:\n\n```sql\nANALYZE;              -- analyze all tables\nANALYZE users;        -- analyze one table\n```\n\n```go\n_, err := db.Exec(`ANALYZE;`)\n```\n\nWithout up-to-date statistics the planner may over- or under-estimate the selectivity of an index and choose a sequential scan instead.\n\n#### ANALYZE Statistics Format\n\n`ANALYZE` stores one row per object (table or index) in the internal `minisql_stats` table. You can inspect it directly:\n\n```sql\nSELECT * FROM minisql_stats;\n```\n\n**Table row** (no index name): a single decimal integer — the row count.\n\n```\n100\n```\n\n**Index row**: a space-separated list of numbers, optionally followed by a histogram and/or Most Common Values (MCV) suffix.\n\n```\n\u003cnEntry\u003e \u003cnDistinct_prefix1\u003e [\u003cnDistinct_prefix2\u003e ...][|h=\u003cbounds\u003e][|mcv=\u003cvalues\u003e]\n```\n\n| Component | Meaning |\n|-----------|---------|\n| `nEntry` | Total number of entries in the index (equals the table row count for non-partial indexes). |\n| `nDistinct_prefixN` | Number of distinct key combinations for the first N columns of the index. Composite indexes emit one value per prefix length. For unique indexes the last value equals `nEntry`. |\n| `\\|h=b0,b1,...,bK` | Equi-depth histogram for the leading column (numeric/timestamp columns only). The K+1 comma-separated floats are bucket boundary values. Each of the K buckets holds approximately the same number of entries. Used by the planner to estimate range-scan selectivity. |\n| `\\|mcv=v1:c1,v2:c2,...` | Most Common Values list (non-unique indexes only, up to 50 entries). Each entry is a URL-encoded value string and its occurrence count, sorted descending by count. Used by the planner for exact equality selectivity estimates. |\n\n**Example** — secondary index on a `status` column with 1 000 rows, 2 distinct values, and a skewed distribution:\n\n```\n1000 2|mcv=active%3A950,inactive%3A50\n```\n\n**Example** — primary key on a numeric `id` column with 10 000 rows (unique, so `nDistinct == nEntry`, histogram appended):\n\n```\n10000 10000|h=1,313,625,938,...,9998,10000\n```\n\nThe planner uses statistics in two ways:\n\n- **Equality cost gate**: if the MCV list shows that an equality condition would match more than 30% of table rows, the planner falls back to a sequential scan instead of the index.\n- **Range cost gate**: if the histogram estimate shows a range condition would match more than 30% of rows, the planner likewise prefers a sequential scan.\n\n### DROP INDEX\n\n```go\n_, err := db.Exec(`DROP INDEX \"idx_created\";`)\n```\n\n### Foreign Keys\n\nMiniSQL supports single-column foreign key constraints declared inside `CREATE TABLE`. There is no `ALTER TABLE ADD CONSTRAINT` — FKs must be defined at table-creation time, following the same approach as SQLite.\n\nThree equivalent syntax forms are accepted:\n\n```sql\n-- 1. Inline REFERENCES on the column definition\nCREATE TABLE orders (\n    id      int8 primary key autoincrement,\n    user_id int8 not null references \"users\" (id)\n);\n\n-- 2. Table-level FOREIGN KEY clause\nCREATE TABLE orders (\n    id      int8 primary key autoincrement,\n    user_id int8 not null,\n    foreign key (user_id) references \"users\" (id)\n);\n\n-- 3. Named constraint (CONSTRAINT … FOREIGN KEY)\nCREATE TABLE orders (\n    id      int8 primary key autoincrement,\n    user_id int8 not null,\n    constraint fk_orders_users foreign key (user_id) references \"users\" (id)\n);\n```\n\n**Rules:**\n- The referenced column must be a `PRIMARY KEY` or `UNIQUE` column in the parent table.\n- A `NULL` value in the FK column bypasses the check (the row is accepted without a matching parent row).\n- Dropping a parent table while a child FK still references it is blocked. Drop the child table first.\n\n**Referential actions** (specified via `ON DELETE` / `ON UPDATE`):\n\n| Action | Syntax | Behaviour in MiniSQL |\n|--------|--------|----------------------|\n| `RESTRICT` | `ON DELETE RESTRICT` | **Default.** Immediately rejects any `DELETE` or `UPDATE` on the parent row if a matching child row exists. |\n| `NO ACTION` | `ON DELETE NO ACTION` | Identical to `RESTRICT` in the current implementation. In the SQL standard the check can be deferred to end-of-statement; MiniSQL always checks immediately because deferred constraints are not yet supported. |\n| `CASCADE` | `ON DELETE CASCADE` | Parsed but **not yet implemented** — returns an error at `CREATE TABLE` time. |\n| `SET NULL` | `ON DELETE SET NULL` | Parsed but **not yet implemented** — returns an error at `CREATE TABLE` time. |\n\nWhen `ON DELETE` or `ON UPDATE` is omitted, `RESTRICT` is used.\n\nFK enforcement can be toggled at runtime:\n\n```sql\nPRAGMA foreign_keys;           -- returns 1 (on) or 0 (off)\nPRAGMA foreign_keys = off;     -- disable FK checks for this connection\nPRAGMA foreign_keys = on;      -- re-enable FK checks\n```\n\nFK checks are **on by default**. The pragma state is per-connection and is not persisted.\n\n```go\n// Example: insert a child row with a valid parent\n_, err = db.Exec(`insert into \"orders\" (user_id, amount) values (1, 100)`)\n\n// Example: this fails with ErrForeignKeyViolation — user_id 99 does not exist\n_, err = db.Exec(`insert into \"orders\" (user_id, amount) values (99, 100)`)\nif err != nil {\n    var fkErr minisqlErrors.ErrForeignKeyViolation\n    if errors.As(err, \u0026fkErr) {\n        fmt.Printf(\"FK violation: %s.%s → %s.%s\\n\",\n            fkErr.ChildTable, fkErr.ChildColumn,\n            fkErr.ParentTable, fkErr.ParentColumn)\n    }\n}\n\n// Example: deleting a parent row that still has children fails with ErrForeignKeyParentViolation\n_, err = db.Exec(`delete from \"users\" where id = 1`)\nif err != nil {\n    var fkErr minisqlErrors.ErrForeignKeyParentViolation\n    if errors.As(err, \u0026fkErr) {\n        fmt.Printf(\"parent FK violation: %s.%s referenced by %s.%s\\n\",\n            fkErr.ParentTable, fkErr.ParentColumn,\n            fkErr.ChildTable, fkErr.ChildColumn)\n    }\n}\n```\n\n## DML Commands\n\n### INSERT\n\nInsert test rows:\n\n```go\ntx, err := s.db.Begin()\nif err != nil {\n\treturn err\n}\naResult, err := tx.ExecContext(context.Background(), `insert into users(\"email\", \"name\", \"age\") values('Danny_Mason2966@xqj6f.tech', 'Danny Mason', 35),\n('Johnathan_Walker250@ptr6k.page', 'Johnathan Walker', 32),\n('Tyson_Weldon2108@zynuu.video', 'Tyson Weldon', 27),\n('Mason_Callan9524@bu2lo.edu', 'Mason Callan', 19),\n('Logan_Flynn9019@xtwt3.pro', 'Logan Flynn', 42),\n('Beatrice_Uttley1670@1wa8o.org', 'Beatrice Uttley', 32),\n('Harry_Johnson5515@jcf8v.video', 'Harry Johnson', 25),\n('Carl_Thomson4218@kyb7t.host', 'Carl Thomson', 53),\n('Kaylee_Johnson8112@c2nyu.design', 'Kaylee Johnson', 48),\n('Cristal_Duvall6639@yvu30.press', 'Cristal Duvall', 27);`)\nif err != nil {\n\treturn err\n}\nrowsAffected, err = aResult.RowsAffected()\nif err != nil {\n\treturn err\n}\n// rowsAffected = 10\nif err := tx.Commit(); err != nil {\n\tif errors.Is(err, minisql.ErrTxConflict) {\n\t\t// transaction conflict, you might want to retry here\n\t}\n\treturn err\n}\n```\n\nWhen trying to insert a duplicate primary key, you will get an error:\n\n```go\n_, err := db.ExecContext(context.Background(), `insert into users(\"id\", \"name\", \"email\", \"age\") values(1, 'Danny Mason', 'Danny_Mason2966@xqj6f.tech', 35);`)\nif err != nil {\n\tif errors.Is(err, minisql.ErrDuplicateKey) {\n\t\t// handle duplicate primary key\n\t}\n\treturn err\n}\n```\n\n### SELECT\n\nSelecting from the table:\n\n```go\n// type user struct {\n// \tID      int64\n// \tEmail   string\n// \tName    string\n// \tCreated time.Time\n// }\n\nrows, err := db.QueryContext(context.Background(), `select * from users;`)\nif err != nil {\n\treturn err\n}\ndefer rows.Close()\nvar users []user\nfor rows.Next() {\n\tvar aUser user\n\terr := rows.Scan(\u0026aUser.ID, \u0026aUser.Name, \u0026aUser.Email, \u0026aUser.Created)\n\tif err != nil {\n\t\treturn err\n\t}\n\tusers = append(users, aUser)\n}\nif err := rows.Err(); err != nil {\n\treturn err\n}\n// continue\n```\n\nTable should have 10 rows now:\n\n```sh\n id     | email                            | name                    | age    | created                       \n--------+----------------------------------+-------------------------+--------+-------------------------------\n 1      | Danny_Mason2966@xqj6f.tech       | Danny Mason             | 35     | 2025-12-21 22:31:35.514831    \n 2      | Johnathan_Walker250@ptr6k.page   | Johnathan Walker        | 32     | 2025-12-21 22:31:35.514831    \n 3      | Tyson_Weldon2108@zynuu.video     | Tyson Weldon            | 27     | 2025-12-21 22:31:35.514831    \n 4      | Mason_Callan9524@bu2lo.edu       | Mason Callan.           | 19     | 2025-12-21 22:31:35.514831    \n 5      | Logan_Flynn9019@xtwt3.pro        | Logan Flynn             | 42     | 2025-12-21 22:31:35.514831    \n 6      | Beatrice_Uttley1670@1wa8o.org    | Beatrice Uttley         | 32     | 2025-12-21 22:31:35.514831    \n 7      | Harry_Johnson5515@jcf8v.video    | Harry Johnson.          | 25     | 2025-12-21 22:31:35.514831    \n 8      | Carl_Thomson4218@kyb7t.host      | Carl Thomson            | 53     | 2025-12-21 22:31:35.514831    \n 9      | Kaylee_Johnson8112@c2nyu.design  | Kaylee Johnson.         | 48     | 2025-12-21 22:31:35.514831    \n 10     | Cristal_Duvall6639@yvu30.press   | Cristal Duvall.         | 27     | 2025-12-21 22:31:35.514831    \n```\n\nYou can also count rows in a table:\n\n```go\nvar count int\nif err := db.QueryRow(`select count(*) from users;`).Scan(\u0026count); err != nil {\n\treturn err\n}\n```\n\nYou can inspect the query plan for a `SELECT` with `EXPLAIN`. The result columns are `step`, `operation`, `detail`, `rows_estimated`, `rows_actual`, and `duration_us`. For plain `EXPLAIN`, actual rows and duration are `NULL`; `EXPLAIN ANALYZE` executes the query and fills those fields.\n\n```go\ntype explainStep struct {\n\tStep          int64\n\tOperation     string\n\tDetail        string\n\tRowsEstimated sql.NullInt64\n\tRowsActual    sql.NullInt64\n\tDurationUS    sql.NullInt64\n}\n\nrows, err := db.QueryContext(context.Background(), `\n\tEXPLAIN ANALYZE\n\tSELECT * FROM users WHERE age \u003e= 30 ORDER BY created DESC;\n`)\nif err != nil {\n\treturn err\n}\ndefer rows.Close()\n\nvar plan []explainStep\nfor rows.Next() {\n\tvar step explainStep\n\tif err := rows.Scan(\n\t\t\u0026step.Step,\n\t\t\u0026step.Operation,\n\t\t\u0026step.Detail,\n\t\t\u0026step.RowsEstimated,\n\t\t\u0026step.RowsActual,\n\t\t\u0026step.DurationUS,\n\t); err != nil {\n\t\treturn err\n\t}\n\tplan = append(plan, step)\n}\nif err := rows.Err(); err != nil {\n\treturn err\n}\n```\n\n#### JOINs\n\n`INNER JOIN`, `LEFT JOIN`, and `RIGHT JOIN` are supported. Arbitrary chain topologies work — three or more tables can be joined in sequence, not just star-schema patterns.\n\n### UPDATE\n\nLet's try using a prepared statement to update a row:\n\n```go\nstmt, err := db.Prepare(`update users set age = ? where id = ?;`)\nif err != nil {\n\treturn err\n}\naResult, err := stmt.Exec(int64(36), int64(1))\nif err != nil {\n\treturn err\n}\nrowsAffected, err = aResult.RowsAffected()\nif err != nil {\n\treturn err\n}\n// rowsAffected = 1\n```\n\nSelect to verify update:\n\n```sh\n id     | email                            | name                    | age    | created                       \n--------+----------------------------------+-------------------------+--------+-------------------------------\n 1      | Danny_Mason2966@xqj6f.tech       | Danny Mason             | 36     | 2025-12-21 22:31:35.514831    \n```\n\n### UPDATE FROM\n\nPostgreSQL-style `UPDATE … FROM` lets you set column values based on data from a second table (or subquery). The target table can have an optional alias; the `FROM` source can be a table name or a derived subquery.\n\n```go\n// Set each employee's salary to the budget of their department divided by 10.\n_, err := db.ExecContext(context.Background(), `\n    update employees e\n    set salary = d.budget / 10\n    from departments d\n    where e.dept_id = d.id\n`)\nif err != nil {\n    return err\n}\n\n// Same query using explicit AS aliases.\n_, err = db.ExecContext(context.Background(), `\n    update employees as emp\n    set salary = dept.budget / 10\n    from departments as dept\n    where emp.dept_id = dept.id\n`)\n\n// FROM can also be a subquery.\n_, err = db.ExecContext(context.Background(), `\n    update employees e\n    set salary = d.budget\n    from (select id, budget from departments where id = 1) as d\n    where e.dept_id = d.id\n`)\n```\n\nEach target row may match **at most one** FROM row; zero matches leaves the row unchanged. If more than one FROM row matches a single target row, the statement returns an error.\n\n### DELETE\n\nYou can also delete rows:\n\n```go\n_, err := db.ExecContext(context.Background(), `delete from users;`)\nif err != nil {\n\treturn err\n}\nrowsAffected, err = aResult.RowsAffected()\nif err != nil {\n\treturn err\n}\n```\n\n## Development \n\nMiniSQL uses [mockery](https://github.com/vektra/mockery) to generate mocks for interfaces. Install mockery:\n\n```sh\ngo install github.com/vektra/mockery/v3@v3.6.1\n```\n\nThen to generate mocks:\n\n```sh\nmockery\n```\n\nTo run unit tests:\n\n```sh\nLOG_LEVEL=info go test ./... -count=1\n```\n\nSetting the `LOG_LEVEL` to `info` makes sure to supress debug logs and makes potential error messages in tests easier to read and debug.\n\n### Benchmarking \u0026 Profiling\n\nSee the [benchmarks/README.md](https://github.com/RichardKnop/minisql/blob/main/benchmarks/README.md) and [benchmarks/RESULTS.md](https://github.com/RichardKnop/minisql/blob/main/benchmarks/RESULTS.md).\n\n## Acknowledgements \n\nShout out to some great repos and other resources that were invaluable while figuring out how to get this all working together:\n- [Let's Build a Simple Database](https://cstack.github.io/db_tutorial/parts/part1.html)\n- [go-sqldb](https://github.com/auxten/go-sqldb)\n- [sqlparser](https://github.com/marianogappa/sqlparser)\n- [sqlite docs](https://www.sqlite.org/fileformat2.html) (section about file format has been especially useful)\n- [C++ implementation of B+ tree](https://github.com/sayef/bplus-tree)\n- [Mastering PostgreSQL GIN Indexes: The Ultimate Guide to Faster JSONB, Array, and Full-Text Search](https://medium.com/@vedantthakkar1003/mastering-postgresql-gin-indexes-the-ultimate-guide-to-faster-jsonb-array-and-full-text-search-f1f8ec3e67af)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frichardknop%2Fminisql","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frichardknop%2Fminisql","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frichardknop%2Fminisql/lists"}