Divergences from Ecto

These guides are a 1:1 port of Elixir’s Ecto v3.14 guides to lode. Most of Ecto translates directly. Where it doesn’t, the guides flag it in place, and this page is the consolidated map.

There are two distinct kinds of difference, and only one of them is a gap:

  1. Design re-expressions. Gleam has no macros, no runtime reflection, no behaviours/protocols, and no process dictionary, and it models failure with Result rather than exceptions. So Ecto’s macro query syntax becomes builder functions, __schema__ reflection becomes explicit Schema(row) values, the implicit global/dynamic repo becomes a Repo value you pass, and bang functions (insert!) and telemetry are dropped. These are deliberate and are not tracked as tasks — they are explained inline in the guides and in DESIGN.md in the repository (see especially §1 and §11). They are not listed below.

  2. Not-yet-implemented gaps. Real Ecto features with no lode equivalent yet. Each is marked in the guides with a [TASK:<slug>] token and has a matching entry below (same slug, same anchor) plus a tracked issue in the project’s bd tracker. This is that list.

Every [TASK:<slug>] you see in a guide links to the #<slug> anchor on this page. Each entry records what Ecto does, what lode does today (the workaround), the tracking issue, and which guides reference it.


Query builder

Subqueries — resolved

Resolved (lode-s3o, lode-qqw, lode-bx4). A subquery as a FROM source (query.from_subquery) and WHERE x IN/NOT IN (subquery) (query.where_in_subquery / where_not_in_subquery) are expressible in the typed builder, with the subquery’s parameters threaded into the outer $n sequence. Correlated / EXISTS subqueries and subquery-as-JOIN-source are covered too — see Correlated / EXISTS subqueries. Referenced by: Aggregates and subqueries.

Correlated / EXISTS subqueries — resolved

Resolved (lode-qqw). query.where_exists / query.where_not_exists render WHERE EXISTS (...) / NOT EXISTS (...), and a subquery references the enclosing query’s bindings with expr.outer(alias, name) (Ecto’s parent_as). The outer alias is rendered verbatim — the subquery is lexically nested, so the binding is already in scope — which sidesteps the positional-binding model without any cross-query alias plumbing. The subquery’s $n parameters are threaded into the outer statement’s sequence. Correlated EXISTS needs a SQL adapter; the in-memory adapter evaluates only uncorrelated EXISTS (see the in-memory coverage note).

A subquery as a JOIN source is also covered now (lode-bx4): query.join_subquery(q, kind, subquery, alias, on) renders <kind> JOIN (<subquery>) AS alias ON <on>, threading the subquery’s params — so subqueries work as a FROM source, in IN/EXISTS, and as a JOIN source. Referenced by: Aggregates and subqueries.

Association-derived joins — resolved

Resolved (lode-epe). association.join(query, schema, name, kind, alias) derives a join’s source and ON from a declared association (Ecto’s join: c in assoc(p, :comments)): the child table and ON child.fk = parent.<key> come from the association on schema, which is the query’s from binding. The owner-side column is the parent’s primary key for has_many/has_one, or a belongs_to’s owner_column. It returns a ResultError for an unknown or non-joinable association (many_to_many/through, which still need repo.query_raw). The joined child takes the next binding, so reference its columns with expr.Col(1, "..."); as with any join, project explicitly (query.select) rather than relying on SELECT *, which clobbers same-named columns. Referenced by: Dynamic queries.

Preload via JOIN — resolved

Resolved for has_many/has_one/belongs_to/many_to_many (lode-crv, lode-v21, lode-qig). preload.join("comments") (through query.preload + repo.all/repo.one) loads the association in the parent’s own query via a LEFT JOIN, projecting each side’s columns under a distinct prefix and splitting them back with schema.load_prefixed — Ecto’s preload-through-a-join, expressed as a strategy on the preload rather than a named join binding (Gleam has no query-macro binding syntax). The join’s parent column is the primary key for has_many/has_one; a belongs_to declares its parent foreign key with association.owner_column("author_id") (codegen emits it automatically). A per-association where/preload_order is honored — rebound to the joined binding and pushed into the ON clause, so the LEFT JOIN still keeps childless parents. repo.one dedups the joined rows to the single parent (by the parent’s primary key, so distinct parents sharing a foreign key don’t collapse); nested preloads on the joined children still run batched. many_to_many works too — it joins through the join table (two LEFT JOINs) and groups the leaves by the parent row rather than a key match.

