Thin SQLite bindings for Odin.
odin-sqlite is designed as:
- a generated raw binding layer from
sqlite3.h - a small "handwritten" wrapper layer on top
- a practical, explicit API for normal SQLite usage in Odin
The design goal is not to build an ORM. The wrapper stays close to SQLite's actual execution model and is optimized first for predictable server-side usage.
I normally hand roll my code, and I started down the path with sqlite3 bindings as well, but I thought why not actually give the LLMs a proper shot at something where I can very strictly define the scope.
This is the result, a probably 90% LLM generate SQLite3 bindings for Odin. It does not cover everything, but it should pretty much cover more than what most people use.
I did have to sort out a fair few memory issues manually, but other than that, the LLM's did the rest. The markdown files which summarise what is done, the spec etc is in the llm-docs branch.
I feel dirty, but.. it also saved me stupid amounts of time.
The current user-facing surface includes:
- database open/close helpers
- statement prepare/step/reset/finalize helpers
- typed parameter binding
- batch positional bind helpers
- typed column readers
- transaction and savepoint helpers
- scalar/query convenience helpers
- statement reuse/cache helpers
- tracing/debug helpers
- incremental blob API
- backup API
- reflection-based row mapping
- struct-tag-based row mapping
- struct query convenience wrappers
If you are new to the package:
- read the minimal example below
- browse
packaging/examples/README.md - run all the tests with
make test
Run the wrapper checks, example checks and smoke tests:
make test
That runs the project checks and the smoke suite. The smoke suite uses a
tracking allocator and exits non-zero on any leaked allocation or bad free, so
make test doubles as a memory-safety gate.
To create a package directory:
make package-dir
To create a zip package:
make package-zip
The output is written under:
out/
See also:
packaging/README.package.md
The packaged examples live under:
packaging/examples/
Start with:
packaging/examples/README.md
Useful examples include:
packaging/examples/minimal/main.odinpackaging/examples/prepared_statements/bind_types/main.odinpackaging/examples/common_patterns/query_optional/main.odinpackaging/examples/common_patterns/struct_mapping/main.odinpackaging/examples/common_patterns/struct_queries/main.odinpackaging/examples/common_patterns/ownership_and_cleanup/main.odinpackaging/examples/transactions/commit_and_rollback/main.odinpackaging/examples/common_patterns/errors/main.odin
package main
import "core:fmt"
import sqlite "../sqlite"
main :: proc() {
db, err, ok := sqlite.db_open(":memory:")
if !ok {
fmt.println(sqlite.error_string(err))
sqlite.error_destroy(&err)
return
}
defer sqlite.db_close(&db)
_, _ = sqlite.db_exec(db, "CREATE TABLE items(id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
_, _ = sqlite.db_exec(db, "INSERT INTO items(name) VALUES ('alpha'), ('beta')")
value, scalar_err, scalar_ok := sqlite.db_scalar_text(
db,
"SELECT name FROM items WHERE id = 1",
)
if !scalar_ok {
fmt.println(sqlite.error_string(scalar_err))
sqlite.error_destroy(&scalar_err)
return
}
defer delete(value)
fmt.println(value)
}
package main
import "core:fmt"
import sqlite "../sqlite"
main :: proc() {
db, err, ok := sqlite.db_open(":memory:")
if !ok {
fmt.println(sqlite.error_string(err))
sqlite.error_destroy(&err)
return
}
defer sqlite.db_close(&db)
_, _ = sqlite.db_exec(db, "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
_, _ = sqlite.db_exec(db, "INSERT INTO users(name) VALUES ('alice'), ('bob')")
stmt, prep_err, prep_ok := sqlite.stmt_prepare(db, "SELECT name FROM users WHERE id = ?1")
if !prep_ok {
fmt.println(sqlite.error_string(prep_err))
sqlite.error_destroy(&prep_err)
return
}
defer sqlite.stmt_finalize(&stmt)
bind_err, bind_ok := sqlite.stmt_bind_i64(&stmt, 1, 2)
if !bind_ok {
fmt.println(sqlite.error_string(bind_err))
sqlite.error_destroy(&bind_err)
return
}
has_row, step_err, step_ok := sqlite.stmt_next(stmt)
if !step_ok {
fmt.println(sqlite.error_string(step_err))
sqlite.error_destroy(&step_err)
return
}
if !has_row {
fmt.println("no row")
return
}
name := sqlite.stmt_get_text(stmt, 0)
defer delete(name)
fmt.println(name)
}
The wrapper includes positional batch bind helpers:
stmt_bind_args(...)stmt_bind_args_slice(...)
Their semantics are:
- fewer args than parameters is allowed
- more args than parameters is an error
- bindings are not auto-cleared automatically; the per-slot copy stored by the wrapper is freed on rebind, reset+clear_bindings, or finalize
Example:
package main
import "core:fmt"
import sqlite "../sqlite"
main :: proc() {
db, err, ok := sqlite.db_open(":memory:")
if !ok {
fmt.println(sqlite.error_string(err))
sqlite.error_destroy(&err)
return
}
defer sqlite.db_close(&db)
stmt, prep_err, prep_ok := sqlite.stmt_prepare(db, "SELECT ?1, ?2, ?3")
if !prep_ok {
fmt.println(sqlite.error_string(prep_err))
sqlite.error_destroy(&prep_err)
return
}
defer sqlite.stmt_finalize(&stmt)
bind_err, bind_ok := sqlite.stmt_bind_args(
&stmt,
sqlite.bind_arg_i64(42),
sqlite.bind_arg_text("alice"),
sqlite.bind_arg_bool(true),
)
if !bind_ok {
fmt.println(sqlite.error_string(bind_err))
sqlite.error_destroy(&bind_err)
return
}
has_row, step_err, step_ok := sqlite.stmt_next(stmt)
if !step_ok || !has_row {
fmt.println(sqlite.error_string(step_err))
sqlite.error_destroy(&step_err)
return
}
text_value := sqlite.stmt_get_text(stmt, 1)
defer delete(text_value)
fmt.printf(
"values -> %d %q %v\n",
sqlite.stmt_get_i64(stmt, 0),
text_value,
sqlite.stmt_get_bool(stmt, 2),
)
}
The package now includes additive struct-mapping helpers such as:
stmt_scan_struct(...)db_query_one_struct(...)db_query_optional_struct(...)db_query_all_struct(...)
These helpers are convenience layers on top of the explicit statement API.
Capabilities:
- exact column-name matching, with optional
sqlite:"column_name"tag overrides using inner: Inner_Struct(embedded) fields are walked recursively- integer narrowing (
i64→i8/i16/i32/u8/u16/u32) is range-checked and out-of-range or negative-into-unsigned reads returnSQLITE_MISMATCH - on a scan error mid-struct, any field memory already copied is freed before the proc returns
Example:
package main
import "core:fmt"
import sqlite "../sqlite"
User_Row :: struct {
id: i64,
display_name: string `sqlite:"user_name"`,
is_admin: bool,
}
main :: proc() {
db, err, ok := sqlite.db_open(":memory:")
if !ok {
fmt.println(sqlite.error_string(err))
sqlite.error_destroy(&err)
return
}
defer sqlite.db_close(&db)
_, _ = sqlite.db_exec(db, "CREATE TABLE users(id INTEGER PRIMARY KEY, user_name TEXT NOT NULL, is_admin INTEGER NOT NULL)")
_, _ = sqlite.db_exec(db, "INSERT INTO users(user_name, is_admin) VALUES ('alice', 1), ('bob', 0)")
row := User_Row{}
query_err, query_ok := sqlite.db_query_one_struct(
db,
"SELECT id, user_name, is_admin FROM users ORDER BY id LIMIT 1",
&row,
)
if !query_ok {
fmt.println(sqlite.error_string(query_err))
sqlite.error_destroy(&query_err)
return
}
defer delete(row.display_name)
fmt.printf(
"user -> {id=%d display_name=%q is_admin=%v}\n",
row.id,
row.display_name,
row.is_admin,
)
}
Error is a value type. It carries owned copies of its message, sql,
ctx, and op strings.
Ownership rules:
- a successful call (
ok == true) returnserror_none()— all string fields are empty, no allocation happened, and no destroy is required - a failing call (
ok == false) returns anErrorthat owns its strings; release them withsqlite.error_destroy(&err)when you are done error_with_op(&err, "..."),error_with_context(&err, "..."), anderror_with_sql(&err, "...")mutateerrin place and free any previous value of the field they set — they are pointer-taking on purpose so the ownership chain stays linearerror_make(code, message)is the safe constructor for user-built Errors; do not useError{message = "literal"}becauseerror_destroywould try to free a string literal
error_string(err) and error_summary(err) allocate via
context.temp_allocator and return a borrowed view that is valid until the
next temp-allocator reset. Clone with strings.clone if you need to retain
the formatted string.
Typical failure pattern in application code:
err, ok := sqlite.db_exec(db, sql)
if !ok {
fmt.println(sqlite.error_string(err))
sqlite.error_destroy(&err)
return
}
db_savepoint, db_release, and db_rollback_to validate the supplied name
against [A-Za-z_][A-Za-z0-9_]*. Names containing quotes, semicolons, or any
other punctuation are rejected with SQLITE_MISUSE — SQLite has no parameter
binding for SAVEPOINT names so the wrapper refuses to splice arbitrary input
into the SQL string.
db_prepare_cached requires a non-nil ^Stmt_Cache. If you do not want a
cache, call stmt_prepare directly and manage the lifetime yourself.
cache_put(&cache, &stmt) consumes the caller's Stmt value (it is zeroed
after the call). The cache now owns the handle, the cloned SQL string, and
any bound-storage allocations. Do not finalize the original stmt afterwards.
cache_clear / cache_destroy / cache_prune_unused finalize the cached
statements they remove. db_prepare_cached returns a pointer to a
cache-owned Stmt — do not call stmt_finalize on it directly.
Some convenience APIs copy SQLite-managed data into caller-owned memory using the allocator you pass in.
That means:
- copied
stringand[]u8values returned from helper APIs are caller-owned - copied
stringand[]u8values mapped into your structs are also caller-owned - when you use a non-temporary allocator for that data, you are responsible
for releasing it with
delete(...)when appropriate
This applies to APIs such as:
stmt_get_text(...)stmt_get_blob(...)db_scalar_text(...)blob_read_all(...)stmt_scan_struct(...)forstringand[]u8fieldsdb_query_one_struct(...)db_query_optional_struct(...)db_query_all_struct(...)
For db_query_all_struct(...), ownership is two-layered:
- the returned slice is owned by you
- any copied
string/[]u8fields inside the returned rows are also owned by you - on an error mid-iteration, the wrapper frees every already-appended row's copied fields and the dynamic array before returning; the caller does not need to clean up partial state
If you want a focused usage example for this, read:
packaging/examples/common_patterns/ownership_and_cleanup/main.odin
A few wrapper procs return strings that borrow SQLite-managed memory. These are documented inline at the call site. The notable ones:
db_errmsg(db)— valid only until the next SQLite call on the same DB. Prefer themessagefield on a returnedError, which is an owned copy.db_errstr(code)— borrows SQLite's static error-string table; valid for the program's lifetime.stmt_sql(stmt)— valid for the statement's lifetime.stmt_column_name(stmt, i)/stmt_column_decltype(stmt, i)— valid until the next call on the same column or until the statement is finalized / automatically re-prepared.stmt_param_name(stmt, i)— valid for the statement's lifetime.
Clone with strings.clone if you need to retain any of these across other
SQLite calls.
SQLite's sqlite3_bind_text, sqlite3_bind_blob, sqlite3_blob_read, and
sqlite3_blob_write take their length and offset arguments as 32-bit int.
The wrapper guards against silent overflow: any input whose length or offset
exceeds max(i32) (≈ 2 GiB) is rejected with SQLITE_TOOBIG instead of
wrapping into a negative number.
For user-facing examples and packaging notes, see:
packaging/examples/README.mdpackaging/README.package.md