minisql

package module
v0.53.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 8, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

README

minisql

CI Status Go Report Card

MiniSQL is an embedded single file database written in Golang, inspired by SQLite. It is not a clone of SQLite in Go, it is an alternative solution which borrows ideas from other databases as well. It can differentiate itself from SQLite in several areas:

  1. Pure Go, zero CGO — already a differentiator.
  2. MVCC snapshot isolation — true MVCC for reads already implemented.
  3. Parallel query execution — SQLite is single-threaded; MiniSQL can parallelize full table scans.
  4. Modern API surface — idiomatic Go, context-aware, database/sql compatible.
  5. JSON as a first-class type — native JSONB (not an extension) - not implemented yet.

This is an early stage project and it might contain bugs and is not battle tested. Please employ caution when using this database.

Donate Bitcoin

To use minisql in your Go code, import the driver:

import (
  _ "github.com/RichardKnop/minisql"
)

And create a database instance:

// Simple path
db, err := sql.Open("minisql", "./my.db")

// With connection parameters
db, err := sql.Open("minisql", "./my.db?log_level=debug")

// Multiple parameters
db, err := sql.Open("minisql", "./my.db?log_level=debug&max_cached_pages=500")

Connection Pooling

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:

  • Write-heavy workloads: SetMaxOpenConns(1) still makes sense — writes serialize internally on dbLock anyway, multiple connections just add OCC conflict noise without throughput gain.
  • Read-heavy or mixed workloads: multiple connections are beneficial — read-only transactions run concurrently via MVCC snapshot isolation without holding the database lock.

Connection String Parameters

MiniSQL supports optional connection string parameters:

Parameter Values Default Description
wal_checkpoint_threshold non-negative integer 1000 Auto-checkpoint after N WAL frames (0 = disabled)
log_level debug, info, warn, error warn Set logging verbosity level
max_cached_pages positive integer 2000 Maximum number of pages to keep in memory cache
slow_query_threshold Go duration, e.g. 50ms, 2s 0 Log queries taking at least this long at WARN level (0 = disabled)
synchronous off, normal, full normal WAL fsync mode (see WAL durability below)
parallel_scan on, off off Enable concurrent leaf-page scanning for full table scans (see Parallel Full Table Scan below)

Examples:

// Enable debug logging
db, err := sql.Open("minisql", "./my.db?log_level=debug")

// Set cache size to 500 pages (~2MB memory)
db, err := sql.Open("minisql", "./my.db?max_cached_pages=500")

// Disable auto-checkpoint (manual checkpoint only)
db, err := sql.Open("minisql", "./my.db?wal_checkpoint_threshold=0")

// Maximum write durability (fsync after every commit)
db, err := sql.Open("minisql", "./my.db?synchronous=full")

// Log queries that take at least 50ms
db, err := sql.Open("minisql", "./my.db?slow_query_threshold=50ms")

// Enable parallel full table scans
db, err := sql.Open("minisql", "./my.db?parallel_scan=on")

// Combine multiple parameters
db, err := sql.Open("minisql", "/path/to/db.db?log_level=info&max_cached_pages=2000")

Write-Ahead Log (WAL)

MiniSQL 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.

Commit protocol:

  1. Serialise all modified pages as WAL frames and write them to the WAL file.
  2. Optionally fsync() the WAL file (controlled by the synchronous setting).
  3. The in-memory WAL index is updated so subsequent reads see the new pages immediately.
  4. The main database file is not written during a commit — it is updated only during a checkpoint.

On 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.

Checkpoint (PRAGMA wal_checkpoint):

  1. Copies every WAL page into the main database file.
  2. Sync()s the database file (skipped in synchronous=off).
  3. Truncates the WAL file to its header (32 bytes).
  4. Resets the in-memory WAL index.

An 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.

WAL Durability Modes

The synchronous setting controls when fsync() is called, trading durability for write performance. This matches SQLite's PRAGMA synchronous for WAL mode.

Mode Connection string PRAGMA Description
normal synchronous=normal PRAGMA synchronous = normal Default. No fsync per commit. fsync only at checkpoint. Matches SQLite WAL default.
full synchronous=full PRAGMA synchronous = full fsync after every WAL commit. Maximum durability — survives an OS crash between commits.
off synchronous=off PRAGMA synchronous = off No fsyncs at all. Fastest, but uncommitted data may be lost on OS crash or power failure.

The 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.

You can read the current mode at runtime:

PRAGMA synchronous;   -- returns 0 (off), 1 (normal), or 2 (full)