has_many :through and multiple join preloads (lode-pdt, lode-m5w). A has_many :through loads via a join preload too: two LEFT JOINs (parent → mid → leaf), grouping leaves by the parent row and deduping by the leaf primary key. The hops default to primary keys; when a hop goes via a non-key column, association.through_columns(parent_column:, mid_column:) names the columns for the ON clauses (the batched through loader already keys off the value extractors, so only the join form needed this). More than one preload.join runs as a single combined query: each association contributes its JOIN(s) and prefixed child columns to one query (binding ranges allocate sequentially), the query runs once, parents are deduped by primary key, and each association’s children are split back out of the shared rows — deduping the cartesian repetition that sibling joins produce. So loading two associations is one round-trip, not two. Two further notes: join preload is SQL-adapter only (the in-memory adapter doesn’t evaluate joins), and a parent limit/offset combined with a has_many join counts joined rows, not parents — paginate with a batched preload instead, and note that several to-many joins multiply the intermediate row count before dedup. Referenced by: Associations.

UNION / UNION ALL — resolved

Resolved (lode-a0b). query.union(q, other) and query.union_all(q, other) combine result sets (the second operand is parenthesized; an outer limit applies to the whole). To order a union, wrap it with query.from_subquery and order the outer query. Referenced by: Self-referencing many to many.

Row locking (FOR UPDATE) — resolved

Resolved (lode-x45). query.lock(q, "FOR UPDATE") appends a lock clause (rendered verbatim at the end of the SELECT); run it inside a repo.transaction. Referenced by: Overview.

first / last helpers — resolved

Resolved (lode-f0n). query.first(q, by:) and query.last(q, by:) order by the field (ascending / descending) and take one row. Referenced by: CRUD.

Query / schema prefixes — resolved

Resolved (lode-220). query.prefix(q, schema) schema-qualifies reads (FROM

Window functions — resolved

Resolved (lode-p49). expr.over(call, partition_by:, order_by:) projects a window application in select — the callee is a ranking function (expr.row_number(), expr.rank(), expr.dense_rank()) or any aggregate expression (expr.sum, expr.count_all, …), and the window’s ordering is built with expr.win_asc/expr.win_desc. Named windows mirror Ecto’s windows:: declare with query.window(q, name, partition_by:, order_by:) and project with expr.over_named(call, window) — rendered as a standard WINDOW <name> AS (...) clause between HAVING and ORDER BY. Both dialects render identical standard SQL (SQLite has window functions since 3.25; the bundled amalgamation is 3.50.x). Out of scope: frame clauses (ROWS BETWEEN ...) are not modeled — use repo.query_raw for frames. Window expressions are SQL-only — the in-memory adapter does not evaluate them (see in-memory query coverage). Referenced by: Aggregates and subqueries.

Common table expressions (with_cte) — resolved

Resolved (lode-ac3). query.with_cte(q, name:, as_:, recursive:) attaches a named CTE, rendered as a single WITH "name" AS (...), ... prefix — WITH RECURSIVE when any attached CTE is recursive (one keyword covers the list, as in both engines). The CTE’s parameters render before the main statement’s, taking the first $n/?n numbers, exactly like from_subquery’s threading. Reference the CTE by name as an ordinary source (query.from(source: name, ...) or a join); a recursive body is base |> query.union_all(step) with the step referencing the CTE’s own name. Inside a CTE body, union operands render without parentheses — SQLite rejects parenthesized compound operands and the recursive form requires the bare shape — so CTE-body union operands must not carry their own order_by/limit. Works on both engines (SQLite has had CTEs since 3.8.3). Out of scope: CTEs on update_all/delete_all (SELECT-only) and CTE column aliases (WITH name (cols) AS ... — name columns with an explicit select in the body); use repo.query_raw for those. SQL-only — the in-memory adapter does not evaluate CTEs (see in-memory query coverage). Referenced by: Aggregates and subqueries. (The label is as_ because as is a Gleam keyword.)


Aggregates and reads

repo.aggregate and COUNT(*) — resolved

Resolved (lode-94o). repo.aggregate(repo:, query:, by:) now computes sum/avg/min/max/count server-side, and repo.count pushes SELECT count(*) down — falling back to materialize-and-count only when the query carries limit/offset/group_by/distinct (which need a subquery; see Subqueries). Referenced by: Aggregates and subqueries.

Repo.stream — resolved

Resolved (lode-amt, lode-c5h). repo.stream_fold(repo:, schema:, query:, batch_size:, from:, with:) folds a query’s full result set in batches, holding only one batch of loaded rows at a time; repo.stream_for_each is the side-effecting form. On Postgres this is a real server-side cursor: the adapter opens (or nests into) a transaction, DECLAREs a cursor over the query — $n parameters and all — FETCH FORWARD batch_size at a time folding each batch, then CLOSEs. So peak memory is one batch regardless of result size, and every row comes from one MVCC snapshot — concurrent writes never duplicate or skip a row, and there are no OFFSET re-scans (an O(n) read). The in-memory adapter has no cursor engine, so it folds its matched rows directly (a test convenience, not bounded-memory).

What still differs (shape, not capability — a design re-expression, not a gap): Ecto’s Repo.stream returns a lazy Enumerable you pipe into Enum/Flow and must wrap in Repo.transaction yourself. Gleam has no lazy stream type and no macros, so lode exposes a fold (stream_fold) / side-effect loop (stream_for_each) instead, and manages the transaction for you. Rows arrive in the query’s order; with no order_by the stream defaults to primary-key order (an index scan, so still cheap) for determinism across calls. Referenced by: CRUD.

Loading schemas from raw rows — resolved

Resolved (lode-o33). repo.query_raw_as(repo:, schema:, sql:, params:) runs raw SQL and loads each result row into a typed struct via schema.load, in one call (columns matched by name). Referenced by: CRUD, Schemaless queries.

repo.reload

Open (lode-oil). Ecto’s Repo.reload/2 re-fetches a struct (or a list of structs) by primary key. No equivalent here. Today: call repo.get with the schema and the row’s key yourself (the list case is a WHERE pk IN query). A thin helper over schema.primary_key + repo.get.


Repo writes

insert_or_update from primary-key presence

Implemented, with a divergent decision rule (lode-o4j). repo.insert_or_update(repo:, schema:, changeset:) exists and delegates to insert/update (so association writes, prefixes, and constraint-error mapping all carry through).

The divergence: Ecto decides insert-vs-update from the struct’s __meta__.state:built (never persisted) inserts, :loaded (read from the DB) updates. lode has no struct metadata, so it decides from the primary key via schema.primary_key_present: if every primary-key field on the changeset’s data is set — not NULL, not integer 0, not "" — the row is updated; otherwise it is inserted. This is the same persisted-vs-new rule cast_assoc/put_assoc already apply to child rows.

The consequences to know:

Referenced by: CRUD.


Bulk writes and update_all

update_all with expression values — resolved

Resolved (lode-ube). repo.update_all_set(repo:, query:, set:) takes query.Assignments: query.set_expr("col", to: <expr>) sets a column from an arbitrary expression (e.g. expr.Col(0, "other"), or a function of it), beside the literal query.set. repo.update_all (literal #(col, value) pairs) is unchanged. Referenced by: Associations.

update_all inc / push / pull — resolved

Resolved (lode-dzs). query.inc("col", by:) (col = col + n), query.push("col", value) (array_append), and query.pull("col", value) (array_remove) build query.Assignments for repo.update_all_set. In-memory, inc is evaluated (numeric arithmetic); push/pull (array ops) need Postgres. Note Postgres rejects two assignments to the same column in one UPDATE, so push and pull the same column in separate calls. Referenced by: Schemaless queries.

Schemaless insert_all — resolved

Resolved (lode-320). repo.insert_all_raw(repo:, source:, columns:, rows:) bulk-inserts value lists into a bare table with no schema (Ecto’s Repo.insert_all("table", rows)), returning the inserted count; insert_all_raw_returning returns the inserted Rows. The schema-based repo.insert_all/upsert_all remain for typed rows. Referenced by: Schemaless queries.


Ecto.Multi

Multi step coverage — resolved

Resolved (lode-c5t). lode/multi now covers Ecto’s core step set: the changeset steps (insert, update, delete, run), bulk steps (insert_all, update_all, delete_all — the stored result is the affected count), query steps (one, all, exists), put, and composition (merge, append, prepend). Multi.insert_or_update/4, Multi.error/3, and Multi.inspect/2 have no dedicated step — a run step covers each (call repo.insert_or_update on the transaction-scoped repo, return Error(..) to short-circuit, or print the results so far and pass them through). merge receives the results so far and its steps run at the merge position. Step names must be unique across the composed multi: transaction returns Error(MultiFailure(name:, reason: QueryError(..))) for a duplicate — before any step runs for statically-known names, at the merge point (rolling back) for merge-introduced ones. DIVERGENCE (shape): Ecto raises ArgumentError at add/merge time; lode raises nothing, so the check runs when transaction executes — and it also rejects same-name steps that previously overwrote each other silently. The transaction-scoped repo the steps run on inherits the outer repo’s prepare hook. Referenced by: CRUD cheatsheet.


Changesets and constraints

Schemaless changesets — resolved

Resolved (lode-21y). schema.schemaless([schema.field("q", primitive.string()), ...]) builds a Schema(Dict(String, Value)) over a bare row — Ecto’s cast({data, types}, params, keys). The fields supply the casting/validation types; load/dump are the identity, so the whole changeset machinery (cast, validators, apply_changes) works and apply_changes returns a Dict(String, Value). Referenced by: Data mapping and validation.

Constraint errors on changesets — resolved

Resolved (lode-68l). When a constraint declared on the changeset (unique_constraint/foreign_key_constraint/check_constraint) has the same database name as a returned violation, repo.insert/repo.update now convert it into a ChangesetInvalid carrying that constraint’s field error (e.g. "has already been taken"), ready for form rendering — Ecto’s {:error, changeset}. An undeclared violation still propagates as the raw Error(error.ConstraintError(..)). Referenced by: Constraints and upserts, Multi tenancy with foreign keys.

Exclusion / no-assoc constraint helpers — resolved

Resolved (lode-f5x). changeset.exclusion_constraint(field, name) and changeset.no_assoc_constraint(field, name) record the constraint like unique_constraint/foreign_key_constraint, so a violation reported by the adapter maps to a field error ({:error, changeset}) instead of a raw ConstraintError. no_assoc_constraint is a foreign-key constraint with the “is still associated with this entry” message; create the underlying exclusion constraint itself with migration.execute. Referenced by: Constraints and upserts.

optimistic_lock — resolved

Resolved (lode-dnm). changeset.optimistic_lock(field) reads the current version from the changeset’s data; repo.update and repo.delete_changeset then filter the write on it (WHERE <field> = <current>), update also SETs <field> = <current> + 1, and zero affected rows — another writer got there first — surfaces as Error(error.StaleEntry), Ecto’s raised Ecto.StaleEntryError as a Result. repo.delete takes a bare struct, so a locked delete goes through the changeset-taking repo.delete_changeset. The incrementer is a fixed + 1 (no custom incrementer). Referenced by: Basic CRUD.

prepare_changes

Open (lode-mgu). Ecto’s prepare_changes/2 registers callbacks that run inside the repo transaction immediately before the write, with repo access (the classic use is bumping a counter cache). No equivalent slot on Changeset. Today: compose the extra writes explicitly with repo.transaction or multi.

unsafe_validate_unique

Open (lode-0ej). Ecto’s unsafe_validate_unique/4 runs a pre-flight SELECT so a duplicate reports as a normal validation error before the write (racy by design — the database constraint stays the authority, hence “unsafe”). Only the post-write mapping exists here (changeset.unique_constraint). Today: query for the duplicate yourself before repo.insert, and keep unique_constraint for the race.


Associations and embeds

cast_assoc id-based matching — resolved

Resolved (lode-rpg). changeset.cast_assoc_by_key(name:, existing:, blank:, params:, key_param:, child_key:, with:) matches each param map to an existing child by key_param (e.g. "id"): a match casts that child (an update preserving its other columns), a non-match casts blank (an insert), and existing children matched by no param are handled by the association’s on_replace at write time. Pass the loaded children as existing; plain cast_assoc (build-all-from-blank) remains. Referenced by: Associations.

cast_assoc :sort_param / :drop_param — resolved

Resolved (lode-8wn). changeset.sort_drop_params(params:, key_param:, sort:, drop:) reorders and removes child param maps before cast_assocdrop removes params whose key is listed, sort lists keys in the desired order (keyless/new params keep their order after). Referenced by: Associations.

many_to_many join schema — resolved

Resolved (lode-70e, lode-7ee, lode-3a5, lode-k8q). A many_to_many join table can carry more than the two foreign keys: association.join_defaults([#("col", value), ...]) sets static columns on each inserted link, and association.join_row(with: fn(parent, child) { [#("col", value), ...] }) computes columns per link from the parent and the specific child (a role, a position, an app-computed timestamp) — merged over the static defaults. Together these cover Ecto’s :join_through with a schema for the data-carrying join row. (Columns with a database default can be omitted.) association.join_row_checked(with: fn(parent, child) { Ok(cols) / Error([#("col", FieldError(..))]) }) is the fallible form: rejecting a link rolls back the whole write and surfaces ChangesetInvalid errors keyed "<assoc>.<col>" with association/join_child_key metadata — Ecto’s join-through-schema changeset. The same keying now covers invalid child changesets staged with put_assoc/cast_assoc on any writable association (lode-t16): the flush rejects the whole batch before writing anything, each error keyed "<assoc>.<field>" with association/child_index metadata. association.join_constraint(name:, message:) covers the constraint side of Ecto’s join schema (unique_constraint on the join changeset): a database violation on a new link becomes a ChangesetInvalid keyed on the association with constraint/constraint_kind + association/join_child_key metadata, rolling the write back; declare the name per engine (Postgres constraint name / SQLite "table.col, table.col"). Referenced by: Associations.

Embed identity & on_replace — resolved

Resolved (lode-dg0). embed.cast_embed_many_by_key(name:, existing:, blank:, params:, key_param:, child_key:, on_replace:, with:) matches embeds by an id field — a match casts that embed (an update preserving its id), a non-match inserts — mirroring cast_assoc_by_key for inline embeds. embed.put_new_id(p, "id") stamps a generated v4 UUID on new embeds (Ecto’s autogenerated embed binary_id); call it in your with cast. Embeds dropped from the params follow on_replace: EmbedDelete removes them (the inline default), EmbedRaise invalidates the parent. (Plain cast_embed_many — replace-the-whole-list — still exists for the simple case.) Referenced by: Embedded schemas.


Types

Interval / duration type — resolved

Resolved (lode-e7k, lode-smo). temporal.duration() is an LodeType mapping a Postgres interval, and it round-trips through the normal bound-parameter path — no inline literals or raw SQL. There is a VInterval (months, days, microseconds) Value variant: a read decodes the interval into it keeping the parts separate (Postgres never normalizes across them), and a write binds it via pgo’s interval encoder (a small FFI, since pog doesn’t expose interval values). temporal.duration().load folds a VInterval into a gleam/time/duration.Duration (a fixed span, so months = 30×86400s, days = 86400s on read); dump emits the whole span in the microseconds component. Referenced by: Duration types with pog.


Repo hooks and multi-tenancy

prepare_query / default_options — resolved

Resolved (lode-qgq). repo.with_prepare(repo, fn(query) { ... }) returns a repo whose reads and bulk writes (all/one/get/count/aggregate/ exists/update_all(_set)/delete_all/stream_fold) pass every query through the hook first — so a tenant or soft-delete scope is applied implicitly, and the hook is inherited by transaction-scoped repos. It is a function on the Repo value rather than a generated module callback (lode has no use Repo), so build one prepared repo per scope. Referenced by: Multi tenancy with foreign keys.


Migrations, DDL, and tooling

DDL coverage — resolved (foreign keys & timestamps)

Resolved (lode-cqr). The DDL layer now has the column foreign-key option and the timestamps helper:

Schema/namespace DDL is also covered now (lode-tvz): migration.create_schema(name) / drop_schema(name) emit CREATE SCHEMA / DROP SCHEMA ... CASCADE (auto-reversible), for the prefix-based multi-tenancy strategy. Referenced by: Multi tenancy with query prefixes.

Storage create / drop — resolved

Resolved (lode-l0c). lode/storage’s storage.create(repo, database:) and storage.drop(repo, database:) run CREATE DATABASE / DROP DATABASE IF EXISTS (Ecto’s storage_up/storage_down). As in Ecto, run them against a Repo connected to a different database (conventionally postgres), since Postgres can’t create or drop the database it’s connected to. There is still no mix-style CLI wrapper — call them from a gleam run entrypoint. Referenced by: Overview, Getting started, Testing with Lode.

Generators (migration & schema) — resolved

Resolved (lode-hl9). lode/migration/gen’s gen.module_source(name:, version:) returns the text of a migration module (a pub fn migration() over migration.new(version) with a commented skeleton), and gen.file_name its <name>.gleam name — no timestamp prefix, because a Gleam module name can’t start with a digit (ordering is by the version inside, and the migrations() list, not the file name).

lode/gen/schema is the macro-less mix phx.gen.schema: it parses field:type[:modifier] args (incl. references:table and :unique) into either a spec.table(...) snippet to paste into the spec (spec mode, the default, §14-pure) or a finished standalone schema module via codegen (--standalone, the §14.6 manual opt-out), plus the create_table migration. context_source additionally emits a context module of CRUD wrappers (list_/get_/create_/update_/delete_/change_ over the schema) — Phoenix’s mix phx.gen.context. There is no mix, so these are pure source generators wired into a gleam run entrypoint (examples/codegen_demo/src/gen_schema.gleam, gen_context.gleam), writing files with the codegen FFI. Referenced by: Getting started.


Testing

SQL Sandbox — resolved

Resolved (lode-bj3). lode/sandbox’s sandbox.run(repo, body) runs a test in a transaction that is always rolled back — per-test isolation without truncation, and (built on repo.transaction) a transaction the test opens inside becomes a savepoint. Not replicated (by design): Ecto’s process-ownership registry (allow/3, shared mode) — there is no process-dictionary repo (DESIGN.md §11); concurrency is “own Repo per async test.” Referenced by: Testing with Lode.

In-memory adapter query coverage — bounded by design

Resolved as a documented, intentional boundary (lode-qj6). The in-memory adapter is a single-source test aid, not a Postgres emulator. It evaluates: where (incl. like/ilike, in, is null, numeric arithmetic), order_by/limit/offset, a single aggregate select (count/sum/avg/min/max), group_by with a projected aggregate select, a subquery FROM source, and uncorrelated IN/EXISTS. It does not evaluate: joins (single-source by design — so preload.join, association.join, and join_subquery are Postgres-only), HAVING, correlated subqueries, or DISTINCT (every stored row gets a unique generated id, so full-row dedup never fires). It also does not evaluate window functions (expr.over/expr.over_named) — a windowed select comes back as plain full rows; prove window queries on Postgres or SQLite. Nor does it evaluate CTEs (query.with_cte) — the attached CTE is ignored and its name is not a stored source, so the query returns no rows; prove CTE queries on Postgres or SQLite. Test those against Postgres; use the memory adapter for changeset/logic and basic-query tests. Referenced by: Testing with Lode.

SQLite adapter — resolved

Resolved (lode-mr1). lode ships a SQLite adapter (lode/adapters/sqlite, on sqlight/esqlite) alongside Postgres — the parallel to Ecto’s Ecto.Adapters.SQLite3. The SQL/DDL renderer is parameterized by a Dialect (sql.postgres()/sql.sqlite(), ddl.postgres()/ddl.sqlite()), so the query builder, changesets, repo, and migrations are unchanged across engines — only the adapter line differs (repo.new(sqlite.new(conn))). The adapter carries an engine tag, which the migrator reads to pick the DDL dialect, so migrator.migrate targets SQLite with no caller change (SerialINTEGER PRIMARY KEY AUTOINCREMENT). ddl.timestamps() is portable too: its current-timestamp default renders per dialect (now() vs a strftime expression that matches the adapter’s Timestamp TEXT shape). SQLite is embedded, so tests run against sqlight.open(":memory:") with no server.

Type fidelity follows SQLite’s storage classes: Bool binds as 0/1, Decimal/Uuid/Date/Time/Timestamp/JSON travel as TEXT (the field’s typed load reconstructs them — the same schema round-trips on both engines). SQLite-specific limits: ON CONFLICT ON CONSTRAINT <name> is Postgres-only (use a column target — the adapter rejects it with a clear error), and UNIQUE/NOT NULL/FOREIGN KEY/CHECK violations map to a typed ConstraintError; and SQLite cannot ADD COLUMN with a non-constant default, so default_now columns must be part of create_table; repo.stream_fold on SQLite steps a prepared statement row by row (bounded memory, one row at a time — batch_size is irrelevant with no server round trips). The adapter drives a single SQLite connection, serialized by a per-adapter lock: concurrent processes sharing one repo take turns — a transaction (or a stream, for its whole walk) owns the connection for its whole body, plain writes queue and run outside it, and a transaction whose process crashes is rolled back and the lock freed. That is coarser than a Postgres pool (one writer at a time — use Postgres for parallel throughput); for parallel tests, keep one :memory: repo per test as the suite does. Codegen introspection (codegen.introspect/bootstrap_spec/generate) and drift.check are engine-aware too (sqlite_master + PRAGMA on SQLite); drift there compares at SQLite’s type-affinity level, so intent changes within one affinity (e.g. Uuid vs Text) are invisible — see the lode/drift docs. Still Postgres-only: the FOR UPDATE row lock, DISTINCT ON, schema prefixes, and migration.up_sql/down_sql as a whole-Migration SQLite render through the migrator (the per-op DDL dialect is what the migrator uses). No MySQL adapter — see MySQL / MSSQL adapters. Referenced by: Overview, Testing with Lode.

MySQL / MSSQL adapters

Open (lode-gtn, backlog). Ecto’s ecto_sql ships MySQL (myxql) and MSSQL (tds) adapters alongside Postgres. lode ships Postgres, SQLite, and the in-memory test adapter. The Dialect seam added for SQLite (lode/query/sql, lode/migration/ddl) is the extension point; a MySQL adapter also needs a Gleam MySQL driver. Filed as backlog — no tracked demand.


Tracking

All of the above are tracked in the project’s bd issue tracker under the divergence label (issue ids shown per entry). To see them:

bd list --label divergence
Search Document