> blog // engineering

One SQL contract, five drivers

September 27, 2026 · updated September 28, 2026 · lib/e/db.e, lib/x/, benchmarks/db

Neper's standard library has had a database contract, e.db, for a while, with nothing behind it. Today it has five drivers: SQLite, PostgreSQL, MySQL, ODBC and SQL Server. Four bind a C library the vendor or the operating system provides. The fifth speaks SQL Server's own protocol, TDS, inside TLS 1.3, in plain Neper. Each is tested against a live database on Windows and Linux. This post walks through the contract, shows how to write a driver, explains what differs between the five, and ends with numbers: the same workload in Neper, C, Go and Rust, and which one wins.

This post first appeared with three drivers. The fourth, x.microsoft.odbc, landed later the same day. The fifth, x.microsoft.tds, came the day after, and it needed Neper's AES-GCM made 30–60 times faster first. The first four databases' numbers are from the September 28 run; SQL Server's were measured later that day on the same machine.

The contract

e.db is deliberately small. It defines four handles, one table of functions, a value type and five errors:

type Connection = struct { ctx: *void, driver: *const Driver }
type Statement = struct { ctx: *void, driver: *const Driver }
type Rows = struct { ctx: *void, driver: *const Driver }
type Transaction = struct { ctx: *void, driver: *const Driver }
type Value = union enum u8 { Null, Bool: bool, I64: i64, U64: u64, F64: f64, Text: str, Bytes: []const u8, Time: time.Instant }
type Parameter = struct { name: str, value: Value }
type Column = struct { name: str, kind: ValueKind, nullable: bool }
type Driver = struct { close: fn(ctx: *void) -> err, prepare: ..., execute: ..., query: ..., begin: ...,
                       statement_close: ..., statement_execute: ..., statement_query: ...,
                       rows_columns: ..., rows_next: fn(ctx: *void, dst: []Value) -> (bool, err), rows_next_borrowed: ..., rows_close: ...,
                       transaction_execute: ..., transaction_query: ..., transaction_commit: ..., transaction_rollback: ... }
error Closed
error InvalidQuery
error Constraint
error Busy
error Unsupported

Using it

This is examples/db/quickstart, in full:

use e.io
use e.mem
use e.db
use x.sqlite.sqlite

fn main(a: *mem.Arena) -> err {
    var c = try sqlite.open(a, ":memory:")
    let created = try db.execute(&c, "CREATE TABLE lang(name TEXT NOT NULL, year INTEGER)", zero)

    var tx = try db.begin(&c)
    var p: [2]db.Parameter = zero
    p[0] = db.Parameter { name: "", value: db.Value{ Text: "Neper" } }
    p[1] = db.Parameter { name: "", value: db.Value{ I64: 2026i64 } }
    let inserted = try db.execute_transaction(&tx, "INSERT INTO lang VALUES (?, ?)", p[0..])
    try db.commit(&tx)

    var rows = try db.query(&c, "SELECT name, year FROM lang", zero)
    var row: [2]db.Value = zero
    while true {
        let more = try db.reader_next_err(&rows, row[0..])
        if !more { break }
        switch row[0] {
        case .Text as name:
            try io.printf["{}\n"](name)
        default:
            ret db.InvalidQuery
        }
    }
    try db.close_rows(&rows)
    ret db.close(&c)
}

Replace the open line with libpq.open(a, "host=… dbname=…"), mysql.open(a, options) or odbc.open(a, "DSN=…") and the rest of the program is unchanged. The one exception is PostgreSQL's parameter spelling, $1 rather than ?.

Writing a driver

A driver is three things: a context struct, fifteen functions with the table's signatures, and an open that fills in the table. examples/db/memory_driver is a complete driver in about 170 lines that keeps its rows in memory. It is worth reading whole, and it comes down to four steps.

1. A context behind every handle. The driver decides what ctx points at. A real driver keeps its library handle there; this one keeps rows.

type Store = struct { ids: [64]i64, names: [64]str, count: usize, committed: usize,
                      in_transaction: bool, cursor: usize, columns: [2]db.Column }

fn store_of(ctx: *void) -> *Store { ret mem.cast[*Store](ctx) }

2. The functions. Each receives the context and answers in e.db's terms. The row reader is the interesting one:

// Committed rows are visible outside a transaction; inside one, all of them.
fn m_rows_next(ctx: *void, dst: []db.Value) -> (bool, err) {
    let s = store_of(ctx)
    if dst.len < 2usize { ret (false, db.InvalidQuery) }
    var visible = s.committed
    if s.in_transaction { visible = s.count }
    if s.cursor >= visible { ret (false, ok) }
    dst[0] = db.Value{ I64: s.ids[s.cursor] }
    dst[1] = db.Value{ Text: s.names[s.cursor] }
    s.cursor += 1usize
    ret (true, ok)
}

3. Hand back only the context. A Rows, Statement or Transaction that a driver returns carries just its context. e.db copies in the table from the connection it came from, so a driver never passes its own table around.

fn m_query(ctx: *void, sql: str, params: []const db.Parameter) -> (db.Rows, err) {
    var rows: db.Rows = zero
    if !str.eq(sql, "SELECT id, name FROM t") { ret (rows, db.InvalidQuery) }
    store_of(ctx).cursor = 0usize
    rows.ctx = ctx
    ret (rows, ok)
}

4. Fill the table once and hand out the connection.

fn open(s: *Store, driver: *db.Driver) -> db.Connection {
    driver.close = m_close
    driver.execute = m_execute
    driver.query = m_query
    driver.rows_next = m_rows_next
    driver.rows_next_borrowed = m_rows_next   // its rows are already its own
    // ... the other eleven
    ret db.Connection { ctx: mem.cast[*void](s), driver: driver }
}

Binding a C library

The four drivers that bind a C library follow one layout. A small module holds nothing but the C declarations, one variant file per OS. The driver module next to it is portable Neper and never names a library. (The SQL Server driver binds nothing; it is one portable module over e.net.)

// lib/x/postgresql/capi.linux.e   (capi.windows.e is identical but for "libpq.dll")
@import("libpq.so.5", "PQsendQueryParams")
extern fn send_query_params(conn: usize, command: *const u8, count: i32, types: usize,
                            values: usize, lengths: usize, formats: usize, result_format: i32) -> i32

@import("libpq.so.5", "PQgetvalue")
extern fn getvalue(result: usize, row: i32, column: i32) -> *u8

SQLite: x.sqlite.sqlite

PostgreSQL: x.postgresql.libpq

MySQL: x.oracle.mysql

ODBC: x.microsoft.odbc

See the package specification for the full value and parameter rules.

SQL Server: x.microsoft.tds

See the package specification for the full value and parameter rules.

How they are tested

There are six fixtures, each with one exit code per check:

Each suite run starts throwaway servers with fresh data directories: PostgreSQL 18.6 and MySQL 8.4 on Windows, and the Ubuntu 24.04 packages (PostgreSQL 16, MySQL 8.0) on Linux. ODBC reaches the same PostgreSQL through psqlODBC 17.00 on Windows and psqlODBC 16.00 with unixODBC 2.3.12 on Linux. SQL Server is 2025 Developer on both: a local instance on Windows, and Microsoft's mssql-server package in WSL, each with a self-signed certificate that the tests pin. We also broke each driver on purpose to confirm the fixtures notice: a wrong numeric digit, a wrong SQLSTATE mapping, the busy guard, the statement splitter and the quote lexer were each caught with a distinct exit code. In the SQL Server driver, so were three planted defects: a null bitmap read from the wrong end, a ? inside a bracketed name taken for a placeholder, and a SELECT's row count taken for affected rows.

Performance

Three phases run through e.db by benchmarks/db:

The same work is written four times:

Go and Rust speak the PostgreSQL and MySQL wire protocols themselves rather than through libpq and libmysql, and for MySQL they use server-side prepared statements where Neper and C render text. All four languages reach ODBC through the same driver manager and the same psqlODBC, against the same PostgreSQL server the libpq rows use. For SQL Server, Neper, Go and Rust each speak TDS themselves and C goes through Microsoft's driver; all four encrypt the whole session and check the server's certificate against the same pinned one. Neper's AES runs in portable code, while OpenSSL, Go and rustls use the CPU's AES instructions.

Each round ran every language once, alternated, 9 rounds after a warm-up, and the tables report medians. N is 100,000 rows and 20,000 lookups for SQLite, and 20,000 rows and 5,000 lookups for the servers and ODBC. Everything ran on one laptop, a Core i5-12500H, under Windows 11 and under WSL2 Ubuntu 24.04, with the Neper programs built by the compiler at 03165098 (the SQL Server one from the x.microsoft.tds branch, whose compiler is unchanged). The servers were local and throwaway (PostgreSQL with fsync=off, MySQL and SQL Server at their defaults), and SQLite wrote a file with its default rollback journal.

Other work shared the machine, so the absolute speeds moved between runs, and Windows moved far more than Linux. On Linux the interquartile spread was 2–23% for scans and up to 55% for MySQL's lookups, and 23–62% for SQL Server. On Windows it was 12–99% for almost every cell, and more for SQLite inserts and SQL Server's scans. Each cell below is therefore each language's median as a percentage of the fastest median in the same round, where 100% means it won the cell; read the Windows cells as rough.

The verdict: C, then Rust, then Neper, then Go.

