Skip to content

Instantly share code, notes, and snippets.

@fzakaria
Created August 20, 2026 18:24
Show Gist options
  • Select an option

  • Save fzakaria/8fe754ee7db752a24cb9c55b38492844 to your computer and use it in GitHub Desktop.

Select an option

Save fzakaria/8fe754ee7db752a24cb9c55b38492844 to your computer and use it in GitHub Desktop.
SQLite compiled to WebAssembly, running inside the Nix evaluator via builtins.wasm — arbitrary SQL against a database read through Nix host functions

SQLite inside the Nix evaluator, via builtins.wasm

A real SQLite, compiled to WebAssembly, running inside Nix evaluation and answering arbitrary SQL against a database it never opens as a file.

# query.nix
builtins.wasm { path = ./sqlite_nix.wasm; } {
  db  = ./index.db;
  sql = "SELECT version, rev FROM versions
         WHERE attr = 'hello' ORDER BY version";
}
$ nix eval --extra-experimental-features wasm-builtin -f query.nix
[ { rev = 728; version = "2.10"; } { rev = 822; version = "2.12"; }
  { rev = 1369; version = "2.12.1"; } { rev = 1486; version = "2.12.2"; }
  { rev = null; version = "2.12.3"; } { rev = 0; version = "2.7"; }
  { rev = 13; version = "2.8"; } ]

Takes { db, sql }, returns one attribute set per row keyed by column name, mapping SQLite's types onto make_int / make_float / make_string / make_null. Any SQL works — aggregates, GROUP BY, subqueries:

sql = "SELECT attr, COUNT(*) AS versions FROM versions
       GROUP BY attr ORDER BY versions DESC LIMIT 3";
# => [ { attr = "linux"; versions = 548; }
#      { attr = "linux_latest"; versions = 540; }
#      { attr = "freefall"; versions = 534; } ]

How it works

SQLITE_OS_OTHER=1 removes SQLite's entire VFS layer — no open, no pread, no assumption that a filesystem exists — and requires you to supply your own. The VFS here is read-only and its xRead is a call back into the Nix evaluator:

static int nixRead(sqlite3_file *f, void *buf, int amt, sqlite3_int64 off)
{
    NixFile *p = (NixFile *) f;
    unsigned got = nix_read_file_range(p->pathId, (unsigned long long) off,
                                       buf, (unsigned) amt);
    ...
}

xFileSize calls read_file with a zero-length buffer, which copies nothing and returns the size — the stat() we do not otherwise have. xAccess always answers "no such file", because there is no journal and no WAL. xDeviceCharacteristics returns SQLITE_IOCAP_IMMUTABLE so SQLite skips locking and recovery.

Linking wasi-libc makes the module import wasi_snapshot_preview1, so builtins.wasm runs it in WASI mode: main runs, argv[1] is the ValueId of the argument, and the result goes back through return_to_nix.

Requires a patched Nix

builtins.wasm ships a read_file host function that reads a whole file, but nothing that reads part of one — so SQLite would have to slurp the entire database into linear memory on every call. read_file_range.patch adds a read_file_range host function that does a real positional pread when the path is a genuine file on disk.

Without it you get:

error: unknown import: `env::read_file_range` has not been defined

Apply it to DeterminateSystems/nix-src and build:

$ nix build github:DeterminateSystems/nix-src --max-jobs 0 \
    --extra-substituters https://install.determinate.systems \
    --extra-trusted-public-keys 'cache.flakehub.com-3:hJuILl5sVK4iKm86JzgdXW12Y2Hwd5G07qKtHTOcDCM='

The patch passes Nix's own functional test suite. Note that evaluation is entirely client-side, so none of this needs the Determinate daemon installed — the nix binary on its own is enough.

Building the module

$ nix-build sqlite-wasm.nix

Written up in more detail at https://fzakaria.com/2026/08/19/three-ways-to-smuggle-sqlite-into-nix.

--- a/src/libexpr/primops/wasm.cc
+++ b/src/libexpr/primops/wasm.cc
@@ -1,5 +1,9 @@
#include "nix/expr/primops.hh"
#include "nix/expr/eval-inline.hh"
+#include "nix/util/file-descriptor.hh"
+
+#include <fcntl.h>
+#include <unistd.h>
#include <wasmtime.hh>
#include <boost/unordered/concurrent_flat_map.hpp>
@@ -490,6 +494,41 @@
return contents.size();
}
+
+ /**
+ * Read a range of a file into Wasm memory, starting at `offset` and copying at most `len` bytes. Returns the number
+ * of bytes actually copied.
+ *
+ * Unlike `read_file`, this does not require the module to hold the entire file in linear memory, so a module can
+ * page in a large file (e.g. a SQLite database) instead of slurping all of it on every call.
+ */
+ uint32_t read_file_range(ValueId pathId, uint64_t offset, uint32_t ptr, uint32_t len)
+ {
+ auto & pathValue = getValue(pathId);
+ auto path = state.realisePath(noPos, pathValue);
+
+ auto buf = memory().subspan(ptr, len);
+
+ /* If this is a real file on disk, do a positional read so that we never touch the bytes we were not asked
+ for. */
+ if (auto physical = path.getPhysicalPath()) {
+ AutoCloseFD fd{open(physical->string().c_str(), O_RDONLY | O_CLOEXEC)};
+ if (!fd)
+ throw SysError("opening file '%s'", physical->string());
+ auto n = pread(fd.get(), buf.data(), len, offset);
+ if (n < 0)
+ throw SysError("reading file '%s'", physical->string());
+ return n;
+ }
+
+ /* Otherwise fall back to materialising the whole file. */
+ auto contents = path.readFile();
+ if (offset >= contents.size())
+ return 0;
+ auto n = std::min<size_t>(len, contents.size() - offset);
+ memcpy(buf.data(), contents.data() + offset, n);
+ return n;
+ }
};
template<typename R, typename... Args>
@@ -532,6 +571,7 @@
regFun(linker, "call_function", &NixWasmInstance::call_function);
regFun(linker, "make_app", &NixWasmInstance::make_app);
regFun(linker, "read_file", &NixWasmInstance::read_file);
+ regFun(linker, "read_file_range", &NixWasmInstance::read_file_range);
if (useWasi) {
unwrap(linker.func_wrap(
{ pkgs ? import <nixpkgs> { } }:
let
amalgamation = pkgs.fetchzip {
url = "https://sqlite.org/2026/sqlite-amalgamation-3530300.zip";
hash = "sha256-QgNam6cJkD3hVe8B/EZBapbneu6N23ehrgzy35egCcw=";
};
in
pkgs.pkgsCross.wasi32.stdenv.mkDerivation {
pname = "sqlite-nix-wasm";
version = "0.1";
src = ./.;
buildPhase = ''
runHook preBuild
$CC -O2 -o sqlite_nix.wasm \
-I${amalgamation} \
${amalgamation}/sqlite3.c sqlite_nix.c \
-DSQLITE_OS_OTHER=1 \
-DSQLITE_THREADSAFE=0 \
-DSQLITE_OMIT_LOAD_EXTENSION \
-DSQLITE_OMIT_WAL \
-DSQLITE_OMIT_DEPRECATED \
-DSQLITE_OMIT_PROGRESS_CALLBACK \
-DSQLITE_TEMP_STORE=3 \
-DSQLITE_DEFAULT_MEMSTATUS=0 \
-Wl,--export-memory \
-Wl,--initial-memory=16777216
runHook postBuild
'';
installPhase = ''
mkdir -p $out
cp sqlite_nix.wasm $out/
'';
}
/*
* A real SQLite, compiled to WebAssembly, querying a database that it reads
* through Nix's own host functions.
*
* There is no filesystem here. SQLITE_OS_OTHER=1 strips SQLite's usual VFS
* layer out entirely, and we supply a read-only one whose xRead is a call
* back into the Nix evaluator via `read_file_range`. SQLite's pager thinks it
* is doing ordinary positional reads against a file; every one of them is
* actually a `pread` performed by the host on our behalf.
*/
#include "sqlite3.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
/* ------------------------------------------------------------------ *
* Nix host interface. All of these are imported from the "env" module.
* ------------------------------------------------------------------ */
#define NIX_IMPORT(name) \
__attribute__((import_module("env"), import_name(#name)))
typedef unsigned ValueId;
NIX_IMPORT(read_file) extern unsigned nix_read_file(ValueId path, void *ptr, unsigned len);
NIX_IMPORT(read_file_range) extern unsigned nix_read_file_range(ValueId path, unsigned long long off,
void *ptr, unsigned len);
NIX_IMPORT(get_attr) extern ValueId nix_get_attr(ValueId set, const char *ptr, unsigned len);
NIX_IMPORT(copy_string) extern unsigned nix_copy_string(ValueId v, char *ptr, unsigned max);
NIX_IMPORT(make_int) extern ValueId nix_make_int(long long n);
NIX_IMPORT(make_string) extern ValueId nix_make_string(const char *ptr, unsigned len);
NIX_IMPORT(make_null) extern ValueId nix_make_null(void);
NIX_IMPORT(make_attrset) extern ValueId nix_make_attrset(const void *ptr, unsigned len);
NIX_IMPORT(make_list) extern ValueId nix_make_list(const void *ptr, unsigned len);
NIX_IMPORT(make_float) extern ValueId nix_make_float(double x);
NIX_IMPORT(panic) extern void nix_panic(const char *ptr, unsigned len);
NIX_IMPORT(warn) extern void nix_warn(const char *ptr, unsigned len);
NIX_IMPORT(return_to_nix) extern void nix_return(ValueId v);
/* The attribute layout make_attrset expects: 12 bytes each. */
struct NixAttr {
unsigned name_ptr;
unsigned name_len;
unsigned value_id;
};
static void die(const char *msg)
{
nix_panic(msg, strlen(msg));
__builtin_unreachable();
}
/* ------------------------------------------------------------------ *
* A read-only SQLite VFS backed by Nix's read_file_range.
* ------------------------------------------------------------------ */
/* xOpen only receives a filename string, but what we actually need is the
ValueId of the Nix path. Stash it here for the duration of the open. */
static ValueId g_pathId;
typedef struct NixFile {
sqlite3_file base;
ValueId pathId;
sqlite3_int64 size;
} NixFile;
static int nixClose(sqlite3_file *f)
{
(void) f;
return SQLITE_OK;
}
static int nixRead(sqlite3_file *f, void *buf, int amt, sqlite3_int64 off)
{
NixFile *p = (NixFile *) f;
/* The one line that matters: SQLite's pager asks for a page, and we ask
the Nix evaluator for exactly those bytes. */
unsigned got = nix_read_file_range(p->pathId, (unsigned long long) off, buf, (unsigned) amt);
if (got < (unsigned) amt) {
memset((char *) buf + got, 0, (unsigned) amt - got);
return SQLITE_IOERR_SHORT_READ;
}
return SQLITE_OK;
}
static int nixWrite(sqlite3_file *f, const void *b, int amt, sqlite3_int64 off)
{
(void) f; (void) b; (void) amt; (void) off;
return SQLITE_READONLY;
}
static int nixTruncate(sqlite3_file *f, sqlite3_int64 sz) { (void) f; (void) sz; return SQLITE_READONLY; }
static int nixSync(sqlite3_file *f, int flags) { (void) f; (void) flags; return SQLITE_OK; }
static int nixFileSize(sqlite3_file *f, sqlite3_int64 *pSize)
{
*pSize = ((NixFile *) f)->size;
return SQLITE_OK;
}
static int nixLock(sqlite3_file *f, int l) { (void) f; (void) l; return SQLITE_OK; }
static int nixUnlock(sqlite3_file *f, int l) { (void) f; (void) l; return SQLITE_OK; }
static int nixCheckReservedLock(sqlite3_file *f, int *pRes) { (void) f; *pRes = 0; return SQLITE_OK; }
static int nixFileControl(sqlite3_file *f, int op, void *pArg) { (void) f; (void) op; (void) pArg; return SQLITE_NOTFOUND; }
static int nixSectorSize(sqlite3_file *f) { (void) f; return 4096; }
static int nixDeviceCharacteristics(sqlite3_file *f)
{
(void) f;
/* IMMUTABLE tells SQLite the file cannot change underneath it, so it
skips locking and journal recovery entirely. */
return SQLITE_IOCAP_IMMUTABLE;
}
static const sqlite3_io_methods nixIoMethods = {
.iVersion = 1,
.xClose = nixClose,
.xRead = nixRead,
.xWrite = nixWrite,
.xTruncate = nixTruncate,
.xSync = nixSync,
.xFileSize = nixFileSize,
.xLock = nixLock,
.xUnlock = nixUnlock,
.xCheckReservedLock = nixCheckReservedLock,
.xFileControl = nixFileControl,
.xSectorSize = nixSectorSize,
.xDeviceCharacteristics = nixDeviceCharacteristics,
};
static int nixOpen(sqlite3_vfs *vfs, const char *name, sqlite3_file *f, int flags, int *pOut)
{
(void) vfs; (void) name;
NixFile *p = (NixFile *) f;
memset(p, 0, sizeof(*p));
p->base.pMethods = &nixIoMethods;
p->pathId = g_pathId;
/* read_file with a zero-length buffer copies nothing and returns the
size, which is exactly the stat() we do not otherwise have. */
p->size = nix_read_file(p->pathId, 0, 0);
if (pOut)
*pOut = flags & ~SQLITE_OPEN_READWRITE;
return SQLITE_OK;
}
static int nixDelete(sqlite3_vfs *v, const char *n, int s) { (void) v; (void) n; (void) s; return SQLITE_OK; }
static int nixAccess(sqlite3_vfs *v, const char *n, int flags, int *pRes)
{
(void) v; (void) n; (void) flags;
/* No journal, no WAL, no anything. The database is the only file. */
*pRes = 0;
return SQLITE_OK;
}
static int nixFullPathname(sqlite3_vfs *v, const char *in, int n, char *out)
{
(void) v;
size_t len = strlen(in);
if (len >= (size_t) n)
len = (size_t) n - 1;
memcpy(out, in, len);
out[len] = 0;
return SQLITE_OK;
}
static int nixRandomness(sqlite3_vfs *v, int n, char *out) { (void) v; memset(out, 0, n); return n; }
static int nixSleep(sqlite3_vfs *v, int micro) { (void) v; (void) micro; return 0; }
static int nixCurrentTime(sqlite3_vfs *v, double *p) { (void) v; *p = 2440587.5; return SQLITE_OK; }
static int nixGetLastError(sqlite3_vfs *v, int n, char *p) { (void) v; (void) n; (void) p; return 0; }
static sqlite3_vfs nixVfs = {
.iVersion = 1,
.szOsFile = sizeof(NixFile),
.mxPathname = 1024,
.zName = "nix",
.xOpen = nixOpen,
.xDelete = nixDelete,
.xAccess = nixAccess,
.xFullPathname = nixFullPathname,
.xRandomness = nixRandomness,
.xSleep = nixSleep,
.xCurrentTime = nixCurrentTime,
.xGetLastError = nixGetLastError,
};
/* SQLITE_OS_OTHER=1 means SQLite has no built-in VFS, so it asks us. */
int sqlite3_os_init(void) { return sqlite3_vfs_register(&nixVfs, 1); }
int sqlite3_os_end(void) { return SQLITE_OK; }
/* ------------------------------------------------------------------ *
* Entry point.
*
* Takes { db = <path>; sql = "<any SQL>"; } and returns a list of rows,
* each row an attribute set keyed by column name.
* ------------------------------------------------------------------ */
#define MAX_ROWS 65536
#define MAX_COLS 64
static ValueId g_rows[MAX_ROWS];
static struct NixAttr g_cols[MAX_COLS];
static ValueId getAttr(ValueId set, const char *name)
{
ValueId v = nix_get_attr(set, name, strlen(name));
if (v == 0)
die("argument must be an attrset with 'db' and 'sql'");
return v;
}
/* Copy a Nix string out of the evaluator into a fresh buffer. */
static char *dupNixString(ValueId v, unsigned *lenOut)
{
unsigned len = nix_copy_string(v, 0, 0);
char *buf = malloc(len + 1);
if (!buf)
die("out of memory");
nix_copy_string(v, buf, len);
buf[len] = 0;
if (lenOut)
*lenOut = len;
return buf;
}
int main(int argc, char **argv)
{
if (argc < 2)
die("expected the Nix ValueId as argv[1]");
ValueId arg = (ValueId) strtoul(argv[1], 0, 10);
g_pathId = getAttr(arg, "db");
char *sql = dupNixString(getAttr(arg, "sql"), 0);
sqlite3 *db = 0;
if (sqlite3_open_v2("nixdb", &db, SQLITE_OPEN_READONLY, "nix") != SQLITE_OK)
die("cannot open database");
sqlite3_stmt *stmt = 0;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, 0) != SQLITE_OK)
die(sqlite3_errmsg(db));
int ncol = sqlite3_column_count(stmt);
if (ncol > MAX_COLS)
die("too many columns");
unsigned nrows = 0;
int rc;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW && nrows < MAX_ROWS) {
for (int i = 0; i < ncol; i++) {
const char *name = sqlite3_column_name(stmt, i);
ValueId value;
switch (sqlite3_column_type(stmt, i)) {
case SQLITE_INTEGER:
value = nix_make_int(sqlite3_column_int64(stmt, i));
break;
case SQLITE_FLOAT:
value = nix_make_float(sqlite3_column_double(stmt, i));
break;
case SQLITE_NULL:
value = nix_make_null();
break;
default: {
const char *txt = (const char *) sqlite3_column_text(stmt, i);
value = nix_make_string(txt, (unsigned) sqlite3_column_bytes(stmt, i));
break;
}
}
g_cols[i].name_ptr = (unsigned) (unsigned long) name;
g_cols[i].name_len = (unsigned) strlen(name);
g_cols[i].value_id = value;
}
g_rows[nrows++] = nix_make_attrset(g_cols, (unsigned) ncol);
}
if (rc != SQLITE_DONE && rc != SQLITE_ROW)
die(sqlite3_errmsg(db));
sqlite3_finalize(stmt);
sqlite3_close(db);
nix_return(nix_make_list(g_rows, nrows));
return 0;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment