sqlode reads SQL schema and query files and generates typed Gleam code. It follows the sqlc workflow: write SQL, run the generator, call the generated functions.
sqlode is inspired by sqlc but is not a drop-in replacement. Macros use the sqlode.* prefix — sqlc.* is not accepted.
Supported engines (raw and native): PostgreSQL (pog), MySQL 8.0 (shork), SQLite (sqlight). The per-engine support matrix lives in doc/capabilities.md.
First time here? doc/tutorials/getting-started-sqlite.md walks through a SQLite project end to end, and examples/sqlite-basic/ is the runnable version of the same tutorial. The rest of this README is reference material.
- CLI (
sqlode generateetc.): BEAM only (escript). The supported drivers —pog,shork,sqlight— are BEAM-native, so this is intentional. sqlode/runtime: cross-target. Pure value transformation (prepare(query, params)→#(String, List(Value))) with no FFI. Importable from a JavaScript-target Gleam app.- Generated modules: cross-target. They depend only on
sqlode/runtimeand the driver chosen by the consumer; the JS-target story is gated by whether a JavaScript-callable driver is available downstream.
sqlode ships as an Erlang escript, so most paths need Erlang/OTP on the host. Option D (Docker) bundles Erlang, and option E (mise) manages both the escript and Erlang together.
Whichever install path you pick, your Gleam project still needs gleam add sqlode because generated code imports sqlode/runtime.
curl -fsSL https://raw.githubusercontent.com/nao1215/sqlode/main/scripts/install.sh | shWrites the latest release escript to $HOME/.local/bin/sqlode and warns if Erlang/OTP is missing. To review the script first, download it, read it, then sh install.sh.
Environment variables:
SQLODE_VERSION=v0.1.0pins a release tag instead oflatest.SQLODE_INSTALL_DIR=/path/to/bininstalls elsewhere. System paths needsudo.
If $HOME/.local/bin is not on your PATH, add it:
export PATH="$HOME/.local/bin:$PATH"Grab the escript from GitHub Releases and put it on your PATH:
chmod +x sqlode
./sqlode generate --config=sqlode.yamlgleam add sqlode
gleam run -m sqlode -- generatedocker run --rm -v "$PWD:/work" ghcr.io/nao1215/sqlode:latest init --engine=sqlite
docker run --rm -v "$PWD:/work" ghcr.io/nao1215/sqlode:latest generateThe container's working directory is /work, so mounting your project there lets init / generate / verify write into the host. Swap :latest for a version tag (:0.10.0) to pin a release. The :latest tag appears once the docker workflow has run on main; before that, docker build -t sqlode . at the repo root produces the same image.
If you already use mise for managing Gleam and Erlang:
mise plugin add sqlode https://github.com/nao1215/sqlode.git#mise-plugin
mise install sqlode@latestThis installs the escript and manages versions alongside your Gleam/Erlang toolchain. Pin a version in .mise.toml:
[tools]
sqlode = "0.12.0"mise handles PATH automatically — no manual exports needed.
# standalone CLI
sqlode init
# or via Gleam
gleam run -m sqlode -- initThis creates sqlode.yaml along with stub files db/schema.sql and db/query.sql:
version: "2"
sql:
- schema: "db/schema.sql"
queries: "db/query.sql"
engine: "postgresql"
gen:
gleam:
out: "src/db"
runtime: "raw"schema and queries each take a single path, a list of paths, or a directory (sqlode then picks up every .sql in it). An optional name on each sql block shows up in diagnostics when several blocks are configured.
The schema parser accepts either a schema snapshot or a migration history (additive and destructive DDL both work). The full supported-statement list is in Schema DDL scope.
Schema (db/schema.sql):
CREATE TABLE authors (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
bio TEXT,
created_at TIMESTAMP NOT NULL
);Queries (db/query.sql):
-- name: GetAuthor :one
SELECT id, name, bio
FROM authors
WHERE id = $1;
-- name: ListAuthors :many
SELECT id, name
FROM authors
ORDER BY name;
-- name: CreateAuthor :exec
INSERT INTO authors (name, bio)
VALUES (sqlode.arg(author_name), sqlode.narg(bio));# standalone CLI
sqlode generate
# or via Gleam
gleam run -m sqlode -- generateThis writes params.gleam and queries.gleam under the configured output directory. models.gleam is added when the schema defines tables or when a :one / :many query returns result columns.
pub type GetAuthorParams {
GetAuthorParams(id: Int)
}
pub fn get_author_values(params: GetAuthorParams) -> List(Value) {
[runtime.int(params.id)]
}
pub type CreateAuthorParams {
CreateAuthorParams(author_name: String, bio: Option(String))
}One record per table in the schema, plus row types for queries that return results. When a query's columns exactly match a table (same columns, types, nullability, order), sqlode emits an alias instead of a duplicate record.
// Table record (singularized), reusable across queries
pub type Author {
Author(id: Int, name: String, bio: Option(String), created_at: String)
}
// Exact match: alias
pub type GetAuthorRow =
Author
// Partial match: separate row type
pub type ListAuthorsRow {
ListAuthorsRow(id: Int, name: String)
}Each query is a RawQuery(params). all() / QueryInfo enumerate queries without type parameters.
pub type QueryInfo {
QueryInfo(name: String, sql: String, command: runtime.QueryCommand, param_count: Int)
}
pub fn all() -> List(QueryInfo) { ... }
pub fn get_author() -> runtime.RawQuery(params.GetAuthorParams) { ... }
pub fn list_authors() -> runtime.RawQuery(Nil) { ... }
pub fn create_author() -> runtime.RawQuery(params.CreateAuthorParams) { ... }For the common case, call the generated prepare_* helper. It builds the params record and returns the (sql, values) tuple that Gleam database drivers accept directly:
let #(sql, values) = queries.prepare_get_author(id: 1)
// sql: "... WHERE id = $1"sqlode.slice works the same way — pass a List, the SQL expands to the right number of placeholders:
let #(sql, values) = queries.prepare_get_authors_by_ids(ids: [1, 2, 3])
// sql: "... WHERE id IN ($1, $2, $3)"If you need the RawQuery descriptor (caching, batching, custom wrappers), the low-level shape is still there:
let q = queries.get_author()
let #(sql, values) = runtime.prepare(q, params.GetAuthorParams(id: 1))The placeholder dialect ($1 / ?) is baked into the RawQuery, so runtime.prepare does not take it as an argument.
The runtime option controls what code sqlode emits.
| Mode | Generated files | DB driver | Use case |
|---|---|---|---|
raw |
queries, params, models | — | You run the queries yourself |
native |
queries, params, models, adapter | pog / sqlight / shork | Full adapter: bind params, decode rows |
sqlode itself must be a runtime dependency (not just dev) because the generated code imports sqlode/runtime. native mode also needs a driver:
gleam add sqlode
gleam add pog # PostgreSQL native
gleam add sqlight # SQLite native
gleam add shork # MySQL nativegen.gleam.vendor_runtime: true copies the sqlode/runtime module into the output directory as runtime.gleam and rewrites the generated imports to match. The generated package then only needs sqlode as a dev dependency. Native adapters still need their driver.
gen:
gleam:
out: "src/db"
runtime: "raw"
vendor_runtime: trueShared-runtime is smaller and updates with gleam update sqlode; vendored is self-contained but has to be regenerated to pick up runtime changes.
With runtime: "native", sqlode generates an adapter that wraps pog (PostgreSQL), sqlight (SQLite), or shork (MySQL 8.0). The three adapters have the same shape; MySQL routes :execrows through SELECT ROW_COUNT() and :execlastid through SELECT LAST_INSERT_ID() under the hood.
Out of scope today: MariaDB is not separately validated — the mysql engine targets MySQL 8.0. :execresult is rejected on every native target; use :exec, :execrows, or :execlastid. BLOB / BINARY round-trip through shork_ffi.coerce (the same identity FFI shork's value constructors use), so no shork API extension is needed.
gen:
gleam:
out: "src/db"
runtime: "native"An adapter function handles parameter binding, execution, and decoding:
// pog_adapter.gleam (generated)
pub fn get_author(db: pog.Connection, p: params.GetAuthorParams)
-> Result(Option(models.GetAuthorRow), pog.QueryError)import db/params
import db/sqlight_adapter
import gleam/io
import gleam/option
import sqlight
pub fn main() {
let assert Ok(db) = sqlight.open(":memory:")
// Create table
let assert Ok(_) = sqlight.exec(
"CREATE TABLE authors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
bio TEXT
);",
db,
)
// :exec — returns Result(Nil, sqlight.Error)
let assert Ok(_) = sqlight_adapter.create_author(
db,
params.CreateAuthorParams(
author_name: "Alice",
bio: option.Some("Author bio"),
),
)
// :one — returns Result(Option(Row), sqlight.Error)
let assert Ok(option.Some(author)) = sqlight_adapter.get_author(
db,
params.GetAuthorParams(id: 1),
)
io.debug(author.name) // "Alice"
// :many — returns Result(List(Row), sqlight.Error)
let assert Ok(authors) = sqlight_adapter.list_authors(db)
io.debug(authors) // [ListAuthorsRow(id: 1, name: "Alice")]
}import db/params
import db/pog_adapter
import gleam/io
import gleam/option
import pog
pub fn main() {
let db = pog.default_config()
|> pog.host("localhost")
|> pog.database("mydb")
|> pog.connect()
// :one — returns Result(Option(Row), pog.QueryError)
let assert Ok(option.Some(author)) = pog_adapter.get_author(
db,
params.GetAuthorParams(id: 1),
)
io.debug(author.name)
// :many — returns Result(List(Row), pog.QueryError)
let assert Ok(authors) = pog_adapter.list_authors(db)
io.debug(authors)
}MySQL works in both modes. raw returns the prepared SQL plus encoded params; native generates a mysql_adapter that wraps shork.
sql:
- engine: "mysql"
schema: "db/schema.sql"
queries: "db/query.sql"
gen:
gleam:
out: "src/db"
runtime: "raw"-- name: GetAuthor :one
SELECT id, email, display_name
FROM authors
WHERE id = ?;import db/params
import db/queries
import sqlode/runtime
pub fn fetch(id: Int) -> #(String, List(runtime.Value)) {
runtime.prepare(queries.get_author(), params.GetAuthorParams(id:))
}sql:
- engine: "mysql"
schema: "db/schema.sql"
queries: "db/query.sql"
gen:
gleam:
out: "src/db"
runtime: "native"import db/mysql_adapter
import db/params
import gleam/option
import shork
pub fn main() {
let assert Ok(db) = shork.connect(shork.default_config())
// :execlastid — returns the AUTO_INCREMENT id of the new row.
let assert Ok(id) =
mysql_adapter.create_author(
db,
params.CreateAuthorParams(
email: "[email protected]",
display_name: "Alice",
bio: option.None,
is_active: True,
avatar: option.None,
),
)
// :one — returns Result(Option(Row), shork.QueryError).
let assert Ok(option.Some(author)) =
mysql_adapter.get_author(db, params.GetAuthorParams(id:))
let _ = author.display_name
Nil
}| Annotation | sqlight return type | pog return type | shork return type |
|---|---|---|---|
:one |
Result(Option(Row), sqlight.Error) |
Result(Option(Row), pog.QueryError) |
Result(Option(Row), shork.QueryError) |
:many |
Result(List(Row), sqlight.Error) |
Result(List(Row), pog.QueryError) |
Result(List(Row), shork.QueryError) |
:exec |
Result(Nil, sqlight.Error) |
Result(Nil, pog.QueryError) |
Result(Nil, shork.QueryError) |
:execrows |
Result(Int, sqlight.Error) |
Result(Int, pog.QueryError) |
Result(Int, shork.QueryError) |
:execlastid |
Result(Int, sqlight.Error) |
Result(Int, pog.QueryError) |
Result(Int, shork.QueryError) |
:batchone, :batchmany, :batchexec, and :copyfrom are not implemented and fail generation — see Planned annotations.
:execresult is raw only. Native rejects it because it is indistinguishable from :execrows once rows are decoded.
| Annotation | Description |
|---|---|
:one |
Returns at most one row |
:many |
Returns zero or more rows |
:exec |
Returns nothing |
:execresult |
Returns the execution result (raw runtime only) |
:execrows |
Returns the number of affected rows |
:execlastid |
Returns the last inserted ID |
Reserved for future work; any use fails generation today.
| Annotation | Planned behavior |
|---|---|
:batchone |
Batch variant of :one |
:batchmany |
Batch variant of :many |
:batchexec |
Batch variant of :exec |
:copyfrom |
Bulk insert |
| Macro | Description |
|---|---|
sqlode.arg(name) |
Names a parameter |
sqlode.narg(name) |
Names a nullable parameter |
sqlode.slice(name) |
Expands to a list parameter for IN clauses |
sqlode.embed(table) |
Embeds all columns of a table into the result |
@name |
Shorthand for sqlode.arg(name) |
Prefix with -- sqlode:skip to exclude a query from generation — useful when the SQL uses syntax sqlode cannot yet parse.
-- sqlode:skip
-- name: ComplexQuery :many
SELECT ...;-- name: GetAuthorsByIds :many
SELECT id, name FROM authors
WHERE id IN (sqlode.slice(ids));Generates a parameter with type List(Int):
pub type GetAuthorsByIdsParams {
GetAuthorsByIdsParams(ids: List(Int))
}Important
sqlode.slice(...) is only supported on PostgreSQL because the
generated native SQLite (sqlight) and MySQL (shork) adapters
cannot bind array parameters at runtime. sqlode generate /
sqlode verify reject any block that targets sqlite or mysql
with a slice macro present.
If you need IN-clause expansion against SQLite/MySQL, drop the macro and either:
- inline the placeholders yourself in the SQL and bind one parameter per element from your application code, or
- move the query to
runtime: rawand callruntime.preparefollowed by your driver's parameter-list call directly, where you control how the list flattens to scalar bindings.
-- name: GetBookWithAuthor :one
SELECT sqlode.embed(authors), books.title
FROM books
JOIN authors ON books.author_id = authors.id
WHERE books.id = $1;The embedded table becomes a nested field:
pub type GetBookWithAuthorRow {
GetBookWithAuthorRow(authors: Author, title: String)
}Columns from JOINed tables are resolved against their source tables:
-- name: GetBookWithAuthor :one
SELECT books.title, authors.name
FROM books
JOIN authors ON books.author_id = authors.id;books.title and authors.name end up correctly typed in the generated row.
PostgreSQL RETURNING columns become the result type:
-- name: CreateAuthor :one
INSERT INTO authors (name, bio) VALUES ($1, $2)
RETURNING id, name;pub type CreateAuthorRow {
CreateAuthorRow(id: Int, name: String)
}Common Table Expressions are supported — sqlode strips the CTE prefix and infers types from the main query:
-- name: GetRecentAuthors :many
WITH filtered AS (
SELECT id FROM authors WHERE id > 0
)
SELECT authors.id, authors.name
FROM authors
JOIN filtered ON authors.id = filtered.id;| SQL type | Gleam type |
|---|---|
| INT, INTEGER, SMALLINT, BIGINT, SERIAL, BIGSERIAL | Int |
| FLOAT, DOUBLE, REAL, NUMERIC, DECIMAL, MONEY | Float |
| BOOLEAN, BOOL | Bool |
| TEXT, VARCHAR, CHAR | String |
| BYTEA, BLOB, BINARY | BitArray |
| TIMESTAMP, DATETIME | String |
| DATE | String |
| TIME, TIMETZ, INTERVAL | String |
| UUID | String |
| JSON, JSONB | String |
TYPE[], TYPE ARRAY |
List(TYPE) |
| CITEXT, INET, CIDR, MACADDR, XML, BIT, TSVECTOR, TSQUERY | String |
| POINT, LINE, LSEG, BOX, PATH, POLYGON, CIRCLE | String |
| PostgreSQL ENUM | Generated custom type (with to_string/from_string helpers) |
Nullable columns (no NOT NULL) are wrapped in Option(T).
Each sql block can carry type overrides and column renames:
sql:
- schema: "db/schema.sql"
queries: "db/query.sql"
engine: "postgresql"
gen:
gleam:
out: "src/db"
overrides:
types:
- db_type: "uuid"
gleam_type: "String"
- column: "users.id"
gleam_type: "String"
renames:
- table: "authors"
column: "bio"
rename_to: "biography"Two targeting modes:
db_type— every column of a given database type (e.g. everyuuidbecomesString).column— a specific column viatable.column(e.g. onlyusers.id).
Column-level overrides win over db_type overrides.
A non-primitive gleam_type (e.g. UserId instead of Int) keeps the name in generated record fields but, by default, encodes and decodes through the underlying primitive. That works when the mapped type is a transparent alias:
// OK: transparent alias — the underlying primitive encoders apply
// directly because `UserId` is just `Int` at runtime.
pub type UserId = IntFor opaque domain types (single-constructor wrappers around the underlying value), declare explicit encode / decode codec hooks alongside the override. The generated code then routes the value through the user-supplied pair instead of calling the primitive encoder on the wrapped form:
overrides:
types:
- db_type: "int"
gleam_type: "myapp/types.UserId"
encode: "user_id_to_int"
decode: "int_to_user_id"// myapp/types.gleam
pub opaque type UserId {
UserId(Int)
}
pub fn user_id_to_int(id: UserId) -> Int {
let UserId(inner) = id
inner
}
pub fn int_to_user_id(value: Int) -> UserId {
UserId(value)
}encode and decode MUST be specified together — providing only one half is rejected at config-load time. Hook function names are resolved relative to the module of gleam_type: with gleam_type: "myapp/types.UserId" the generated code calls types.user_id_to_int(value) (the trailing-segment alias of the existing type import), so the user only needs the type itself in scope.
Without codec hooks, sqlode emits a warning at generation time pointing at the transparent-alias requirement; the warning is suppressed when codec hooks are provided.
sqlode checks that gleam_type starts with an uppercase letter and encode / decode start with a lowercase letter (or underscore).
By default UUID, JSON, DATE, TIME, TIMESTAMP become String. type_mapping opts into richer aliases:
gen:
gleam:
out: "src/db"
type_mapping: "rich"| SQL type | string (default) |
rich |
strong |
|---|---|---|---|
| TIMESTAMP / DATETIME | String |
SqlTimestamp |
SqlTimestamp(String) |
| DATE | String |
SqlDate |
SqlDate(String) |
| TIME / TIMETZ | String |
SqlTime |
SqlTime(String) |
| UUID | String |
SqlUuid |
SqlUuid(String) |
| JSON / JSONB | String |
SqlJson |
SqlJson(String) |
rich is a plain String alias — readable in signatures, not enforced by the compiler. strong emits a single-constructor wrapper with an *_to_string helper; SqlUuid and String are then distinct at compile time, and adapters wrap / unwrap values automatically.
Example with type_mapping: "strong":
// Generated in models.gleam
pub type SqlUuid {
SqlUuid(String)
}
pub fn sql_uuid_to_string(value: SqlUuid) -> String {
let SqlUuid(inner) = value
inner
}sqlode is still early. A few constraints to check before adopting it; most are tracked for future releases.
sqlode infers a parameter's type from its surrounding SQL. Four contexts are recognised today:
INSERT INTO t (col) VALUES ($1)— parameter inheritscol's type.WHERE col = $1(and!=,<,<=,>,>=).WHERE col IN ($1, $2, ...)andsqlode.slice($1).$1::int/CAST($1 AS int)— explicit cast.
Anywhere else, sqlode fails generation with:
Query "Name": could not infer type for parameter $N. Use a type cast (e.g. $N::int) to specify the type
Cases that need an explicit cast today: scalar arithmetic (price + $1), parameters inside CASE WHEN branches whose other branches are also parameters, and function arguments sqlode does not yet recognise. Pin the type with $N::int (PostgreSQL) or CAST($N AS INTEGER) (SQLite).
The schema parser accepts both schema snapshots and migration histories (including destructive DDL). Supported statements:
CREATE TABLE,CREATE VIEW,CREATE TYPE(enum)ALTER TABLE ... ADD COLUMN/DROP COLUMNALTER TABLE ... RENAME TO/RENAME COLUMNALTER TABLE ... ALTER COLUMN TYPE/SET NOT NULL/DROP NOT NULLDROP TABLE,DROP VIEW,DROP TYPE
Anything else (CREATE INDEX, transaction blocks, comments) is silently skipped.
CREATE VIEW ... AS SELECT ... columns resolve against the base tables so generated models have real types. By default sqlode fails generation when any view column cannot be resolved — a partially resolved view is almost always a sign that the schema and the config have drifted, and silently dropping columns lets that drift reach generated code.
If you need the old warn-and-continue behaviour, set strict_views: false:
sql:
- schema: "db/schema.sql"
queries: "db/query.sql"
engine: "postgresql"
gen:
gleam:
out: "src/db"
strict_views: falseUnresolvable columns are then printed to stderr and dropped (or the whole view is dropped if nothing resolves).
See Custom type aliases. Without codec hooks, the mapped gleam_type MUST be a transparent alias (pub type UserId = Int) — the generated code calls primitive encoders directly on the value. Opaque single-constructor types (pub opaque type UserId { UserId(Int) }) are supported via the explicit encode / decode hooks documented there.
Attach the original SQL as a comment on each generated adapter function.
gen:
gleam:
out: "src/db"
emit_sql_as_comment: trueKeep table names as-is instead of singularising. authors stays pub type Authors { ... } (default would be Author).
gen:
gleam:
out: "src/db"
emit_exact_table_names: true# Standalone escript
sqlode generate [--config=./sqlode.yaml]
sqlode verify [--config=./sqlode.yaml]
sqlode init [--output=./sqlode.yaml]
# Via Gleam
gleam run -m sqlode -- generate [--config=./sqlode.yaml]
gleam run -m sqlode -- verify [--config=./sqlode.yaml]
gleam run -m sqlode -- init [--output=./sqlode.yaml]
verify is the static check lane for CI. It loads the project like generate does — schema parsing, query parsing, analyser pass — but writes no files and collects every failure into a single report instead of short-circuiting on the first error.
$ sqlode verify
Verifying config: sqlode.yaml
[src/db] query "FilterAuthors" has 4 inferred parameter(s), exceeds query_parameter_limit 3
Non-zero exit on any finding, so it gates generation in CI:
- run: sqlode verify
- run: sqlode generatePer-block policies verify honours:
strict_views— promote view-resolution warnings to findings (same asgenerate).query_parameter_limit— per-query cap on inferred parameters, mirroring sqlc's option. Unset means no limit.
sqlode follows sqlc conventions, so most SQL files move over untouched. The differences:
| sqlc | sqlode | |
|---|---|---|
| Install | Standalone binary (brew install sqlc) |
Escript or gleam add sqlode |
| Config | sqlc.yaml / sqlc.json |
sqlode.yaml (v2 format only), also accepts sqlc.yaml / sqlc.yml / sqlc.json on autodiscovery |
| Generate | sqlc generate |
sqlode generate |
| Init | sqlc init |
sqlode init |
| Vet/Verify | sqlc vet, sqlc verify |
sqlode verify (static analysis + query_parameter_limit) |
| Target language | Go, Python, Kotlin, etc. | Gleam |
| Runtime | Generated code is self-contained | Generated code imports sqlode/runtime by default; set vendor_runtime: true to vendor a copy and drop the runtime dependency (see Self-contained generation) |
-
Install sqlode — see Install.
-
Keep your existing
sqlc.yaml/sqlc.yml/sqlc.json.sqlode generateauto-discovers them in the current directory when no--configis passed (search order:sqlode.yaml,sqlode.yml,sqlc.yaml,sqlc.yml,sqlc.json; pass--config=<path>if more than one exists). If you prefer a dedicated file, copy the config tosqlode.yaml. Either way, keepversion: "2"and thesqlblocks and replace thegensection:gen: gleam: out: "src/db" runtime: "raw" # or "native"
-
Swap
sqlc.arg/sqlc.narg/sqlc.slice/sqlc.embedfor thesqlode.*versions in your.sqlfiles. The@nameshorthand is unchanged. -
Run
sqlode generate(orgleam run -m sqlode -- generate).
sqlc.yamlv1 formatvetandverifycommandsemit_json_tagsand other sqlc-specific emit options not listed above