Scored against the fastest in each of the 30 cells (3 workloads × 5 databases × 2 hosts), the geometric means are C 95%, Rust 85%, Neper 73%, Go 65%. C wins 17 cells, Rust 9, Go 3 and Neper 1. Counting places instead of percentages, Neper is most often third (13 of 30) and last in 8, more than Rust (6) and half as often as Go (16). SQL Server moved Rust up and Neper down: Rust wins five of its six cells, and Neper's SQL Server scans, at 28–35% of the fastest, are its weakest cells anywhere. Neper's largest gap is still in one workload: across both hosts it averages 86% of the fastest on inserts, 78% on lookups and 58% on scans.

1st2ndlastin each row; in the Neper · C tables, the faster of the two is green and the slower red

Windows 11NeperCGoRust
SQLite insert88%100%26%70%
SQLite scan55%100%24%92%
SQLite lookup86%98%66%100%
PostgreSQL insert96%100%79%79%
PostgreSQL scan100%93%97%57%
PostgreSQL lookup88%100%79%72%
MySQL insert65%71%100%48%
MySQL scan62%95%100%92%
MySQL lookup65%74%100%52%
ODBC insert91%100%67%83%
ODBC scan75%100%63%94%
ODBC lookup74%100%44%92%
SQL Server insert74%87%75%100%
SQL Server scan35%91%44%100%
SQL Server lookup67%86%58%100%
Geometric mean73%93%63%80%
Overall3rd1st4th2nd
Linux (WSL2)NeperCGoRust
SQLite insert86%98%57%100%
SQLite scan56%100%23%76%
SQLite lookup73%100%64%96%
PostgreSQL insert98%100%56%93%
PostgreSQL scan63%100%87%46%
PostgreSQL lookup97%100%59%96%
MySQL insert79%95%94%100%
MySQL scan83%100%94%98%
MySQL lookup70%86%77%100%
ODBC insert98%100%93%95%
ODBC scan65%100%57%96%
ODBC lookup84%100%90%95%
SQL Server insert96%94%79%100%
SQL Server scan28%100%62%97%
SQL Server lookup86%89%71%100%
Geometric mean74%97%67%91%
Overall3rd1st4th2nd

The raw medians and every sample are in benchmarks/db/results/.

Windows 11Insert rows/s
Neper · C
Scan rows/s
Neper · C
Lookup µs
Neper · C
SQLite 3.511,109,527 · 1,254,2314,168,438 · 7,642,33923.3 · 20.3
PostgreSQL 18.626,567 · 27,7594,487,021 · 4,194,01539.8 · 35.1
MySQL 8.4.99,427 · 10,2401,645,468 · 2,496,723102.6 · 90.2
psqlODBC 17.007,004 · 7,736698,200 · 932,76681.1 · 60.0
SQL Server 20255,062 · 5,913584,735 · 1,532,121208.1 · 162.4
Linux (WSL2)Insert rows/s
Neper · C
Scan rows/s
Neper · C
Lookup µs
Neper · C
SQLite 3.451,228,694 · 1,389,1455,230,692 · 9,280,3785.8 · 4.2
PostgreSQL 1621,233 · 21,7183,962,307 · 6,337,51148.5 · 47.1
MySQL 8.0.467,506 · 9,0402,397,467 · 2,905,679113.1 · 92.9
psqlODBC 16.008,850 · 9,0331,023,769 · 1,584,46771.1 · 59.7
SQL Server 20253,947 · 3,870556,809 · 1,976,768270.9 · 259.8

Scans are still where Neper loses. Neper reaches 55–83% of the fastest scan on seven of the eight database-host pairs of the four C-library drivers; on Linux, where the scan spread is 2–23%, that gap is real. (SQL Server's scans, lower still, are a different story, told below.) SQLite shows it plainest, at 55–56% of C on both hosts:

The exception is PostgreSQL on Windows, where Neper wins the scan outright: 4.49 million rows per second against C's 4.19 million. That is the chunked reads (D1606). Windows has libpq 18.6, so the driver takes 256 rows per result. WSL has libpq 16, which has no chunked mode, so Neper falls back to a row at a time there and scans at 63% of C, while the C baseline still reads a row at a time on both hosts. ODBC has no borrowed form at all, so the driver copies every value, and its scans sit at 65–75%.

ODBC costs every language. Over the same PostgreSQL server, C's scan through psqlODBC is 4–4.5 times slower than C's scan through libpq, and its lookups are 27–71% slower. What ODBC buys is reach, not speed. Within ODBC, Neper's inserts are at 91–98% of C and its lookups at 74–84%.

Inserts and lookups are close contests, except on MySQL. Against a server each statement waits on a loopback round trip, and a lookup takes 35–135 µs, and the language mostly disappears into it: Neper is within 2–12% of C on PostgreSQL inserts and lookups on both hosts. MySQL is the wide one. Go wins all three MySQL cells on Windows and Rust two of three on Linux, while Neper and C trail. Rust and Go use server-side prepared statements over the binary protocol, where Neper and C render each statement as text. That protocol choice explains most of it, though not all: Rust's own MySQL lookups on Windows are the slowest of the four (129 µs). MySQL's insert and lookup cells also spread 26–82% between rounds, so they are the least settled of the lot.

SQL Server is decided by encryption. Every language encrypts the whole session here, and the scan moves about 600 KB of TLS records. Neper decrypts them in portable code at about 23 MB/s, while OpenSSL, Go and rustls use the CPU's AES instructions. That is most of Neper's scan time, and why it reads 557,000–585,000 rows per second against C's 1.5–2.0 million and Rust's 1.7–1.9 million (28–35% of the fastest). Inserts and lookups send small records, so the cipher matters less there: Neper's Linux inserts beat C's, at 96% of Rust's, and its lookups are at 86% on Linux and 67% on Windows. Rust's tiberius wins five of the six SQL Server cells. Before D1645, Neper's AES-GCM ran at 372 KB/s, and this scan read 15,000 rows a second. A program can now hand the record cipher to the host's OpenSSL, and its scans run 3.2–3.6 times faster: see Bring your own OpenSSL. The tables here keep Neper's own cipher, the default.

Go is last overall, mainly on SQLite. Its PostgreSQL scans are strong (87–97%) and it wins every MySQL cell on Windows, but its SQLite numbers (23–66%) and its Windows ODBC lookups (44%) pull its mean to 65%.

C leads, Rust is second. C wins 17 of 30 cells and never falls below 71%. Rust has the second-best mean on both hosts, 91% on Linux and 80% on Windows, where its MySQL numbers (48–52%) and PostgreSQL scan (57%) pull it down. Neper is third on both, at 74% and 73%.

What changed since the first post. On the 18 cells both runs share, Neper rose from 69% to 77%. The changes behind it are the borrowed rows (D1599), a faster byte copy for the copying readers (D1600), the chunked PostgreSQL reads (D1606), and call-aware register allocation (D1607). The largest gains are the PostgreSQL scan on Windows (51% to 100%), the MySQL scans (52–66% to 62–83%), MySQL lookups on Linux (45% to 70%) and SQLite inserts (61–76% to 86–88%). SQLite's scan barely moved, from 52–53% to 55–56%, and a few server cells on Windows fell back within that host's noise. C rose over the same cells, from 86% to 95%, so part of every percentage's movement is the fastest implementation changing, not only Neper closing the gap.

Two bugs the first run found, both fixed.

MySQL's scan first ran at 7–9% of C because e.str.parse_f64 took about 4 µs a call. It now has Clinger's and Eisel-Lemire's fast paths, and push_f64 uses Ryu, with every result unchanged (D1593); that scan now reaches 62–83% of the fastest. A Linux program importing more than about 48 C symbols failed to link, and one declaring more than about 58 externs failed to build on either target. Both limits are lifted (D1594), so one program can use several drivers at once.

Try it

The drivers are in lib/x/sqlite, lib/x/postgresql, lib/x/oracle and lib/x/microsoft. Each has a specification under docs/packages/ covering upstream version, targets, ownership and licensing. To reproduce the numbers, first build the five Neper programs in release mode from benchmarks/db/src/{sqlite,postgresql,mysql,odbc,sqlserver}.e, named <prefix>-<driver> (with .exe on Windows). Then one script per host does the rest:

On Windows the client libraries and psqlODBC are unpacked under D:\tools (or $env:NEPER_DB_TOOLS), and the C program builds with MSVC. SQL Server is a local 2025 Developer instance, given the certificate strict connections need by tests/selfhost/sqlserver_tls.ps1, run once in an elevated PowerShell. On Linux the Ubuntu packages are libsqlite3-dev, libpq-dev, libmysqlclient-dev, unixodbc-dev and odbc-postgresql, plus Microsoft's mssql-server and msodbcsql18, set up once by tests/selfhost/sqlserver.sh setup. Both scripts source benchmarks/db/tools-env, which keeps the Go and Rust toolchains' caches in one directory:

$ pwsh benchmarks/db/run-windows-all.ps1 build/bench/neper results-windows.json
$ bash benchmarks/db/run-linux-all.sh build/linux/bench/neper results-linux.json

The raw samples behind every number above are in benchmarks/db/results/, as windows-2026-09-28-five-drivers.json and linux-2026-09-28-five-drivers.json. If your numbers disagree with ours, we want to know.