m0serve Realtime from a synchronous Python app

SQLite performance notes

Measured findings for SQLite from Mojo: transactions around batch writes, mmap_size for large random reads, json_each for IN lists.

Measured against m0-sqlite on macOS (arm64, Apple system libsqlite3 3.51.0), August 2026. Every number here came from a benchmark in this repo's style — build a real database, run the real bindings, time the real calls. Reproduce with the scripts described at the end.

The short version:

Change Effect Verdict
Batch writes in one explicit transaction 46x on 10k inserts Do this. Nothing else comes close.
PRAGMA mmap_size 40% on random reads past the cache Worth adopting, with a caveat
json_each(?) for variable-length IN lists 6.7x over a per-id loop at N=1000 Use above N≈10
PRAGMA cache_size ~5%, inside noise Skip
Statement cache ~650 ns per prepare Confirms the existing decision not to build one
carray() Unavailable, and unusable from Mojo structs anyway Superseded by m0_array — see below
Aggregating in Mojo instead of in SQL 1.37x on sum+min+max over 200k rows Use when the rows are wanted anyway

Written in parallel with m0_array. This study and the m0_array virtual table landed from two separate sessions that did not see each other's work. They agree on the facts and answer the same question differently: this document establishes that the carray extension cannot be reached from here, and m0_array supplies the capability anyway by registering a Mojo-side virtual table instead of loading a C one. Where they overlap, m0_array is the answer; the analysis below is why it had to be built rather than borrowed.

PRAGMA compile_options on the macOS system library, filtered to what matters:

OMIT_LOAD_EXTENSION        <- no runtime extensions, ever
DEFAULT_MMAP_SIZE=0        <- mmap is off until you ask
MAX_MMAP_SIZE=1073741824   <- and can be raised to 1 GB
DEFAULT_CACHE_SIZE=2000    <- 2000 pages, ~8 MB at the 4 KB page size
THREADSAFE=2               <- multi-thread, NOT serialized
MAX_VARIABLE_NUMBER=500000 <- generous; Linux builds are usually 32766
ENABLE_SETLK_TIMEOUT       <- blocking locks, which busy_timeout uses
ENABLE_FTS5, ENABLE_RTREE, ENABLE_MATH_FUNCTIONS, ENABLE_SESSION
DEFAULT_WAL_AUTOCHECKPOINT=1000

Two of these deserve attention. OMIT_LOAD_EXTENSION means no loadable extension can ever be used on macOS with the system library — that single flag decides the carray question below. And THREADSAFE=2 means the library defaults to multi-thread rather than serialized mode; open_serialized's SQLITE_OPEN_FULLMUTEX still selects per-connection serialization, so that helper works, but the docstring's "serialized by default" describes the upstream default, not this build.

Probed, not assumed: json_each and FTS5 are present; carray and generate_series are not. sqlite3_bind_pointer links; sqlite3_enable_load_extension does not link at all.

carray(), and whether Mojo structs can feed it

Short answer: no, on two independent grounds.

It isn't there. SELECT ... FROM carray(...) fails with no such table: carray on the library we link. carray only became part of the amalgamation in SQLite 3.51.0 and even then it is off unless built with -DSQLITE_ENABLE_CARRAY; before that it was a loadable extension. Loading it at runtime is not an option on macOS, because the system library is compiled OMIT_LOAD_EXTENSION — the symbol sqlite3_enable_load_extension isn't merely disabled, it doesn't exist, and a binary referencing it fails to link. Linux distributions usually do enable loading, so a shipped carray.so could work there, but a feature that exists on one of two supported platforms isn't a feature this package can offer.

Structs wouldn't work if it were. carray binds one flat, homogeneous C array and reads it with a fixed element type: int32, int64, double, char*, or struct iovec. It has no concept of a stride, a field offset, or a record layout. Handing it a List[MyStruct] would make it read the first four or eight bytes of each struct as a value and then step forward by the element size rather than the struct size — garbage, not an error.

What does map cleanly is a flat numeric list:

Mojo carray flag Works?
List[Int] / List[Int64] CARRAY_INT64 Yes — contiguous, 8-byte elements
List[Int32] CARRAY_INT32 Yes
List[Float64] CARRAY_DOUBLE Yes
List[String] CARRAY_TEXT No — needs char*[], and Mojo strings are not NUL-terminated (see c_string)
List[UInt8] blobs CARRAY_BLOB No — needs an array of struct iovec, hand-built
List[SomeStruct] No. No layout guarantee, no stride support

There's a pleasing consequence: the parallel-array (SoA) style this repo already uses — because List[Struct] fights ImplicitlyCopyable — is exactly the layout carray wants. A List[Int] field of an SoA struct is already a bindable C array. So if carray ever becomes available, the data is in the right shape; the blocker is availability, not representation.

sqlite3_bind_pointer does resolve, so the pointer-passing machinery carray depends on is present. If a future toolchain links a libsqlite3 built with SQLITE_ENABLE_CARRAY, wiring it up would be a small change.

The portable substitute: json_each

The problem carray solves is passing N values into one prepared statement when N varies. SQLite has a built-in answer that needs no extension: bind a JSON array as text and unpack it with json_each, which is present here and in every SQLite built since 3.38.

SELECT name, payload FROM items
 WHERE id IN (SELECT value FROM json_each(?1))

Nanoseconds per call, 50k-row table, warm cache:

N loop (one ? per id) rebuilt IN (?,…) json_each json_each, statement reused
1 2,485 2,635 8,490 5,245
10 15,870 9,420 10,055 7,060
50 80,280 17,650 17,680 14,920
200 336,705 57,360 51,895 48,830
1000 1,700,200 315,280 259,600 255,560

Read this as three regimes. At N=1 the plain loop wins and json_each's parsing overhead is pure loss. Around N=10 they cross. From N=50 up the loop is losing badly — 4.5x at 50, 6.7x at 1000 — because it pays a full statement round trip per id.

Rebuilding IN (?,?,…) is competitive but has two problems the table doesn't show: it needs a different prepared statement for every distinct N, which defeats any statement reuse, and it is bounded by MAX_VARIABLE_NUMBER (500k here, but 32766 on typical Linux builds). json_each needs one statement forever.

Building the JSON array is a string concatenation this repo is already equipped for — m0-core's escape_json_string for text ids, plain String(i) for integers.

Pragmas

20,000 random point reads over a 45 MB database (deliberately past the ~8 MB default page cache):

Setting Time
baseline, as open() leaves it 55 ms
PRAGMA mmap_size=536870912 33 ms
PRAGMA cache_size=-131072 (128 MB) 52 ms
both 33 ms

mmap_size is worth 40% and cache_size is worth approximately nothing. That asymmetry makes sense: raising the cache only helps if the working set fits it and the reads repeat, while mmap removes a read() syscall and a buffer copy from every page fetch. On a database that fits entirely in the default cache (a 4 MB, 50k-row table) neither pragma moved the needle at all, which is the other half of the same story.

The caveat on mmap, from SQLite's own documentation: with memory-mapped I/O a disk error that would have been an SQLITE_IOERR becomes a SIGBUS, and a stray pointer write in the process can corrupt the database file instead of being caught. That is a real trade, so open() does not set it. It belongs to the application that knows its storage.

temp_store=MEMORY showed no measurable effect on these workloads — nothing here spilled to a temp file. Set it if you sort large result sets without a supporting index.

Writes: the number that dwarfs everything else

10,000 single-row inserts:

Approach Time
autocommit — one implicit transaction per insert 325 ms
autocommit with PRAGMA synchronous=OFF 125 ms
one explicit begin / commit around the batch 7 ms

Batching is 46x. Weakening durability to synchronous=OFF — which risks database corruption on an OS crash — buys 2.6x, still eighteen times slower than just wrapping the loop in a transaction. This is the single most valuable thing to know about writing to SQLite, and it costs two method calls this package already exposes.

Statement caching, revisited

The decision recorded in ROADMAP.md not to build a statement cache still holds, and now has a second measurement behind it. For the same trivial point query:

  • prepare + step + finalize: 1,651 ns
  • reset + step on a cached statement: 999 ns

So a prepare costs about 650 ns, fixed. Against the 17 µs a 50-row fetch takes, that is under 4% — consistent with the ~10% measured earlier on a smaller query, and not worth the ownership complexity of a cache that has to outlive the statements it hands out. Callers who care can hold their own Statement, which is what the benchmark's fastest column does.

Aggregates: SQL is not automatically the fast side

Statement.fetch_ints was written to hand back a column-major List because that "is what a SIMD pass over the results wants". m0_sqlite.stats_ints and friends are that pass, and measuring them turned up something worth writing down: pulling a column out and reducing it here beats asking SQLite to aggregate it.

200,000 integer rows, in-memory database, sum + min + max, values checked against SQLite's own answers before timing (bench_reduce in bench_sqlite.mojo):

Path Time
SELECT sum(v), min(v), max(v) — in-engine 11.84 ms
fetch_ints + stats_ints 8.63 ms
of which, the reduction itself 0.043 ms

SQLite runs three aggregate steps through its bytecode VM for every row; the read-out runs one column fetch. That is the whole difference — and it is why the vectorization is almost beside the point. The reduction is 0.5% of the pipeline. It is 4x its own scalar loop, and that 4x buys nothing you would notice unless the column is already materialised and you intend to pass over it repeatedly.

Do not read this as "aggregate in Mojo". It is one shape: a full scan, in memory, three aggregates at once, no index, no WHERE. Any of those moving can move the answer past it — a single aggregate halves the VM work, an index can make SQLite skip rows entirely, and a result set larger than memory makes the read-out untenable at any speed. What it does establish is narrower and still useful: when you already need the rows, computing an aggregate from them is not the expensive choice it looks like, and reaching for SQL to avoid a Mojo-side pass is not automatically right.

Integers only, deliberately. A vector sum reassociates the additions, and floating point is not associative — a Float64 version would disagree with a scalar loop in the last ulp with no single right answer to test against. Integer addition reassociates exactly, so these agree bit for bit at every length, which test_reduce.mojo asserts from 0 to 80 elements and against SQLite over 1,000 rows.

The WSGI-bridge techniques, checked against this package

The bridge work (docs/WSGI_PERFORMANCE.md) distilled into a short method: split a cost by part before designing against it; check whether a missing API is missing or merely unbound; eliminate copies; hoist per-call overhead; and measure a cache's hit path before building it. Checked here deliberately, item by item:

technique status here
reach the unbound C API no analog — libsqlite3 is on the link line, external_call already reaches all of it; carray() is absent from the library binary, which no loader trick fixes
replace interpreted work with C calls no analog — there is no interpreter boundary
batch N calls into one (PyDict_Copy) no analog — SQLite has no bulk row API (sqlite3_step is per row; already recorded at the SoA readers)
eliminate copies on the hot path already appliedcolumn_blob memcpy (13–20x), column_blob_into reuse, m0_array borrow-by-shape (3.4x)
measure a cache's hit path first already practiced — the statement cache was rejected twice by measurement
hoist hidden per-call work checked, nothing there — the StringLiteralString conversion every binder pays on its happy path (_check(rc, "bind_int")) measures at 0.0 ns in an optimized build; the compiler hoists it

Two things did come out of the check.

A text scan pays 2.1x for its per-row String, and the zero-allocation read already existed — undocumented. The bench had int, float and blob rows but no TEXT row, so column_text's per-row allocation had never been priced. Now it is (bench_sqlite.mojo, 100k rows, best of 5):

read 64 B 4096 B
column_text (String per row) 111.5 ns/row 971.7 ns/row
fetch_texts 134.9 ns/row 1233.8 ns/row
column_blob_into on the TEXT column 51.9 ns/row 463.7 ns/row

column_blob_into works on TEXT because SQLite converts TEXT to blob bytes on request, and for TEXT stored as UTF-8 that conversion is a pointer handoff. No new API was added — the fix is the two docstrings that now point at each other, per this package's own rule: add the variant the day something needs it, not speculatively.

The verdict on the package: it already embodies the method. The remaining per-row cost of a scan is SQLite's own VM (sqlite3_step), which is the same kind of floor the WSGI bridge just reached — the boundary code is direct calls and single copies, and what is left is the engine itself.

Recommendations

  1. Document batching prominently. A README example that inserts in a loop without a transaction teaches the 46x-slower pattern.
  2. Consider a mmap_size argument or an open_tuned variant rather than changing open(), given the SIGBUS trade. Left undone deliberately: it changes failure modes, so it is your call.
  3. Add a json_each helper if fetching by id-set becomes common — the ergonomics, not the speed, are the reason: building the JSON array by hand at every call site invites an injection-shaped mistake that ? normally prevents.
  4. Do not pursue carray. Unavailable on macOS, unusable with structs, and both m0_array and json_each already cover what motivates it — m0_array for binding a Mojo List directly (~3x on bulk inserts), json_each for an id-set that arrives as data rather than as a live buffer.
  5. Revisit if the toolchain ever links its own SQLite. Building the amalgamation with -DSQLITE_ENABLE_CARRAY -DSQLITE_ENABLE_MATH_FUNCTIONS and a controlled version would make several of these questions moot — at the cost of owning the build.

Reproducing

The benchmarks were written as standalone Mojo programs against -I packages/m0-sqlite, in the same shape as packages/m0-core/run_benchmarks.mojo. They are not checked in; the measurements above are the deliverable. To redo them, the four workloads are: N-row fetch by the four strategies above; 20k random point reads under each pragma set; 10k inserts under each transaction mode; and prepare-versus-reset for one trivial query.