Skip to content

SQLite

Everything in Talking to a database is written once and runs against Postgres or SQLite. This page is what changes: one line of wiring, the one question SQLite makes you answer, and the five things it refuses.

Swap two lines and the rest of the guide is unchanged:

const Db = sql.Sqlite(.{ .threading = .{ .hop = nilo } });

var db = Db.init(gpa, "/var/lib/app/shop.db", .{ .size = 5 });
defer db.deinit();
db.checking(.{ .tables = &.{ User, Order } });
try app.provide(&db);

Your handler does not change at all — it still takes db: *Db and calls db.find, db.select, db.begin. That is the point: the driver was always behind a seam, and SQLite is the second thing to come through it.

A whole program on one file, tables made at boot, a page with a parent, a customer with their children, a report of grouped Rows and a transaction, is examples/sqlite/: zig build run-sqlite.

The one question it makes you answer

.threading has no default, and leaving it out is a compile error that explains itself. That is deliberate, and the reason is worth thirty seconds.

Everything else nilo talks to is on a socket. When a request waits for Postgres, the fiber parks and its thread goes and serves somebody else — that is what the whole event loop is for. SQLite is not on a socket. It is a library reading a file, so a statement is a function call that returns when it returns, and there is no wait for the loop to park on. Somebody has to decide what happens to the thread meanwhile, and nobody but you knows what your statements look like:

.threading = .{ .hop = nilo }   // hand it to the Engine's thread pool
.threading = .in_fiber          // run it right here

.hop costs a few microseconds per statement and no statement can stall a thread that is serving other connections. .in_fiber skips that cost, and is faster when every statement is a primary-key lookup out of the page cache — until the day one of them scans a big table, at which point every connection assigned to that executor thread waits behind it.

Take .hop unless you have measured otherwise. Its bad case is microseconds; the other one's is a stalled thread. (nilo — the whole module — is the payload because sql/ is not allowed to import the server. That is the layering rule, and it is a build step rather than a convention.)

One writer, and readers beside it

.size = 5 is one writer and four readers, and that is SQLite rather than a knob: one connection may write at a time, and under WAL — which every connection here is primed with — readers carry on while it does.

So writes queue. They queue on a lock that parks the fiber rather than holding its thread, which is the one thing the event loop is still good for here, and a write that waits is a wait rather than a SQLITE_BUSY you have to interpret. If two of them queue for five seconds you get error.Locked — busy_timeout_ms is the number, and it is only reachable from another process on the same file, since inside one process there is exactly one writer and it takes its turn.

Which connection a statement travels down is decided by its first keyword: SELECT and PRAGMA take a reader, everything else takes the writer. For every statement this module writes that is exact. For db.raw it is a guess, and the guess is made safe by opening readers read-only — a raw that writes and looks like a read is refused loudly instead of reading a stale snapshot.

Losing power

Every connection gets synchronous = NORMAL, which is what SQLite recommends for application use: the database cannot corrupt, and what a power cut can lose is the most recent transactions. If losing a committed transaction is not survivable, it is one word:

sql.Sqlite(.{ .threading = .{ .hop = nilo }, .synchronous = .full })

That is not free and the gap is an fsync rather than anything in SQLite or nilo — on the machine bench/result/sql.md §9.5 ran on it was 54× per autocommitted insert. Measure it on yours before deciding; the number belongs to your disk.

OFF is not offered. It is the setting where corruption is possible, and no default here should make it reachable by accident.

What SQLite will not do

Five things, each a compile error that names the dialect rather than a runtime surprise:

db.insertMany, db.updateMany SQLite has no array parameter, and the batch form it does have grows the statement text with the batch — which stops it being a constant. Write a row at a time inside one db.begin; there is no round trip to pay per statement, so it is cheaper than it sounds
.lock = .update writers are serialised by a lock over the whole database. There is no row to hold against anybody
tx.deadline(ms) a deadline has to be enforced by the database, and there is no server. busy_timeout_ms covers the case that actually happens
a []const T column no array type. A list belongs in its own table, or in a TEXT column your own code encodes
.isolation below .serializable SQLite gives every transaction a snapshot and serialises the writers. There is nothing weaker to ask for
.like, .not_like, .contains, .starts_with, .ends_with its LIKE folds ASCII case and cannot be told not to by a statement, so a case-sensitive match would depend on how the file was opened. Each Refusal names the folding spelling — .ilike, .icontains — which is what this database does (ADR 055)

So a program that batches does not compile against both. That is the seam refusing rather than quietly doing something else, and it is worth knowing before you plan a migration on the assumption that swapping the line at the top is free.

One operator moves the other way. .ilike is Postgres's word for what SQLite's LIKE already does — fold ASCII case — so on SQLite it is spelled LIKE, the same one-word swap icontains makes. It used to be written ILIKE on both and came back a syntax error here; nothing could have depended on that. .like went the other way for a while — it compiled here and folded, on this database only — and a program that wrote it and wanted the folding writes .ilike now, which is the one letter the Refusal names.

A sql.Uuid is not on that list. SQLite has no uuid type, so one travels as the thirty-six hyphenated characters into a TEXT column — which is what sqlite3 shows you and what WHERE public = '…' takes. Postgres still sends sixteen bytes. Your Row says public: sql.Uuid either way, and neither the insert nor the read changes (ADR 067).

A sql.Json(T) column, an enum column and .in are not on it either, and for a while they were on it in practice without being written down: SQLite has no jsonb and no enum type, so each of the three binds as text, and .in binds its whole list as one JSON array that json_each reads. Your Row and your condition are the same on both (ADR 067). .in is the one that costs something here — one arena allocation per condition, on SQLite only — because the array has to be written out where Postgres sends a native one.

The schema check is weaker here too, and by exactly as much as SQLite is. A column's declared type is free text — VARCHAR(255), NVARCHAR and CLOB are all one thing to the database — so the check catches a Str field over an INTEGER column and does not catch an i32 over a column holding values too big for it.

Raw SQL on this file

A raw statement's text is yours, and the two things that trip a program written against the Postgres examples are these. The raw page has the longer list.

$1, $2, … mean the same here. SQLite's own numbered placeholder is ?1, and $name there is a named parameter indexed by first appearance, so WHERE ($2 IS NULL OR x = $2) with no $1 before it used to take the first value. nilo respells $n as ?n while compiling, for every call that takes comptime text, so a statement written for Postgres binds by number here too and one text serves both (ADR 204). db.exec takes run-time text and sends it as written; its statements are DDL, which has no parameters.

Dates out of a Timestamp

A sql.Timestamp is stored as an INTEGER of microseconds since the epoch, and SQLite's date functions read seconds. So a report by month divides first and says which epoch:

const MonthLine = struct {
    pub const nilo_table = .projection;

    month: nilo.Str,
    orders: i64,
};

const by_month = try db.raw(MonthLine, c,
    "SELECT strftime('%Y-%m', created_at / 1000000, 'unixepoch') AS month, count(*) " ++
    "FROM orders GROUP BY 1 ORDER BY 1",
    .{},
);

date(created_at / 1000000, 'unixepoch') is the day, and created_at >= strftime('%s', 'now', '-30 days') * 1000000 is a window compared in the column's own unit, so the index on the column is still used. Postgres's spelling of the first is to_char(date_trunc('month', created_at), 'YYYY-MM'); a nilo.Str field takes either.

Two things about the filename

A bare :memory: is refused when you open it. A pool of them would be several separate empty databases: writes going to one, reads finding nothing. The shared form is one database and is what to write:

"file:test?mode=memory&cache=shared"     // lives as long as a connection to it

And a test that cares about read-only enforcement has to use a file. SQLite's URI mode= takes precedence over the flags a connection is opened with, so a reader on an in-memory database can write, where the same reader on a file cannot.

What it costs

523,352 bytes to a program that names sql.Sqlite, and zero to one that does not — the driver is fetched lazily and sql/sqlite.zig is only analysed when something names it, so a Postgres-only binary carries no SQLite at all. A pool connection holds 28 KiB when opened and grows towards cache_size as it touches pages; the 2 MiB default bought nothing at either shape that was measured, so lowering cache_kib is close to free for a service that scans.