And change it for the current connection:

PRAGMA synchronous = full;
PRAGMA synchronous = normal;
PRAGMA synchronous = off;

Parallel Full Table Scan

When 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.

Parallel 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.

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.

Enable at connection open time via the connection string:

db, err := sql.Open("minisql", "./my.db?parallel_scan=on")

Or toggle at runtime with PRAGMA (affects all existing tables on the connection immediately):

PRAGMA parallel_scan = on;
PRAGMA parallel_scan;     -- returns 0 (off) or 1 (on)
PRAGMA parallel_scan = off;
Mode Connection string PRAGMA Description
off (default) PRAGMA parallel_scan = off Single-goroutine sequential leaf scan. Best for small tables or single-CPU environments.
on parallel_scan=on PRAGMA parallel_scan = on Leaf pages partitioned across runtime.NumCPU() goroutines. Rows delivered in arrival order (not row-ID order).

Storage

Each 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.

4096 (page size) 
- 7 (base header size) 
- 8 (internal / leaf node header size) 
- 8 (null bit mask) 
- 8 (internal row ID / key) 
= 4065

All 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.

Each 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.

Moreover, 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.

Database Header Format

The first 100 bytes of page 0 are reserved for the MiniSQL database header. This is part of the on-disk file format.

Current header fields:

Offset Size Field Description
0 8 magic minisql\0 file signature
8 4 format version Current value: 1
12 4 page size Current value: 4096
16 4 first free page Head of the free-page linked list
20 4 free page count Number of free pages currently tracked
24 76 reserved Reserved for future file-format metadata

Notes:

  • MiniSQL now requires the header magic/version/page size to be present when opening a database file.
  • The remaining bytes are reserved so the header can grow without immediately changing the page layout again.
  • The rest of page 0 after the first 100 bytes is used as a normal root B+ tree page.

Concurrency

MiniSQL implements two complementary concurrency control mechanisms:

Write Transactions — Optimistic Concurrency Control (OCC)

Write transactions use Optimistic Concurrency Control. The transaction manager follows a simple process:

  1. 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).
  2. Check at commit time — Verify no pages were modified between the first read and the commit.
  3. Abort on conflict — If any tracked page has a newer version at commit time, abort with ErrTxConflict.

You can use ErrTxConflict to decide whether to retry or surface the error to the caller.

Read-Only Transactions — Snapshot Isolation (MVCC)

Read-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.

This is similar to how SQLite handles isolation. Under the hood:

  • A monotonically increasing commitSeq counter is incremented on every write commit.
  • Each read-only transaction captures the current commitSeq as its SnapshotSeq at start time.
  • At write commit time, the pre-modification copy of each modified page is saved in an in-memory version history (pageVersionHistory).
  • 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.
  • Historical versions are garbage-collected once all snapshot readers that needed them have committed.
Time 0: Read TX1 starts — SnapshotSeq = 1
Time 1: Write TX2 modifies page, commits — commitSeq advances to 2; old page saved in version history
Time 2: TX1 reads the page → sees the historical version at seq 1, not TX2's change
Time 3: TX1 commits; version history for seq 1 is GC'd

