ExSQL
Copy MarkdownA SQLite implementation in pure Elixir — no NIFs, no ports, no C.
ExSQL follows the architecture of SQLite's C source (tokenizer → parser →
execution → storage) but reshapes each stage for the BEAM: the engine is a
pure functional core over immutable data, with an optional GenServer
connection for stateful, sqlite3-style use.
Usage
{:ok, conn} = ExSQL.open()
ExSQL.execute!(conn, """
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);
INSERT INTO users (name, age) VALUES ('alice', 34), ('bob', 29), ('carol', 41);
""")
result = ExSQL.query!(conn, "SELECT name, age FROM users WHERE age > 30 ORDER BY age DESC")
result.columns #=> ["name", "age"]
result.rows #=> [["carol", 41], ["alice", 34]]Or skip the process entirely and thread the database value yourself:
db = ExSQL.Database.new()
{:ok, _, db} = ExSQL.Executor.run(db, "CREATE TABLE t (x INTEGER)")
{:ok, _, db} = ExSQL.Executor.run(db, "INSERT INTO t VALUES (1), (2), (3)")
{:ok, [result], _db} = ExSQL.Executor.run(db, "SELECT sum(x) FROM t")
result.rows #=> [[6]]Using with Ecto
ExSQL ships an Ecto adapter, Ecto.Adapters.ExSQL, so it can back an Ecto repo
as a drop-in SQL adapter — application code stays standard Ecto, and a database
can be in-memory or persisted to a real SQLite file.
# config/config.exs
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.ExSQL,
database: "priv/my_app.db", # or :memory for an in-memory database
pool_size: 1 # required — see below
# lib/my_app/repo.ex
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.ExSQL
endWith a schema, ordinary Ecto queries work:
import Ecto.Query
MyApp.Repo.insert!(%MyApp.User{name: "alice", age: 34})
MyApp.Repo.all(from u in MyApp.User, where: u.age > 30, order_by: [desc: u.age])
pool_size: 1is required. Each connection holds its own immutable database value, so a second writer connection would clobber the first's writes. The intended model is a single writer with lock-free snapshot reads (each read runs against a consistent, immutable point-in-time value).
Query codegen (experimental, opt-in)
For repeated queries, ExSQL can compile a query shape into a native BEAM
function (via Module.create/3, which the JIT turns into machine code) instead
of walking the parsed AST row by row. Turn it on with an environment variable:
EXSQL_CODEGEN=1
It is transparent and gated. A query is keyed by its shape — its structure
with literal values lifted out to parameters — so where: u.age > 30 and
where: u.age > 40 share one compiled module. A shape is only compiled after it
has been seen a few times (EXSQL_CODEGEN_THRESHOLD, default 2): a one-shot
ad-hoc query never pays the (~few-ms) compile cost, while a repeated or
parameterized query — the norm when an app drives ExSQL through Ecto — amortizes
it after the first couple of calls. Anything the compiler doesn't support falls
back to the tree-walking executor automatically, so results are always
identical.
Supported shapes today: single-table scan / filter / project / sort, and
two-table LEFT JOIN on an equi-key (hash-joined, with NULL-filled non-matches,
residual ON filters, and COALESCE projections) — the bread-and-butter reads
of a typical app.
import Ecto.Query
# A hot, repeated query — "episodes for this podcast, with the current user's
# play state" — runs the same shape on every page load, for any id values:
from(e in Episode,
left_join: s in EpisodeState,
on: s.episode_id == e.id and s.user_id == ^user_id,
where: e.podcast_id == ^podcast_id,
order_by: [desc: e.published_at],
select: %{
id: e.id,
title: e.title,
played: coalesce(s.played, false),
position: coalesce(s.play_position_seconds, 0)
}
)
|> Repo.all()With EXSQL_CODEGEN=1, once the shape compiles this runs against a generated
function that reads rows positionally and hash-joins the right side — measured at
roughly 10× faster end-to-end through Ecto than the tree walker on a ~750-row
left join, with byte-identical results. Every subsequent call reuses the same
module, regardless of the user_id / podcast_id bound in.
Codegen is opt-in and experimental. It is verified against the tree-walking executor across the full test suite — every supported query runs the generated code and is checked to match — but it is off by default; enable it per deployment with
EXSQL_CODEGEN=1.
Compared with Exqlite
Exqlite is the mature, production-oriented choice when you want the real
SQLite engine from Elixir. It binds to sqlite3 through NIFs, so it inherits
SQLite's full feature set, planner, file locking, and performance profile.
ExSQL has a different goal: it is a SQLite-compatible engine written in
Elixir, with ordinary BEAM data structures and no native boundary. That makes
some tradeoffs better for BEAM applications, while other SQLite/exqlite limits
remain or are not solved yet.
| Exqlite limitation / behavior | ExSQL status |
|---|---|
| Prepared statements are not cached | Partially solved (opt-in). With EXSQL_CODEGEN=1, repeated query shapes compile once to a native function and are reused for any parameter values (see Query codegen). Caching the SQL parse itself is still a future optimization. |
| Prepared statements are mutable handles and must not be manipulated concurrently | Improved by design. ExSQL has no mutable sqlite3_stmt* handle; query structs and parsed data are normal immutable Elixir values. The stateful connection still serializes execution, but there is no shared native statement object to corrupt. |
| Simultaneous writes are not supported | Same practical limit. The Ecto adapter requires pool_size: 1. ExSQL can provide immutable snapshot reads, but there is still one authoritative writer for a database path. |
| Native calls run through the Dirty NIF scheduler | Avoided. ExSQL is pure Elixir: no NIFs, no ports, and no C calls. Long queries consume BEAM reductions like other Elixir code instead of occupying Dirty NIF scheduler work. |
| Datetimes are stored without offsets | Improved for Ecto values. The Ecto adapter encodes DateTime values with DateTime.to_iso8601/1 and decodes :utc_datetime values with DateTime.from_iso8601/1, so offset-bearing ISO-8601 text round-trips through ExSQL. SQLite-compatible SQL date/time functions still follow SQLite semantics and normalize timezone offsets during calculation. |
BLOB values require {:blob, binary} or they are treated as strings | Same explicit representation. ExSQL also represents TEXT as plain Elixir binaries and BLOB as {:blob, binary}. Use SQL blob literals such as x'CAFE' or the tagged tuple when binding/returning BLOB values. |
In short: ExSQL can improve on Exqlite's native-boundary and mutable-handle constraints, and it can use immutable snapshots as a BEAM-native read model. It does not yet match SQLite/exqlite for maturity, full SQL coverage, or query planning — but on raw engine speed it is closer than you might expect.
Performance. Run through the same Elixir harness over the sqllogictest corpus (~620 files), ExSQL lands around parity with exqlite (the C SQLite NIF) at the median, within 2× on ~93% of files, matching or beating the C engine on a large share — with no timeouts and no result mismatches. The slowest files are unindexed full scans (a measured ~4.9× floor for any pure-Elixir engine) and a few insert-heavy DML cases. Full method and current numbers: BENCHMARKS.md.
On-disk format. ExSQL reads and writes the real SQLite file format —
databases round-trip with the sqlite3 CLI and exqlite (PRAGMA integrity_check passes), including multi-level B-trees, secondary indexes,
overflow pages for large rows, and AUTOINCREMENT sequences. (UTF-8 databases;
reads reject UTF-16 with a clear error.)
Architecture
The module layout mirrors SQLite's pipeline:
| stage | SQLite (C) | ExSQL | approach |
|---|---|---|---|
| lexing | tokenize.c (char-class table + keyword hash) | ExSQL.Tokenizer | binary pattern matching |
| parsing | parse.y (Lemon LALR) | ExSQL.Parser | recursive descent, precedence climbing |
| values | vdbemem.c (Mem cells) | ExSQL.Value | storage classes as native terms |
| execution | codegen + VDBE bytecode VM (vdbe.c) | ExSQL.Executor | tree-walking interpreter |
| storage | btree.c + pager.c | ExSQL.Table / ExSQL.Database | immutable maps keyed by rowid; each row a positional tuple (≈63% smaller than a per-row key map) |
| connection | sqlite3* handle + mutex | ExSQL.Connection | GenServer serializing statements |
SQLite semantics implemented so far:
- Type affinity (
INTEGER/TEXT/REAL/NUMERIC/BLOB) with SQLite's declared-type derivation rules —VARCHAR(40)is TEXT affinity,DECIMALis NUMERIC, untyped columns are BLOB — and §4.2 comparison affinity: a numeric-affinity operand coerces the other side to NUMERIC, a TEXT one coerces a no-affinity side to TEXT, literals compared to literals stay untouched (so'500' = 500is false butt = 500matches a TEXT column); applied across=/</IS/IN/BETWEEN/CASE/USING, withIN (SELECT ...)using the subquery column's affinity - Storage classes mapped to native terms:
nil, integers, floats, binaries (TEXT),{:blob, binary}— with SQLite's cross-class comparison ordering (NULL < numeric < TEXT < BLOB) - Three-valued logic:
NULL AND 0is0,NULL OR 0isNULL,x = NULLnever matches,IS [NOT]is the NULL-safe comparison INTEGER PRIMARY KEYis the rowid: insertingNULLauto-assigns, explicit values re-seed the counter,rowid/oid/_rowid_resolve to it- Constraints:
NOT NULL,UNIQUE,PRIMARY KEY,DEFAULT, with statement-level atomicity on violation (ABORT semantics) - Statements:
CREATE TABLE [IF NOT EXISTS],DROP TABLE [IF EXISTS],INSERT(multi-rowVALUES,INSERT INTO ... SELECT,DEFAULT VALUES, explicitrowidtargets),REPLACE INTO/INSERT OR REPLACE/IGNORE/UPDATE OR ...conflict handling, bareVALUES (...)selects,SELECT(WHERE,GROUP BY/HAVING— including alias and position terms,ORDER BY— column / alias / position / aggregate expression,LIMIT/OFFSETincluding theLIMIT x, yform,DISTINCT),UPDATE,DELETE - Transactions:
BEGIN/COMMIT/ROLLBACKandSAVEPOINT/RELEASE/ROLLBACK TO— a snapshot stack on the immutable database value, with SQLite's error messages (cannot start a transaction within a transaction, …) - Compound selects:
UNION [ALL],INTERSECT,EXCEPT, with SQLite's rules — ORDER BY/LIMIT only after the last component, terms resolved against output columns (by position, name, or any component's column name), distinct ops emerging sorted as from the temp B-tree Joins: comma,
[INNER | CROSS] JOIN,NATURAL,LEFT [OUTER] JOIN,ON/USING, table aliases, subqueries inFROM, qualifiedt.*— with SQLite's column-hiding rules forNATURAL/USINGin*expansion, its join-type validation (unknown join type: INNER OUTER), and ambiguous column detection- Subqueries: scalar
(SELECT ...),EXISTS,IN (SELECT ...), all correlated — free columns resolve against the enclosing row, including inUPDATE/DELETEWHEREclauses - Expressions: full operator precedence,
LIKE/GLOB,IN,BETWEEN,CASE,CAST(§4.1 forced-conversion rules, longest-numeric-prefix text parsing), bitwise&/|/<</>>/~(int64 wrap, negative shifts reverse), string/blob/hex literals, comments - Functions: aggregates (
count(*),count,sum,total,avg,min,max,group_concat/string_agg) and scalars (abs,length,octet_length,lower,upper,coalesce,ifnull,nullif,substr/substring,round,typeof, multi-argmin/max,replace,trim/ltrim/rtrim,instr,hex,quote,char,unicode,sign,iif,zeroblob,concat/concat_ws, function-formlike()/glob()), with SQLite's "no such function" vs "wrong number of arguments" errors
Tests
test/sqlite/ holds ExUnit translations of SQLite's own TCL test suite
(sqlite/test/*.test), one module per source file with the original test
ids preserved — currently select3.test (aggregates, GROUP BY/HAVING),
select4.test (compound selects), join.test, in.test, subquery.test,
cast.test, expr.test (operators), func.test, insert.test,
trans.test, savepoint.test, and types2.test (comparison affinity).
ExSQL.SQLiteCase provides
execsql/execsql2/catchsql helpers mirroring tester.tcl, so a TCL
case translates nearly word for word. As features from the roadmap land,
the matching test file gets translated alongside.
Benchmarks
BENCHMARKS.md measures ExSQL against exqlite (the C SQLite
NIF) driven from the same Elixir harness over the sqllogictest corpus — the
fairest "what does ExSQL cost vs SQLite, from Elixir" comparison — with the
current results, methodology, and commands to reproduce. The harness lives in
bench/slt_compare/.
Roadmap
Landed: ALTER TABLE / CHECK constraints / composite PK & UNIQUE; views;
secondary indexes with a query planner (rowid/index seeks, incremental O(n)
index maintenance for INSERT/UPDATE/DELETE); the on-disk file format
(read and write, interchangeable with real SQLite); positional
tuple-based row storage; and runtime query codegen — compiling repeated
query shapes to native BEAM functions (opt-in via EXSQL_CODEGEN=1, gated by a
shape-keyed sighting cache; see Query codegen).
Next:
- Broaden codegen coverage — more join kinds, aggregates, and expression forms. Today it compiles single-table scans and two-table left joins and falls back to the tree walker for everything else; widening the supported shape extends the JIT win to more of a real app's query mix.
- Prepared-statement / plan cache — skip re-parsing repeated (parameterized) queries. The codegen shape cache already compiles repeated queries; caching the parse would cut the remaining per-call text-parsing overhead that both engines still pay.
Development
mix test # run the suite
mix precommit # format + compile with warnings-as-errors + test
License
GNU General Public License v3.0 or later (GPL-3.0-or-later). See
LICENSE.