Go WASM Driver
Pure Go driver for Stoolap using WebAssembly. No CGO, no shared libraries, no platform-specific binaries. The full Stoolap engine runs as a WASM module inside your Go process via wazero.
For the native driver, which loads libstoolap as a shared library without CGO, see Go Driver.
When to Use
| Native Driver | WASM Driver | |
|---|---|---|
| Package | github.com/stoolap/stoolap-go |
github.com/stoolap/stoolap-go/wasm |
| CGO required | No | No |
| Native dependency | libstoolap shared library (bundled for macOS arm64, Linux x64, Windows x64) |
None |
| Threading | Full (parallel queries) | Single-threaded |
| File persistence | Full (automatic maintenance) | Works (manual maintenance) |
| Cross-compile | Needs libstoolap for the target |
Anywhere Go compiles |
| Best for | Production, max throughput | Portability, zero dependencies |
The two drivers expose the same API: positional and named parameters, prepared statements with batches, transactions, chunked row iteration, one-call result sets and database/sql.
Requirements
- Go 1.25+
- A
stoolap.wasmbuilt from Stoolap 0.5.0 or newer.NewEnginereports which export an older binary lacks.
Installation
go get github.com/stoolap/stoolap-go/wasm
A prebuilt stoolap.wasm (5 MB) is included in the module and available on the releases page.
Quick Start
Direct API
package main
import (
"context"
"fmt"
"os"
"github.com/stoolap/stoolap-go/wasm"
)
func main() {
ctx := context.Background()
wasmBytes, _ := os.ReadFile("stoolap.wasm")
engine, _ := wasm.NewEngine(ctx, wasmBytes)
defer engine.Close(ctx)
db, _ := engine.OpenMemory(ctx)
defer db.Close()
db.Exec(ctx, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
db.Exec(ctx, "INSERT INTO users VALUES (1, 'Alice', 30), (2, 'Bob', 25)")
rows, _ := db.Query(ctx, "SELECT id, name, age FROM users ORDER BY id")
defer rows.Close()
for rows.Next() {
var id int64
var name string
var age int64
rows.Scan(&id, &name, &age)
fmt.Printf("id=%d name=%s age=%d\n", id, name, age)
}
}
database/sql Driver
The WASM driver registers as "stoolap-wasm" for use with database/sql. Call SetWASM() once before opening connections:
package main
import (
"context"
"database/sql"
"fmt"
"os"
"github.com/stoolap/stoolap-go/wasm"
)
func main() {
ctx := context.Background()
wasmBytes, _ := os.ReadFile("stoolap.wasm")
wasm.SetWASM(ctx, wasmBytes)
db, _ := sql.Open("stoolap-wasm", "memory://")
defer db.Close()
db.ExecContext(ctx, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
db.ExecContext(ctx, "INSERT INTO users VALUES (1, 'Alice'), (2, 'Bob')")
rows, _ := db.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id")
defer rows.Close()
for rows.Next() {
var id int64
var name string
rows.Scan(&id, &name)
fmt.Printf("id=%d name=%s\n", id, name)
}
}
Connection Strings
| DSN | Description |
|---|---|
memory:// |
In-memory database (unique, isolated instance) |
memory://mydb |
Named in-memory database (same name shares the engine) |
file:///data/mydb |
File-based persistent database (requires NewEngineWithFS) |
See Connection String Reference for all options.
Parameters
Positional parameters are $1, $2, etc. or ?. Named parameters are :name; a name may be used more than once in a statement. A statement uses one style or the other, and every parameter it names must be supplied.
// Direct API, positional
db.ExecParams(ctx, "INSERT INTO users VALUES ($1, $2)", []any{int64(1), "Alice"})
rows, _ := db.QueryParams(ctx, "SELECT * FROM users WHERE id = $1", []any{int64(1)})
// Direct API, named
db.ExecNamed(ctx, "INSERT INTO users VALUES (:id, :name)",
[]sql.NamedArg{sql.Named("id", int64(2)), sql.Named("name", "Bob")})
rows, _ := db.QueryNamed(ctx, "SELECT * FROM users WHERE id = :id",
[]sql.NamedArg{sql.Named("id", int64(2))})
// database/sql
db.ExecContext(ctx, "INSERT INTO users VALUES ($1, $2)", 1, "Alice")
rows, _ := db.QueryContext(ctx, "SELECT * FROM users WHERE id = :id", sql.Named("id", 1))
Parameter values may be nil, int64, int, int32, float64, float32, bool, string, []byte (VECTOR or BLOB) and time.Time. A nil []byte binds as NULL; an empty []byte{} or an empty string binds as an empty value. There is no size limit on a parameter: the WASM arena grows to fit it.
Transactions
// Direct API
tx, _ := db.Begin(ctx)
tx.Exec(ctx, "INSERT INTO users VALUES (1, 'Alice')")
tx.ExecNamed(ctx, "INSERT INTO users VALUES (:id, :name)",
[]sql.NamedArg{sql.Named("id", int64(2)), sql.Named("name", "Bob")})
tx.Commit()
// Snapshot isolation
tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSnapshot})
rows, _ := tx.Query(ctx, "SELECT * FROM users")
// All reads see the same consistent snapshot
tx.Commit()
// Rollback
tx, _ := db.Begin(ctx)
tx.Exec(ctx, "DELETE FROM users")
tx.Rollback() // changes discarded
Commit and Rollback consume the transaction; using it afterwards returns an error.
Prepared Statements
stmt, _ := db.Prepare(ctx, "INSERT INTO users VALUES ($1, $2)")
defer stmt.Close()
// One row per call
stmt.ExecContext(ctx, []any{int64(1), "Alice"})
// Many rows in one WASM call, inside one transaction. A failing row rolls
// back the whole batch.
batch := make([][]any, 1000)
for i := range batch {
batch[i] = []any{int64(i + 2), fmt.Sprintf("user_%d", i+2)}
}
res, _ := stmt.ExecBatch(ctx, batch)
n, _ := res.RowsAffected() // 1000
// Named parameters
upd, _ := db.Prepare(ctx, "UPDATE users SET name = :name WHERE id = :id")
upd.ExecNamed(ctx, []sql.NamedArg{sql.Named("name", "Carol"), sql.Named("id", int64(1))})
In database/sql, a statement prepared on a *sql.Tx runs inside that transaction.
Reading Results
Query returns a *Rows cursor. Rows arrive from the engine in packed chunks (32 rows first, doubling to 256), so a scan crosses the WASM boundary once per chunk rather than once per row and column. Values are decoded in Go when you read them.
rows, _ := db.Query(ctx, "SELECT id, name, score FROM users")
defer rows.Close()
for rows.Next() {
var id int64
var name string
var score float64
rows.Scan(&id, &name, &score)
}
if err := rows.Err(); err != nil {
// the error that ended iteration
}
Scan accepts *int64, *float64, *string, *bool, *time.Time, *[]byte, the sql.Null* types and *any. The destination count must match the column count. Scanning a non-text column into a string gives the engine’s own text form, the same as CAST(x AS TEXT). A TEXT column in a recognised timestamp form scans into time.Time; anything else into time.Time is an error.
Typed accessors read the current row without a Scan: rows.IsNull(i), rows.Int64(i), rows.Float64(i), rows.Bool(i) and rows.Timestamp(i). They convert between numeric types the way the engine does.
One-Call Result Sets
When the whole result is wanted at once, QueryAll fetches it in a single WASM call. There is no cursor to iterate or close. The database/sql driver takes this path for every query.
rs, _ := db.QueryAll(ctx, "SELECT id, name FROM users WHERE id > $1", []any{int64(10)})
fmt.Println(rs.Columns) // [id name]
for _, row := range rs.Rows {
fmt.Printf("id=%v name=%v\n", row[0], row[1])
}
// Named, prepared and transactional forms
rs, _ = db.QueryNamedAll(ctx, "SELECT * FROM users WHERE id = :id", []sql.NamedArg{sql.Named("id", int64(1))})
rs, _ = stmt.QueryAll(ctx, []any{int64(1)})
rs, _ = tx.QueryAll(ctx, "SELECT COUNT(*) FROM users", nil)
rows.FetchAll() does the same for the rows a cursor has not yet returned.
File Persistence
Create an engine with filesystem access for persistent databases:
engine, _ := wasm.NewEngineWithFS(ctx, wasmBytes, "/path/to/data")
db, _ := engine.Open(ctx, "file:///data/mydb")
The directory is mounted at /data inside the WASM sandbox. Use file:///data/... paths in the DSN.
Manual Maintenance
WASM does not support background threads, so the automatic checkpoint cycle and cleanup do not run. You must call these commands periodically:
// Checkpoint: seal hot rows to cold volumes, persist manifests, truncate WAL.
// Without this, all data stays in the hot buffer and WAL grows unbounded.
db.Exec(ctx, "PRAGMA checkpoint")
// Cleanup: remove deleted rows, old versions, compact indexes
db.Exec(ctx, "VACUUM")
// Backup: create a full .bin snapshot for disaster recovery
db.Exec(ctx, "PRAGMA snapshot")
// Statistics: update optimizer cost estimates
db.Exec(ctx, "ANALYZE my_table")
Important: PRAGMA checkpoint is the most critical command for file-based WASM databases. Without it, data stays in the hot buffer (high memory) and the WAL grows indefinitely. Call it periodically (e.g., every 60 seconds or after bulk writes).
hot_max_bytes does not hold commits back on WASM. Natively a commit over the limit waits for the background seal, which WASM does not have, so the hot buffer only shrinks when PRAGMA checkpoint runs.
For production file-based workloads with automatic background maintenance, use the native driver.
Type Mapping
| SQL Type | Go Type | Nullable Go Type |
|---|---|---|
| INTEGER | int64 |
sql.NullInt64 |
| FLOAT | float64 |
sql.NullFloat64 |
| TEXT | string |
sql.NullString |
| BOOLEAN | bool |
sql.NullBool |
| TIMESTAMP | time.Time |
sql.NullTime |
| JSON | string |
sql.NullString |
| VECTOR/BLOB | []byte |
[]byte (nil for NULL) |
Thread Safety
All WASM operations are serialized through a mutex. The engine is safe for concurrent use from multiple goroutines, but operations execute sequentially.
- Engine: Thread-safe (mutex-protected)
- DB, Tx, Stmt: Use from any goroutine (engine serializes access)
- Rows: One goroutine at a time; the cursor and its decoded chunk live in Go memory
- database/sql: Thread-safe by default (connection pool creates cloned handles)
Building the WASM Binary from Source
Requires: Rust toolchain, wasm32-wasip1 target, and binaryen (for wasm-opt).
# Install WASI target
rustup target add wasm32-wasip1
# Build (from the stoolap engine repo)
cd stoolap
cargo build --profile max --target wasm32-wasip1 --features ffi --no-default-features
# Optimize (31 MB -> 5 MB)
wasm-opt -Oz target/wasm32-wasip1/max/stoolap.wasm -o stoolap.wasm
Performance
Every WASM call costs a boundary crossing, so the driver makes as few as it can:
- Chunked cursors:
Rowsreads packed chunks of up to 256 rows per call and decodes them in Go. Scanning 1000 rows of four columns takes about 80us, against 370us when each column was a call. - One-call results:
QueryAlland thedatabase/sqldriver receive the whole result packed from a single call, with no rows handle to open or close. - Batches:
ExecBatchsends every row of a batch in one call inside one transaction. - CallWithStack: A pre-allocated call stack eliminates per-call allocations.
- Arena allocator: SQL text and parameters are written into a pre-allocated WASM memory region with no malloc or free per operation. The arena grows when an operation needs more than 256 KB.
Read-heavy workloads (SELECT, aggregation, JOINs) perform close to the native driver. Write-heavy workloads have roughly 1.5 to 2x overhead from the boundary crossing per operation; batches remove most of it.
Direct API Reference
Engine
| Method | Description |
|---|---|
NewEngine(ctx, wasmBytes) |
Create engine for in-memory databases |
NewEngineWithFS(ctx, wasmBytes, rootDir) |
Create engine with filesystem access |
engine.Close(ctx) |
Release all resources |
engine.Version(ctx) |
Get engine version string |
engine.Open(ctx, dsn) |
Open database by DSN |
engine.OpenMemory(ctx) |
Open new in-memory database |
DB
| Method | Description |
|---|---|
db.Close() |
Close the connection |
db.Clone(ctx) |
Clone handle (shares engine) |
db.Exec(ctx, query) |
Execute without parameters |
db.ExecParams(ctx, query, args) |
Execute with positional parameters |
db.ExecNamed(ctx, query, args) |
Execute with named parameters |
db.Query(ctx, query) |
Query without parameters |
db.QueryParams(ctx, query, args) |
Query with positional parameters |
db.QueryNamed(ctx, query, args) |
Query with named parameters |
db.QueryAll(ctx, query, args) |
Whole result in one call, as a ResultSet |
db.QueryNamedAll(ctx, query, args) |
Same with named parameters |
db.Prepare(ctx, query) |
Create prepared statement |
db.Begin(ctx) |
Begin transaction (Read Committed) |
db.BeginTx(ctx, opts) |
Begin transaction with options |
Rows
| Method | Description |
|---|---|
rows.Next() |
Advance to next row |
rows.Scan(dest...) |
Read current row |
rows.Err() |
Error that ended iteration |
rows.Columns() |
Get column names |
rows.IsNull(i), rows.Int64(i), rows.Float64(i), rows.Bool(i), rows.Timestamp(i) |
Read one column of the current row |
rows.FetchAll() |
Remaining rows as [][]any in one call |
rows.Close() |
Close result set |
ResultSet
| Field | Description |
|---|---|
rs.Columns |
Column names |
rs.Rows |
Every row as []any |
Stmt
| Method | Description |
|---|---|
stmt.ExecContext(ctx, args) |
Execute with positional parameters |
stmt.ExecNamed(ctx, args) |
Execute with named parameters |
stmt.ExecBatch(ctx, rows) |
Execute once per row in one transaction |
stmt.QueryContext(ctx, args) |
Query with positional parameters |
stmt.QueryNamed(ctx, args) |
Query with named parameters |
stmt.QueryAll(ctx, args) |
Whole result in one call |
stmt.QueryNamedAll(ctx, args) |
Same with named parameters |
stmt.Close() |
Destroy the statement |
Tx
| Method | Description |
|---|---|
tx.Exec(ctx, query) |
Execute within transaction |
tx.ExecParams(ctx, query, args) |
Execute with positional parameters |
tx.ExecNamed(ctx, query, args) |
Execute with named parameters |
tx.Query(ctx, query) |
Query within transaction |
tx.QueryParams(ctx, query, args) |
Query with positional parameters |
tx.QueryNamed(ctx, query, args) |
Query with named parameters |
tx.QueryAll(ctx, query, args) |
Whole result in one call |
tx.QueryNamedAll(ctx, query, args) |
Same with named parameters |
tx.Commit() |
Commit |
tx.Rollback() |
Rollback |