Checkpoint (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.

System Table

All tables and indexes are tracked in the system table minisql_schema. For empty database, it would contain only its own reference:

 type   | name               | table_name         | root_page   | sql                                                
--------+--------------------+--------------------+-------------+----------------------------------------
 1      | minisql_schema     |                    | 0           | create table "minisql_schema" (        
        |                    |                    |             | 	type int4 not null,                  
        |                    |                    |             | 	name varchar(255) not null,          
        |                    |                    |             | 	table_name varchar(255),             
        |                    |                    |             | 	root_page int4,                      
        |                    |                    |             | 	sql text                             
        |                    |                    |             | )                                      

Let's say you create a table such as:

create table "users" (
	id int8 primary key autoincrement,
	email varchar(255) unique,
	name text,
	age int4,
	created timestamp default now()
);
create index "idx_created" on "users" (
	created
);

It 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.

You can check current objects in the minisql_schema system table by a simple SELECT query.

// type schema struct {
// 	Type      int
// 	Name      string
// 	TableName *string
// 	RootPage  int
// 	Sql       *string
// }

rows, err := db.QueryContext(context.Background(), `select * from minisql_schema;`)
if err != nil {
	return err
}
defer rows.Close()

var schemas []schema
for rows.Next() {
	var aSchema schema
	if err := rows.Scan(&aSchema.Type, &aSchema.Name, &aSchema.TableName, &aSchema.RootPage, &aSchema.SQL); err != nil {
		return err
	}
	schemas = append(schemas, aSchema)
}
if err := rows.Err(); err != nil {
	return err
}
 type   | name               | table_name         | root_page   | sql                                                
--------+--------------------+--------------------+-------------+----------------------------------------
 1      | minisql_schema     |                    | 0           | create table "minisql_schema" (        
        |                    |                    |             | 	type int4 not null,                  
        |                    |                    |             | 	name varchar(255) not null,          
        |                    |                    |             | 	table_name varchar(255),             
        |                    |                    |             | 	root_page int4,                      
        |                    |                    |             | 	sql text                             
        |                    |                    |             | )                                      
 1      | users              |                    | 1           | create table "users" (                 
        |                    |                    |             | 	id int8 primary key autoincrement,   
        |                    |                    |             | 	email varchar(255) unique,           
        |                    |                    |             | 	name text,                           
        |                    |                    |             | 	age int4,                            
        |                    |                    |             | 	created timestamp default now()      
        |                    |                    |             | );                                     
 2      | pkey__users        | users              | 2           | NULL                                   
 3      | key__users_email   | users              | 3           | NULL                                   
 4      | idx_users          | users              | 4           | create index "idx_created" on "users" (             
        |                    |                    |             | 	created,                             
        |                    |                    |             | );                                     

Data Types And Storage

Data type Description
BOOLEAN 1-byte boolean value (true/false).
INT4 4-byte signed integer (-2,147,483,648 to 2,147,483,647).
INT8 8-byte signed integer (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807).
REAL 4-byte single-precision floating-point number.
DOUBLE 8-byte double-precision floating-point number.
TEXT Variable-length text. If length is <= 255, the text is stored inline, otherwise text is stored in overflow pages (with UTF-8 encoding).
VARCHAR(n) Storage works the same way as TEXT but allows limiting length of inserted/updated text to max value.
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.

TIMESTAMP Spec

MiniSQL 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.

  • Storage format: signed 64-bit integer counting microseconds since 2000-01-01 00:00:00 UTC (the PostgreSQL epoch).
  • Precision: microseconds. Fractional seconds from 1 to 6 digits are accepted and are scaled to microseconds.
  • Calendar model: proleptic Gregorian calendar for the full supported range.
  • Supported range: 4713-01-01 00:00:00 BC through 294276-12-31 23:59:59.999999.
  • BC handling: input and output use PostgreSQL-style BC suffix. Internally, astronomical year numbering is used (1 BC = year 0, 2 BC = year -1).
  • NOW(): evaluated in UTC and stored as a timezone-naive timestamp value.

Accepted literal forms:

  • YYYY-MM-DD HH:MM:SS
  • YYYY-MM-DD HH:MM:SS.f
  • YYYY-MM-DD HH:MM:SS.ff
  • YYYY-MM-DD HH:MM:SS.ffffff
  • Any of the above with trailing BC

Examples:

'2024-03-15 10:30:45'
'2024-03-15 10:30:45.1'
'2024-03-15 10:30:45.123456'
'0001-12-31 23:59:59.999999 BC'

Important behavior and current non-goals:

  • Timezone-qualified values are rejected. Examples: Z, UTC, GMT, +01:00, -05:30.
  • Leap seconds are not supported. Seconds must be in the range 00 to 59.
  • Year 0000 is rejected in input. Use 0001 ... BC for 1 BC.
  • MiniSQL does not currently support TIMESTAMP WITH TIME ZONE.
  • String formatting normalizes fractional precision to either no fractional part or exactly 6 fractional digits.

SQL Features

Feature Notes
CREATE TABLE, CREATE TABLE IF NOT EXISTS
PRIMARY KEY Single column only; no composite primary keys
AUTOINCREMENT Primary key must be of type INT8
UNIQUE Can be specified when creating a table
CHECK Constraints to test values whenever they are inserted or updated in a column
FOREIGN KEY Single-column FK constraints with RESTRICT / NO ACTION; declared inside CREATE TABLE. PRAGMA foreign_keys = on|off (default on). See Foreign Keys.
Composite primary key or unique constraint As part of CREATE TABLE
NULL and NOT NULL Via null bit mask included in each row/cell
DEFAULT Supported for all columns, including NOW() for TIMESTAMP
DROP TABLE
CREATE INDEX, DROP INDEX Secondary non-unique indexes only; primary and unique indexes are declared as part of CREATE TABLE
INSERT Single row or multiple rows via a tuple of values separated by commas
ON CONFLICT Both DO NOTHING and DO UPDATE supported (with EXCLUDED pseudo table syntax for updating)
SELECT All fields with *, specific fields, or row count with COUNT(*), derived tables support
SELECT DISTINCT
WITH Basic support for CTEs, SELECT only currently
EXPLAIN, EXPLAIN ANALYZE Query plan inspection for SELECT statements. EXPLAIN ANALYZE also executes the query and returns actual row counts and timing
JOIN INNER, LEFT and RIGHT joins supported
UPDATE
DELETE
RETURNING Can be used to return columns from INSERT or DELETE queries, common use case is to return auto incremented primary key
WHERE Operators: =, !=, >, >=, <, <=, IN, NOT IN, LIKE, NOT LIKE, BETWEEN, support for SELECT only non-correlated scalar subqueries
LIKE, NOT LIKE % matches any sequence of zero or more characters; _ matches any single character
LIMIT and OFFSET Basic pagination
ORDER BY Single column only
GROUP BY and HAVING Aggregate functions: COUNT, MAX, MIN, SUM, AVG
Arithmetic expressions +, -, *, / in SELECT and UPDATE SET (e.g. price * 1.1, count + 1)
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
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
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
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
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
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.
CAST(expr AS type) Standard SQL type coercion. Supported target types: BOOLEAN, INT4, INT8, REAL, DOUBLE, TEXT, VARCHAR(n), TIMESTAMP. Follows SQLite semantics: float→int truncates toward zero; text→int/float parses leading digits (non-numeric input → 0). NULL propagates. Usable anywhere an expression is valid (e.g. SELECT CAST(price AS INT8), SELECT CAST(n AS TEXT) AS label).
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'.
VACUUM Rebuilds the database file, repacking it into a minimal amount of disk space (similar to SQLite)
PRAGMA quick_check A cheap structural health check of the open database.
PRAGMA integrity_check A deeper structural and logical check: page graph, overflow chains, and table/index consistency. Prefer offline use for large databases
PRAGMA wal_checkpoint Manually flush WAL frames to the main database file and truncate the WAL.
PRAGMA synchronous Read current WAL fsync mode (returns 0/1/2).
PRAGMA synchronous = off|normal|full Set WAL fsync mode for the current connection. See WAL Durability Modes.
PRAGMA parallel_scan Read current parallel scan state (returns 0 = off, 1 = on).
PRAGMA parallel_scan = on|off Enable or disable concurrent leaf-page scanning for full table scans. See Parallel Full Table Scan.

Prepared statements are supported using ? as a placeholder. For example:

insert into users("name", "email") values(?, ?), (?, ?);

DDL SQL Commands

CREATE TABLE

Let's start by creating your first table:

_, err := db.Exec(`create table "users" (
	id int8 primary key autoincrement,
	email varchar(255) unique,
	name text,
	age int4,
	created timestamp default now()
);`)
DROP TABLE
_, err := db.Exec(`drop table "users";`)
CREATE INDEX

Currently you can only create secondary non unique indexes. Unique and primary index can be created as part of CREATE TABLE.

_, err := db.Exec(`create index "idx_created" on "users" (created);`)
DROP INDEX

Currently you can only drop secondary non unique indexes.

_, err := db.Exec(`drop index "idx_created";`)
Foreign Keys

MiniSQL 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.

Three equivalent syntax forms are accepted:

-- 1. Inline REFERENCES on the column definition
CREATE TABLE orders (
    id      int8 primary key autoincrement,
    user_id int8 not null references "users" (id)
);

-- 2. Table-level FOREIGN KEY clause
CREATE TABLE orders (
    id      int8 primary key autoincrement,
    user_id int8 not null,
    foreign key (user_id) references "users" (id)
);

-- 3. Named constraint (CONSTRAINT … FOREIGN KEY)
CREATE TABLE orders (
    id      int8 primary key autoincrement,
    user_id int8 not null,
    constraint fk_orders_users foreign key (user_id) references "users" (id)
);

Rules:

  • The referenced column must be a PRIMARY KEY or UNIQUE column in the parent table.
  • A NULL value in the FK column bypasses the check (the row is accepted without a matching parent row).
  • Dropping a parent table while a child FK still references it is blocked. Drop the child table first.

Referential actions (specified via ON DELETE / ON UPDATE):

Action Syntax Behaviour in MiniSQL
RESTRICT ON DELETE RESTRICT Default. Immediately rejects any DELETE or UPDATE on the parent row if a matching child row exists.
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.
CASCADE ON DELETE CASCADE Parsed but not yet implemented — returns an error at CREATE TABLE time.
SET NULL ON DELETE SET NULL Parsed but not yet implemented — returns an error at CREATE TABLE time.

When ON DELETE or ON UPDATE is omitted, RESTRICT is used.

FK enforcement can be toggled at runtime:

PRAGMA foreign_keys;           -- returns 1 (on) or 0 (off)
PRAGMA foreign_keys = off;     -- disable FK checks for this connection
PRAGMA foreign_keys = on;      -- re-enable FK checks

FK checks are on by default. The pragma state is per-connection and is not persisted.

// Example: insert a child row with a valid parent
_, err = db.Exec(`insert into "orders" (user_id, amount) values (1, 100)`)

// Example: this fails with ErrForeignKeyViolation — user_id 99 does not exist
_, err = db.Exec(`insert into "orders" (user_id, amount) values (99, 100)`)
if err != nil {
    var fkErr minisql.ErrForeignKeyViolation
    if errors.As(err, &fkErr) {
        fmt.Printf("FK violation: %s.%s → %s.%s\n",
            fkErr.ChildTable, fkErr.ChildColumn,
            fkErr.ParentTable, fkErr.ParentColumn)
    }
}

// Example: deleting a parent row that still has children fails with ErrForeignKeyParentViolation
_, err = db.Exec(`delete from "users" where id = 1`)
if err != nil {
    var fkErr minisql.ErrForeignKeyParentViolation
    if errors.As(err, &fkErr) {
        fmt.Printf("parent FK violation: %s.%s referenced by %s.%s\n",
            fkErr.ParentTable, fkErr.ParentColumn,
            fkErr.ChildTable, fkErr.ChildColumn)
    }
}

DML Commands

INSERT

Insert test rows:

tx, err := s.db.Begin()
if err != nil {
	return err
}
aResult, err := tx.ExecContext(context.Background(), `insert into users("email", "name", "age") values('Danny_Mason2966@xqj6f.tech', 'Danny Mason', 35),
('Johnathan_Walker250@ptr6k.page', 'Johnathan Walker', 32),
('Tyson_Weldon2108@zynuu.video', 'Tyson Weldon', 27),
('Mason_Callan9524@bu2lo.edu', 'Mason Callan', 19),
('Logan_Flynn9019@xtwt3.pro', 'Logan Flynn', 42),
('Beatrice_Uttley1670@1wa8o.org', 'Beatrice Uttley', 32),
('Harry_Johnson5515@jcf8v.video', 'Harry Johnson', 25),
('Carl_Thomson4218@kyb7t.host', 'Carl Thomson', 53),
('Kaylee_Johnson8112@c2nyu.design', 'Kaylee Johnson', 48),
('Cristal_Duvall6639@yvu30.press', 'Cristal Duvall', 27);`)
if err != nil {
	return err
}
rowsAffected, err = aResult.RowsAffected()
if err != nil {
	return err
}
// rowsAffected = 10
if err := tx.Commit(); err != nil {
	if errors.Is(err, minisql.ErrTxConflict) {
		// transaction conflict, you might want to retry here
	}
	return err
}

When trying to insert a duplicate primary key, you will get an error:

_, err := db.ExecContext(context.Background(), `insert into users("id", "name", "email", "age") values(1, 'Danny Mason', 'Danny_Mason2966@xqj6f.tech', 35);`)
if err != nil {
	if errors.Is(err, minisql.ErrDuplicateKey) {
		// handle duplicate primary key
	}
	return err
}
SELECT

Selecting from the table:

// type user struct {
// 	ID      int64
// 	Email   string
// 	Name    string
// 	Created time.Time
// }

rows, err := db.QueryContext(context.Background(), `select * from users;`)
if err != nil {
	return err
}
defer rows.Close()
var users []user
for rows.Next() {
	var aUser user
	err := rows.Scan(&aUser.ID, &aUser.Name, &aUser.Email, &aUser.Created)
	if err != nil {
		return err
	}
	users = append(users, aUser)
}
if err := rows.Err(); err != nil {
	return err
}
// continue

Table should have 10 rows now:

 id     | email                            | name                    | age    | created                       
--------+----------------------------------+-------------------------+--------+-------------------------------
 1      | Danny_Mason2966@xqj6f.tech       | Danny Mason             | 35     | 2025-12-21 22:31:35.514831    
 2      | Johnathan_Walker250@ptr6k.page   | Johnathan Walker        | 32     | 2025-12-21 22:31:35.514831    
 3      | Tyson_Weldon2108@zynuu.video     | Tyson Weldon            | 27     | 2025-12-21 22:31:35.514831    
 4      | Mason_Callan9524@bu2lo.edu       | Mason Callan.           | 19     | 2025-12-21 22:31:35.514831    
 5      | Logan_Flynn9019@xtwt3.pro        | Logan Flynn             | 42     | 2025-12-21 22:31:35.514831    
 6      | Beatrice_Uttley1670@1wa8o.org    | Beatrice Uttley         | 32     | 2025-12-21 22:31:35.514831    
 7      | Harry_Johnson5515@jcf8v.video    | Harry Johnson.          | 25     | 2025-12-21 22:31:35.514831    
 8      | Carl_Thomson4218@kyb7t.host      | Carl Thomson            | 53     | 2025-12-21 22:31:35.514831    
 9      | Kaylee_Johnson8112@c2nyu.design  | Kaylee Johnson.         | 48     | 2025-12-21 22:31:35.514831    
 10     | Cristal_Duvall6639@yvu30.press   | Cristal Duvall.         | 27     | 2025-12-21 22:31:35.514831    

You can also count rows in a table:

var count int
if err := db.QueryRow(`select count(*) from users;`).Scan(&count); err != nil {
	return err
}

You 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.

type explainStep struct {
	Step          int64
	Operation     string
	Detail        string
	RowsEstimated sql.NullInt64
	RowsActual    sql.NullInt64
	DurationUS    sql.NullInt64
}

rows, err := db.QueryContext(context.Background(), `
	EXPLAIN ANALYZE
	SELECT * FROM users WHERE age >= 30 ORDER BY created DESC;
`)
if err != nil {
	return err
}
defer rows.Close()

var plan []explainStep
for rows.Next() {
	var step explainStep
	if err := rows.Scan(
		&step.Step,
		&step.Operation,
		&step.Detail,
		&step.RowsEstimated,
		&step.RowsActual,
		&step.DurationUS,
	); err != nil {
		return err
	}
	plan = append(plan, step)
}
if err := rows.Err(); err != nil {
	return err
}
INNER JOIN (star schema)

There is an experimental support for INNER JOIN, however it only supports star schema joins, i.e. one or more tables joined with the base table. Nested joins and other types of joins such as LEFT, RIGHT are not supported yet.

UPDATE

Let's try using a prepared statement to update a row:

stmt, err := db.Prepare(`update users set age = ? where id = ?;`)
if err != nil {
	return err
}
aResult, err := stmt.Exec(int64(36), int64(1))
if err != nil {
	return err
}
rowsAffected, err = aResult.RowsAffected()
if err != nil {
	return err
}
// rowsAffected = 1

Select to verify update:

 id     | email                            | name                    | age    | created                       
--------+----------------------------------+-------------------------+--------+-------------------------------
 1      | Danny_Mason2966@xqj6f.tech       | Danny Mason             | 36     | 2025-12-21 22:31:35.514831    
DELETE

You can also delete rows:

_, err := db.ExecContext(context.Background(), `delete from users;`)
if err != nil {
	return err
}
rowsAffected, err = aResult.RowsAffected()
if err != nil {
	return err
}

Development

MiniSQL uses mockery to generate mocks for interfaces. Install mockery:

go install github.com/vektra/mockery/v3@v3.6.1

Then to generate mocks:

mockery

To run unit tests:

LOG_LEVEL=info go test ./... -count=1

Setting the LOG_LEVEL to info makes sure to supress debug logs and makes potential error messages in tests easier to read and debug.

Benchmarking & Profiling

See the benchmarks/README.md and benchmarks/RESULTS.md.

Acknowledgements

Shout out to some great repos and other resources that were invaluable while figuring out how to get this all working together:

Documentation

Index

Constants

View Source
const (
	SynchronousOff    = minisql.SynchronousOff
	SynchronousNormal = minisql.SynchronousNormal
	SynchronousFull   = minisql.SynchronousFull
)

Synchronous mode constants.

View Source
const DefaultWALCheckpointThreshold = 1000

DefaultWALCheckpointThreshold is the number of WAL frames that triggers an automatic checkpoint when WAL mode is enabled.

View Source
const DefaultWALWriteBufferSize = 64 * 1024

DefaultWALWriteBufferSize is the default write-buffer size for WAL frame batching (64 KiB ≈ 16 full-page frames). A larger buffer reduces WriteAt syscall overhead for high-frequency single-row-per-transaction workloads at the cost of slightly higher data-loss exposure on unclean shutdown.

Variables

This section is empty.

Functions

This section is empty.

Types

type Conn

type Conn struct {
	// contains filtered or unexported fields
}

Conn implements the database/sql/driver.Conn interface.

func (*Conn) Begin deprecated

func (c *Conn) Begin() (driver.Tx, error)

Begin starts and returns a new transaction.

Deprecated: Drivers should implement ConnBeginTx instead (or additionally).

func (*Conn) BeginTx

func (c *Conn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error)

BeginTx starts and returns a new transaction.

func (*Conn) Close

func (c *Conn) Close() error

Close invalidates and potentially stops any current prepared statements and transactions, marking this connection as no longer in use.

Because the sql package maintains a free pool of connections and only calls Close when there's a surplus of idle connections, it shouldn't be necessary for drivers to do their own connection caching.

Drivers must ensure all network calls made by Close do not block indefinitely (e.g. apply a timeout).

func (*Conn) ExecContext

func (c *Conn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (result driver.Result, err error)

ExecContext executes a query that doesn't return rows.

func (*Conn) HasActiveTransaction

func (c *Conn) HasActiveTransaction() bool

HasActiveTransaction ...

func (*Conn) Ping

func (c *Conn) Ping(ctx context.Context) error

Ping ...

func (*Conn) Prepare

func (c *Conn) Prepare(query string) (driver.Stmt, error)

Prepare returns a prepared statement, bound to this connection.

func (*Conn) PrepareContext

func (c *Conn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error)

PrepareContext returns a prepared statement, bound to this connection. context is for the preparation of the statement, it must not store the context within the statement itself.

func (*Conn) QueryContext

func (c *Conn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (rows driver.Rows, err error)

QueryContext executes a query that may return rows.

func (*Conn) SetTransaction

func (c *Conn) SetTransaction(tx *minisql.Transaction)

SetTransaction ...

func (*Conn) TransactionContext

func (c *Conn) TransactionContext(ctx context.Context) context.Context

TransactionContext ...

type ConnectionConfig

type ConnectionConfig struct {
	FilePath               string          // Database file path
	WALCheckpointThreshold int             // Auto-checkpoint threshold in WAL frames (default: 1000)
	WALWriteBufferSize     int             // WAL write-buffer size in bytes (default: 64 KiB; 0 = flush every commit)
	LogLevel               string          // Log level: debug, info, warn, error (default: warn)
	MaxCachedPages         int             // Maximum number of pages to cache (default: 2000, 0 = use default)
	SlowQueryThreshold     time.Duration   // Log queries at WARN when elapsed time meets or exceeds this duration (0 = disabled)
	Synchronous            SynchronousMode // WAL fsync mode: off, normal (default), full
	ParallelScan           bool            // Enable concurrent leaf-page scanning (default: false)
}

ConnectionConfig holds parsed connection string parameters.

func DefaultConnectionConfig

func DefaultConnectionConfig(filePath string) *ConnectionConfig

DefaultConnectionConfig returns default configuration.

func ParseConnectionString

func ParseConnectionString(connStr string) (*ConnectionConfig, error)

ParseConnectionString parses a connection string with optional query parameters.

Format: /path/to/database.db?param1=value1&param2=value2

Supported parameters:

  • wal_checkpoint_threshold=N : Auto-checkpoint after N WAL frames (default: 1000; 0 = disabled)
  • wal_write_buffer_size=N : WAL write-buffer in bytes (default: 65536; 0 = flush every commit)
  • log_level=debug|info|warn|error : Set logging level (default: warn)
  • max_cached_pages=N : Page cache size in pages (default: 2000)
  • slow_query_threshold=50ms : Log queries taking at least this long (0 = disabled)
  • synchronous=off|normal|full : WAL fsync mode (default: normal, matching SQLite WAL default)
  • parallel_scan=on|off : Enable concurrent leaf-page scanning (default: off)

Examples:

  • "./my.db" : Default settings
  • "./my.db?log_level=debug" : Enable debug logging
  • "./my.db?wal_checkpoint_threshold=500" : Auto-checkpoint every 500 frames
  • "./my.db?wal_write_buffer_size=0" : Disable write batching (flush every commit)
  • "./my.db?synchronous=full" : fsync on every commit (maximum durability)
  • "./my.db?parallel_scan=on" : Enable parallel full table scans
  • "./my.db?log_level=info&max_cached_pages=500" : Multiple parameters

func (*ConnectionConfig) GetZapLevel

func (c *ConnectionConfig) GetZapLevel() zap.AtomicLevel

GetZapLevel converts log level string to zap.Level.

type Driver

type Driver struct {
	// contains filtered or unexported fields
}

Driver implements the database/sql/driver.Driver interface.

func (*Driver) Open

func (d *Driver) Open(name string) (driver.Conn, error)

Open returns a new connection to the database. The name is a connection string with optional parameters:

  • "./my.db" - simple path
  • "./my.db?wal_checkpoint_threshold=500" - auto-checkpoint after 500 WAL frames
  • "./my.db?log_level=debug" - enable debug logging
  • "./my.db?wal_checkpoint_threshold=500&log_level=info" - multiple parameters

type Result

type Result struct {
	// contains filtered or unexported fields
}

Result ...

func (Result) LastInsertId

func (r Result) LastInsertId() (int64, error)

LastInsertId returns the database's auto-generated ID after, for example, an INSERT into a table with primary key.

func (Result) RowsAffected

func (r Result) RowsAffected() (int64, error)

RowsAffected returns the number of rows affected by the query.

type Rows

type Rows struct {
	// contains filtered or unexported fields
}

Rows ...

func (Rows) Close

func (r Rows) Close() error

Close closes the rows iterator.

func (Rows) Columns

func (r Rows) Columns() []string

Columns returns the names of the columns. The number of columns of the result is inferred from the length of the slice. If a particular column name isn't known, an empty string should be returned for that entry.

func (Rows) Next

func (r Rows) Next(dest []driver.Value) error

Next is called to populate the next row of data into the provided slice. The provided slice will be the same size as the Columns() are wide.

Next should return io.EOF when there are no more rows.

The dest should not be written to outside of Next. Care should be taken when closing Rows not to modify a buffer held in dest.

type Stmt

type Stmt struct {
	// contains filtered or unexported fields
}

Stmt ...

func (Stmt) Close

func (s Stmt) Close() error

Close closes the statement.

As of Go 1.1, a Stmt will not be closed if it's in use by any queries.

Drivers must ensure all network calls made by Close do not block indefinitely (e.g. apply a timeout).

func (Stmt) Exec deprecated

func (s Stmt) Exec(args []driver.Value) (driver.Result, error)

Exec executes a query that doesn't return rows, such as an INSERT or UPDATE.

Deprecated: Drivers should implement StmtExecContext instead (or additionally).

func (Stmt) ExecContext

func (s Stmt) ExecContext(ctx context.Context, args []driver.NamedValue) (result driver.Result, err error)

ExecContext ...

func (Stmt) NumInput

func (s Stmt) NumInput() int

NumInput returns the number of placeholder parameters.

If NumInput returns >= 0, the sql package will sanity check argument counts from callers and return errors to the caller before the statement's Exec or Query methods are called.

NumInput may also return -1, if the driver doesn't know its number of placeholders. In that case, the sql package will not sanity check Exec or Query argument counts.

func (Stmt) Query deprecated

func (s Stmt) Query(args []driver.Value) (driver.Rows, error)

Query executes a query that may return rows, such as a SELECT.

Deprecated: Drivers should implement StmtQueryContext instead (or additionally).

func (Stmt) QueryContext

func (s Stmt) QueryContext(ctx context.Context, args []driver.NamedValue) (rows driver.Rows, err error)

QueryContext ...

type SynchronousMode added in v0.32.0

type SynchronousMode = minisql.SynchronousMode

SynchronousMode re-exports the internal type so callers can use it without importing the internal package.

type Tx

type Tx struct {
	// contains filtered or unexported fields
}

Tx ...

func (Tx) Commit

func (tx Tx) Commit() error

Commit ...

func (Tx) Rollback

func (tx Tx) Rollback() error

Rollback ...

Directories

Path Synopsis
benchmarks
cmd/chart command
chart reads Go benchmark output from stdin (or a file) and produces PNG bar charts comparing minisql vs sqlite for each benchmark group.
chart reads Go benchmark output from stdin (or a file) and produces PNG bar charts comparing minisql vs sqlite for each benchmark group.
cmd/report command
report reads Go benchmark output from stdin (or a file) and appends a formatted Markdown table to benchmarks/RESULTS.md (or the file given by the -out flag).
report reads Go benchmark output from stdin (or a file) and appends a formatted Markdown table to benchmarks/RESULTS.md (or the file given by the -out flag).
internal
pkg

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL