PL/SQLite is a high-performance, loadable C extension for SQLite that introduces a fully transactional procedural language. It solves performance bottlenecks by using a Transpiler Architecture that runs logic directly inside the database memory space.
The core of PL/SQLite is implemented in Odin, a data-oriented language designed for high performance and low-level control.
- Deterministic Memory: Unlike Go or Java, Odin has no Garbage Collector (GC), allowing the VM to manage memory manually and predictably within SQLite's memory context.
- Safety & Productivity: Odin provides modern features like slices, tagged unions, and strong typing, making the transpiler and runtime logic safer and easier to maintain than pure C.
- LLVM-Powered: Leverages the LLVM backend for aggressive optimizations and easy cross-compilation to Windows, Linux, and macOS.
- Named Scopes: Secure namespacing for variables. Each procedure owns its scope, preventing accidental overlaps while allowing explicit cross-procedure access using the
@proc_name.variablesyntax. - Transpiler Architecture: Automatically converts high-level PL/SQLite source code into optimized "Engine SQL" function calls.
- Zero-Copy Runtime: Executes logic without the overhead of context-switching between your application language and the database. Strings are passed as pointers (zero-copy) for reads.
- Statement Caching: Automatically caches prepared SQLite statements within procedure scopes, drastically reducing overhead for loops and recursive calls.
- Scope Persistence: Reuses memory scopes and variable maps during loops, eliminating allocation churn for high-performance iteration.
- Atomic Transactions: Procedures are wrapped in SQLite
SAVEPOINTs, ensuring that any error triggers a full rollback.
The PL/SQLite extension integrates a transpiler and a virtual machine directly into the SQLite process.
+-----------------------+ +-------------------------+
| PL/SQLite Source | ----> | register_plsql() |
| | | (Transpiler) |
| DECLARE x = 10; | +-----------+-------------+
| CALL other_proc(); | |
+-----------------------+ v
+-------------------------+
| __plsql_procedures DB |
| (Stored Transpiled SQL) |
+-----------+-------------+
|
v
+-----------------------+ +-------------------------+
| SQLite Engine | <---- | run_plsql() |
| | | (VM Entry Point) |
+-----------+-----------+ +-----------+-------------+
^ | |
| | SQL Calls | Scope/Var
| v v
| +-----------------------+ +-------------------------+
| | Runtime Functions | | Memory Context |
| | (__env_set, __run_if) | ----> | - Scope Stack |
| +-----------+-----------+ | - Variable Hash Maps |
| | | - Savepoints |
| | +-------------------------+
| v ^
+------- run_plsql() (Nested) -----------------+
- Transpiler: Converts procedural code into valid SQL queries with embedded runtime function calls.
- Storage: Transpiled SQL is stored in a dedicated
__plsql_procedurestable. - VM Runtime:
run_plsqlmanages the life-cycle of a procedure call. It pushes a new frame onto the Scope Stack, opens aSAVEPOINT, and executes the transpiled SQL. - Recursive Execution: If the transpiled SQL contains a
CALL, the engine invokesrun_plsqlagain, creating a nested scope and savepoint. This allows for deep recursion (up to 500 levels) while maintaining transactional atomicity.
Why move logic into the database? Here is how PL/SQLite compares to writing the same logic in your application layer (Python, Go, Node.js):
| Feature | โก Extension Logic (PL/SQLite) | ๐ข Application Logic (Python, Go, JS) |
|---|---|---|
| Execution Locality | Inside DB Memory (Zero Copy) | Outside DB (Network/IPC overhead) |
| Round Trips | 1 Call (Zero latency for loops) | N+1 Calls (High latency for loops) |
| Data Transfer | Zero Copy (Pointer access) | Serialization (JSON/Protobuf/etc) |
| Transaction Safety | Automatic (Implicit Savepoints) | Manual (Complex BEGIN/COMMIT handling) |
| Performance | High Throughput (Native Speed) | Latency Bound by serialization/IO |
| Consistency | Strong (Logic lives with data) | Eventual (Logic drift across app versions) |
| Use Case | Complex validation, batch updates, heavy math | UI rendering, external API calls, business rules |
Custom application-level errors can be triggered using the RAISE statement. This will halt execution and trigger an automatic ROLLBACK of all changes made during the procedure call.
SELECT register_plsql('validate_user', 'id', '
IF (@id < 0) THEN
RAISE "Invalid user ID: Must be positive";
END IF;
-- proceed with logic
');| Use Case | Application Layer | PL/SQLite | Improvement |
|---|---|---|---|
| Simple Iteration | ~300-600 ms | ~200-250 ms | 1.5x-3.0x Faster (Go/Python) ๐ |
| RANGE Iteration | ~80-170 ms | ~70-90 ms | 1.5x-1.7x Faster (Rust/Go) ๐ |
| Bulk Inserts | ~110-190 ms | ~80-150 ms | 1.6x Faster (Rust) ๐ |
| Complex Transactions | ~850-1500 ms | ~60-120 ms | 15x Faster (Batched) ๐ฅ |
Verdict: Use PL/SQLite when you need to perform multiple reads/writes based on intermediate results without paying the round-trip cost for each step. Uses Application Logic when you need to integrate with external services or render UIs.
How does this extension compare to other attempts at bringing stored procedures to SQLite?
| Feature | โก PL/SQLite (This Extension) | ๐งฉ aergoio/sqlite-stored-procedures | ๐ Native UDFs / App Logic |
|---|---|---|---|
| Method | Transpiler (Compiles to pure SQL) | Interpreter (AST traversal) | Callbacks (Host language) |
| Execution | Inside DB (Zero Context Switch) | Inside DB (Interpreter Loop) | Outside DB (IPC/Serialization) |
| Performance | Ultra-High (Native SQL Speed) | Medium (Interpreter overhead) | Low (Serialization overhead) |
| Transaction Speed | 11.5x Faster (vs Rust/Node Tx) | Unknown | Baseline |
| Safety | โ Automatic Savepoints | โ Manual Handling | โ Manual Handling |
| Memory | โ GC-Free (Odin Runtime) | โ Interpreter Memory | โ GC-Heavy (V8/Python) |
| Usage | .load Extension |
.load Extension |
Embedded in Host App |
Key Differentiator: PL/SQLite uses a transpiler architecture. Unlike interpreters that evaluate an AST at runtime, PL/SQLite converts your code into optimized "Engine SQL" that SQLite executes directly. This allows us to beat even high-performance client libraries (like better-sqlite3) in bulk operations by eliminating the overhead of moving data between the host language and the engine for every statement.
Variables are local to the procedure by default but can be qualified for cross-procedure access.
| Syntax | Description |
|---|---|
DECLARE x = 10; |
Initialize a variable in the current scope. |
SET x = @x + 1; |
Update a variable in the current scope. |
@var |
Read variable from the current scope. |
@proc.var |
Read variable from a parent/named procedure's scope. |
SET proc.var = val; |
Update variable in a parent/named procedure's scope. |
PL/SQLite is fully typed and supports all standard SQLite data types:
- Integer: 64-bit signed integers.
- Float: 64-bit floating point numbers.
- Text: UTF-8 encoded strings.
- Blob: Binary large objects (Zero-copy support).
- Null: Explicit
NULLsupport in assignments and returns.
Standard branching and iteration logic.
IF (@stock > 0) THEN
-- True block
ELSE
-- False block
END IF;FOR r IN (SELECT id, price FROM items) LOOP
-- Access via @r.id, @r.price
SET total = @total + @r.price;
END LOOP;-- Efficient numerical iteration
RANGE i IN (1, 100) LOOP
INSERT INTO logs(val) VALUES (@i);
END LOOP;Procedures can call other procedures using the CALL keyword.
-- Call as a statement
CALL update_inventory(@item_id, @qty);
-- Use the return value of a call
DECLARE res = CALL process_payment(@user_id, @amount);
IF (@res == "SUCCESS") THEN ...A child procedure can read a parent's variable explicitly.
-- In 'sub_proc'
DECLARE factor = @main_proc.multiplier;
-- Modify parent variable directly
SET main_proc.success_count = @main_proc.success_count + 1;make # Compiles the extension to plsqlite.so
make test # Runs the comprehensive Python test suite
make leak-check # Runs memory verification with ValgrindPL/SQLite supports cross-compilation from Linux to Windows and can be tested locally using Wine.
- Setup Windows SDK: Use
xwinto splat Windows SDK libraries into thewin-libsdirectory.make setup-win-libs
- Download Windows SQLite: Fetches the required
.dll,.lib, and.exefor Windows.make download-sqlite-win
- Build Windows DLL:
make windows
- Run Tests under Wine:
- Install Windows Python in your Wine prefix:
make install-wine-python - Run the test suite:
make test-wine
- Install Windows Python in your Wine prefix:
.load ./plsqliteLoad the compiled shared library into your SQLite session.
.load ./plsqliteNote: This automatically creates the necessary __plsql_procedures table if it doesn't exist.
Create any tables your procedures will interact with.
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, balance INTEGER);
INSERT INTO users VALUES (1, 'Alice', 100);Use register_plsql to compile your PL/SQLite code. The function signature is register_plsql(name, arguments, source_code).
SELECT register_plsql(
'update_balance',
'user_id, amount',
'
-- 1. Check current balance
DECLARE current_bal = 0;
FOR row IN (SELECT balance FROM users WHERE id = @user_id) LOOP
SET current_bal = @row.balance;
END LOOP;
-- 2. Update if sufficient funds
IF (@current_bal + @amount >= 0) THEN
UPDATE users SET balance = balance + @amount WHERE id = @user_id;
RETURN "SUCCESS: New balance is " || (@current_bal + @amount);
ELSE
RETURN "ERROR: Insufficient funds";
END IF;
'
);If there are syntax errors, register_plsql will fail with a descriptive error message.
Run the procedure using run_plsql(name, arg1, arg2...).
-- Add 50 to Alice's balance
SELECT run_plsql('update_balance', 1, 50);
-- Result: "SUCCESS: New balance is 150"
-- Verify the change in the table
SELECT * FROM users WHERE id = 1;Every procedure runs within a SAVEPOINT. If any SQL error occurs inside the procedure (e.g., a constraint violation, division by zero, or invalid table name), the entire procedure and all its nested calls are rolled back.
PL/SQLite supports recursive procedure calls (e.g., for factorial or tree traversal).
- Recursion Limit: The default stack depth limit is 500. Exceeding this will trigger a runtime error and a full rollback.
- Savepoints per Call: Each nested call consumes one SQLite Savepoint.
SELECT register_plsql(
'process_order',
'item_id, qty',
'
DECLARE stock = 0;
FOR row IN (SELECT quantity FROM inventory WHERE id = @item_id) LOOP
SET stock = @row.quantity;
END LOOP;
IF (@stock >= @qty) THEN
UPDATE inventory SET quantity = quantity - @qty WHERE id = @item_id;
RETURN "ORDER_SUCCESS";
ELSE
RETURN "INSUFFICIENT_STOCK";
END IF;
'
);
-- Run the procedure
SELECT run_plsql('process_order', 101, 5);Break down complex logic into reusable sub-procedures.
-- 1. Register a validation helper
SELECT register_plsql(
'validate_qty',
'qty',
'IF (@qty <= 0) THEN RETURN "INVALID_QUANTITY"; END IF;'
);
-- 2. Register the main logic that calls the helper
SELECT register_plsql(
'place_order',
'item_id, qty',
'
-- Call validation helper and check its return value
DECLARE err = CALL validate_qty(@qty);
IF (@err IS NOT NULL) THEN RETURN @err; END IF;
-- Proceed with order logic...
UPDATE inventory SET quantity = quantity - @qty WHERE id = @item_id;
RETURN "ORDER_PLACED";
'
);
-- 3. Execute
SELECT run_plsql('place_order', 101, 5);Shared variables across procedure calls.
SELECT register_plsql('main_proc', 'val', '
DECLARE multiplier = 2;
CALL child_proc(@val);
RETURN "Done";
');
SELECT register_plsql('child_proc', 'input', '
-- Access "multiplier" from the "main_proc" scope
DECLARE result = @input * @main_proc.multiplier;
INSERT INTO logs(msg) VALUES ("Result: " || @result);
');We compared In-Database PL/SQLite against Application-Layer Logic across multiple languages.
Before running, install dependencies and initialize the database:
make setup-bench # Installs Node.js, Go, and Rust dependencies
make setup-bench-db # Seeds 100k rows in databases/bench.dbThen run the full suite or individual languages:
make bench # Run all benchmarks (Python, Node, Go, Rust, SQL)
make bench-python # Run Python-only benchmarks
make bench-node # Run Node.js benchmarks (requires better-sqlite3)
make bench-go # Run Go benchmarks
make bench-rust # Run Rust benchmarks
make bench-sql # Run pure SQL CLI benchmarksLogic: for row in data: if val > 50: insert weighted else: insert normal
| Language / Driver | App-Layer Logic | PL/SQLite (In-DB) | Speedup |
|---|---|---|---|
| Python | 383.32 ms | 255.36 ms | 1.50x |
| Node.js | 309.00 ms | 385.00 ms | 0.80x |
| Go | 618.57 ms | 208.97 ms | 2.96x |
| Rust | 128.77 ms | 200.56 ms | 0.64x |
| SQL CLI | N/A | 208.59 ms | 1.84x |
Logic: Native RANGE loop vs application-layer for loop.
| Language / Driver | App-Layer | PL/SQLite (Range) | Speedup |
|---|---|---|---|
| Python | 78.78 ms | 80.21 ms | 0.98x |
| Node.js | 176.00 ms | 167.00 ms | 1.05x |
| Go | 142.23 ms | 92.75 ms | 1.53x |
| Rust | 120.57 ms | 72.12 ms | 1.67x |
| SQL CLI | N/A | 80.13 ms | 0.98x |
Logic: One procedure call vs 10k separate INSERT statements in a transaction.
| Language / Driver | App-Layer (Tx) | PL/SQLite (Proc) | Speedup |
|---|---|---|---|
| Python | 110.04 ms | 86.48 ms | 1.27x |
| Node.js | 193.00 ms | 126.00 ms | 1.53x |
| Go | 152.33 ms | 151.65 ms | 1.00x |
| Rust | 128.35 ms | 80.24 ms | 1.60x |
| SQL CLI | N/A | 96.31 ms | 1.14x |
Logic: RETURN n + CALL rec_sum(n - 1)
| Language / Driver | App-Layer | PL/SQLite | Note |
|---|---|---|---|
| Python | 83.14 ms | 63.73 ms | 1.30x |
| Node.js | 92.50 ms | 150.83 ms | 0.61x |
| Go | 76.74 ms | 84.05 ms | 0.91x |
| Rust | 120.54 ms | 105.44 ms | 1.14x |
| SQL CLI | N/A | 113.75 ms | 0.73x |
Logic: Account transfer involving multiple selects and updates within a single procedural call vs. multiple app-layer boundaries.
| Language / Driver | App-Layer | PL/SQL Proc | PL/SQL Batch | Speedup (Batch) |
|---|---|---|---|---|
| Python | 879.07 ms | 700.13 ms | 116.86 ms | 7.52x ๐ฅ |
| Node.js | 1517 ms | 1300 ms | 101 ms | 15.0x ๐ฅ |
| Go | 1225.58 ms | 924.03 ms | 117.29 ms | 10.5x ๐ฅ |
| Rust | 865.79 ms | 758.27 ms | 66.73 ms | 13.0x ๐ฅ |
| SQL CLI | N/A | N/A | 58.23 ms | 15.1x ๐ฅ |
- Networked Databases (Massive Win): On a networked database (e.g., Turso, rqlite, sqlite-server), the round-trip overhead makes app-layer transactions take seconds or minutes. PL/SQLite executes them in milliseconds.
- Complex State Logic: Use PL/SQLite for multi-statement sequences that require atomic consistency and depend on intermediate results.
- Driver Reduction: It eliminates the need to pay "FFI taxes" (context switching) between high-level languages like Python/Go and the C-engine.
- Local Iteration: For simple read-only loops over local files, native driver iterators (especially in Rust/Python) remain faster due to highly optimized cursor fetching.
| Pros (Why use it?) | Cons (When to avoid?) |
|---|---|
๐ Network Efficiency: Reduces 100+ network round-trips to a single RPC call. Essential for sqlite-server or distributed SQLite. |
๐ข Simple Iteration: For local simple SELECT * loops, native drivers (Rust/C++) are faster due to optimized cursor fetching. |
| โ๏ธ ACID Consistency: Complex multi-step logic (check balance -> update sender -> update receiver) runs in a single atomic transaction. | ๐ ๏ธ Tooling: Debugging is currently limited to PRINT statements; no interactive breakpoints yet. |
| ๐ Logic Encapsulation: Business rules live in the DB. You don't need to reimplement validation logic in Python, Go, and Node.js clients. | ๐ Vendor Lock-in: While SQL-transpiled, the syntax is specific to PL/SQLite (though heavily inspired by PL/pgSQL). |
| ๐ Zero-Copy: Variables in procedures are pointers to SQLite values, avoiding serialization overhead for intermediate steps. | ๐งต Single Threaded: Long-running procedures hold the SQLite execution lock, potentially blocking other writers. |
PL/SQLite is built for production environments:
- Zero-Copy: Data stays in SQLite's memory pages during execution.
- Memory Safe: Verified with
Valgrind(0 leaks reported) and internalTracking_Allocator. - Leak Detection: Built-in leak reporting via
SELECT __plsql_leak_report();. - Transactional: Fully integrated with SQLite's ACID properties (Atomic Rollbacks).
- Infinite Recursion Protection: Stack depth limits (500) prevent crashes.