Getting Started
This guide is an introduction to lode, a port of Elixir’s Ecto to Gleam. lode gives you schemas, changesets, a typed query builder, and a repository for talking to your database — with one API regardless of which adapter sits underneath. In this guide we’ll cover the basics: creating a project, connecting to a database, and the full create / read / update / delete cycle. If you’d like a higher-level tour first, start with the Overview.
Because Gleam has no macros, no runtime reflection, and no exceptions, lode is a functional re-expression of Ecto rather than a literal translation: repositories are plain values you pass around, schemas are explicit Schema(row) values, every operation returns a Result, and query expressions are ordinary first-class values. We’ll point out each of these differences as we hit them; the full rationale lives in DESIGN.md in the repository.
This guide assumes you have PostgreSQL installed and running locally, and Gleam (with the Erlang/OTP toolchain) installed.
Adding lode to an application
Let’s create a new Gleam application so we can try things out:
$ gleam new friends
$ cd friends
Next we add the dependencies to gleam.toml. We need three things: lode (the core package), lode_sql (the SQL renderer, migrations, and the PostgreSQL adapter), and pog, the PostgreSQL driver — the same shape as Ecto’s ecto_sql + postgrex pairing. We also pull in gleam_erlang (for naming the connection pool process) and argv (for the migration entrypoint we’ll write shortly):
[dependencies]
gleam_stdlib = ">= 1.0.0 and < 2.0.0"
gleam_erlang = ">= 1.3.0 and < 2.0.0"
lode = { path = "../lode/lode" }
lode_sql = { path = "../lode/lode_sql" }
pog = ">= 4.1.0 and < 5.0.0"
argv = ">= 1.0.0 and < 2.0.0"
Note: the packages are not on Hex yet (the names
lodeandlode_sqlbelong to Elixir’s Ecto, and a rename is pending), so for nowlodeandlode_sqlare path dependencies pointing at a local checkout of the lode repository. Adjust the paths to wherever you cloned it.
Then install everything:
$ gleam deps download
In Elixir, the next steps would be mix ecto.gen.repo, a config/config.exs entry, and registering the repo module in your supervision tree. lode re-expresses all three: there is no application config system and no repo module — a repo is a value built from an adapter, and the adapter is built from a database connection. The pog pool is itself a supervised process, started when you call pog.start, so there is nothing extra to wire into a supervision tree (see DESIGN.md in the repository).
Let’s put the connection in one shared module, src/db.gleam:
// src/db.gleam
import gleam/erlang/process
import gleam/option.{Some}
import pog
/// Connect to the local `friends` database.
pub fn connect() -> pog.Connection {
let assert Ok(started) =
pog.default_config(process.new_name("friends"))
|> pog.host("localhost")
|> pog.database("friends")
|> pog.user("user")
|> pog.password(Some("pass"))
|> pog.rows_as_map(True)
|> pog.start
started.data
}
Two notes on this configuration:
- Swap
"user"and"pass"for your PostgreSQL credentials. If your local PostgreSQL trusts the OS user, you can drop thepog.passwordline entirely; a non-standard port ispog.port(5433). If you’d rather configure via the environment, read the standard libpq variables (PGHOST,PGPORT,PGDATABASE,PGUSER,PGPASSWORD) yourself —lode_sql/test/test_db.gleamin the repository shows that pattern. - The
pog.rows_as_map(True)line is required: the PostgreSQL adapter consumes rows as name-keyed maps.
With a connection in hand, building the repo is one line wherever you need it:
import db
import lode/adapters/postgres
import lode/repo
let r = repo.new(postgres.new(db.connect()))
We’ll call this value r for the rest of the guide and pass it explicitly to every operation. Multiple databases? Build multiple repo values.
Not on Postgres? lode also ships a SQLite adapter (
lode/adapters/sqlite, on thesqlightdriver):repo.new(sqlite.new(conn))whereconncomes fromsqlight.open("my_db.sqlite")(or":memory:"). Everything in this guide — the schema, changesets, queries, and migrations — works identically; the renderer is dialect-parameterized and the only line that changes is the adapter. SQLite is embedded, so there’s no server to run.
Setting up the database
The database itself doesn’t exist yet. Ecto would create it with mix ecto.create; lode has no storage-management tooling, so we use PostgreSQL’s own tools:
$ createdb friends
Database create/drop tooling. Ecto’s
storage_up/storage_down(behindmix ecto.create/ecto.drop) have an equivalent:storage.create(repo, database:)/storage.drop(repo, database:), run against aRepoconnected to another database (e.g.postgres). There’s nomix-style CLI, so call them from agleam runentrypoint (or keep usingcreatedb/docker-compose, as inexamples/codegen_demo). TASK:storage-up-down
Now we need a people table. Schema changes are made through migrations, and here lode diverges from Ecto’s file-per-migration layout: a migration is an ordinary Gleam value built with lode/migration, and your project keeps a list of them. Add the list to src/db.gleam:
// src/db.gleam (continued)
import lode/migration.{type Migration}
import lode/migration/ddl
/// All of the application's migrations, in version order.
pub fn migrations() -> List(Migration) {
[
migration.new(20_260_612_000_001)
|> migration.create_table(name: "people", columns: [
ddl.column(name: "id", of: ddl.Serial) |> ddl.primary_key,
ddl.column(name: "first_name", of: ddl.Text) |> ddl.not_null,
ddl.column(name: "last_name", of: ddl.Text) |> ddl.not_null,
ddl.column(name: "age", of: ddl.Integer) |> ddl.not_null,
]),
]
}
The version number plays the role of the timestamp in an Ecto migration filename — any ascending integer works, and a YYYYMMDDhhmmss-style datetime is the convention. Note the table name is pluralized (people); like Ecto, that’s convention rather than requirement.
Like create table inside Ecto’s change/0, migration.create_table is automatically reversible: rolling back renders a DROP TABLE. For DDL the builder doesn’t cover, migration.execute(up, down) takes explicit raw SQL for each direction.
Generators.
lode/migration/genscaffolds a migration module (gen.module_source+gen.file_name, a<name>.gleam— no timestamp prefix, since a Gleam module name can’t start with a digit; ordering is by theversioninside).lode/gen/schemais the macro-lessmix phx.gen.schema: parsefield:typeargs into aspec.table(...)snippet (spec mode) or a finished schema module (--standalone), plus thecreate_tablemigration. There’s alsogen.context(schema + context + migration, likemix phx.gen.context). See the Generators guide for the full command reference, and the divergences entry for the Ecto mapping.
To run migrations, Ecto has mix ecto.migrate; lode ships the same engine as a library (lode/migrator, which tracks applied versions in a schema_migrations table and runs each migration in its own transaction), and you expose it as a gleam run entrypoint. Create src/migrate.gleam:
// src/migrate.gleam
import argv
import db
import lode/adapters/postgres
import lode/migrator
import lode/repo
pub fn main() {
let r = repo.new(postgres.new(db.connect()))
let assert Ok(_) = migrator.run(r, db.migrations(), argv.load().arguments)
}
Now migrate:
$ gleam run -m migrate migrate
Applied 1 migration(s): 20260612000001
The same entrypoint dispatches rollback [n] and status. If you made a mistake, revert the most recent migration with:
$ gleam run -m migrate rollback
We could then fix the migration and run gleam run -m migrate migrate again.
Creating the schema
A schema is the bridge between a database table and a Gleam type. In Ecto, use Ecto.Schema + the schema macro define a struct and its reflection metadata in one go. Gleam has neither macros nor reflection, so lode splits this into explicit, ordinary code: a record type, a Schema(row) value describing the table and how to load/dump the record, and one small typed accessor per field for the query builder. Create src/person.gleam:
// src/person.gleam
import lode/changeset.{type Changeset}
import lode/field
import lode/query/expr.{type FieldRef, FieldRef}
import lode/schema.{type Schema}
import lode/types/primitive
import lode/value.{type Value, VInt, VString}
import gleam/dict.{type Dict}
import gleam/result
pub type Person {
Person(id: Int, first_name: String, last_name: String, age: Int)
}
pub fn person_schema() -> Schema(Person) {
schema.new(
source: "people",
fields: [
schema.primary_key("id", primitive.id()),
schema.field("first_name", primitive.string()),
schema.field("last_name", primitive.string()),
schema.field("age", primitive.integer()),
],
load: fn(row) {
use id <- result.try(schema.require_int(row, "id"))
use first_name <- result.try(schema.require_string(row, "first_name"))
use last_name <- result.try(schema.require_string(row, "last_name"))
use age <- result.try(schema.require_int(row, "age"))
Ok(Person(id:, first_name:, last_name:, age:))
},
dump: fn(p: Person) {
dict.from_list([
#("id", VInt(p.id)),
#("first_name", VString(p.first_name)),
#("last_name", VString(p.last_name)),
#("age", VInt(p.age)),
])
},
)
}
// Typed field accessors for the query builder. `binding: 0` refers to the
// query's first (and here only) table.
pub fn person_id() -> FieldRef(Person, Int) {
FieldRef(binding: 0, name: "id", lode_type: primitive.id())
}
pub fn person_first_name() -> FieldRef(Person, String) {
FieldRef(binding: 0, name: "first_name", lode_type: primitive.string())
}
pub fn person_last_name() -> FieldRef(Person, String) {
FieldRef(binding: 0, name: "last_name", lode_type: primitive.string())
}
pub fn person_age() -> FieldRef(Person, Int) {
FieldRef(binding: 0, name: "age", lode_type: primitive.integer())
}
// Name-only column references for changeset / on_conflict column lists, so they
// read `person_fields.first_name` instead of a bare `"first_name"` (codegen
// emits this too).
pub type PersonFields {
PersonFields(
id: field.Field(Person),
first_name: field.Field(Person),
last_name: field.Field(Person),
age: field.Field(Person),
)
}
pub const person_fields = PersonFields(
id: field.Field("id"),
first_name: field.Field("first_name"),
last_name: field.Field("last_name"),
age: field.Field("age"),
)
A few things to note:
- The type name is singular (
Person) while the table is plural (people) — the same convention Ecto uses. schema.primary_key("id", primitive.id())marksidas an autogenerated key: it is omitted from insert payloads so the database’sSERIALassigns it.- The
load/dumpfunctions are the codecs Ecto derives by reflection. They’re boilerplate, and you don’t have to write them forever: lode’s schema-first workflow lets you describe tables once as a pure-data spec (lode/schema/spec) and havelode/codegengenerate the record, theSchema, the typed query accessors, the*Fieldscolumn-reference record, and more — with no database connection. Seeexamples/codegen_demoin the repository, and DESIGN.md §14. - Our migration made every column
NOT NULL, which keeps the field types plain. A nullable column would instead useprimitive.nullable(primitive.integer()), making the record field aprimitive.NullableValue(Int)(Present(27)orAbsent) — Gleam has nonil, so nullability is always explicit in the type.
The record itself is plain Gleam. There’s no REPL like IEx, so to play along put snippets in main in src/friends.gleam and gleam run, printing values with echo:
let ryan = Person(id: 0, first_name: "Ryan", last_name: "Bigg", age: 27)
echo ryan.age
// 27
// Records are immutable; the update syntax returns a new record:
let ryan = Person(..ryan, age: 28)
echo ryan
// Person(id: 0, first_name: "Ryan", last_name: "Bigg", age: 28)
One re-expression worth dwelling on: in Elixir, %Friends.Person{} is an “empty” struct whose fields are all nil. Gleam records are closed and total — every field always has a value — so the closest thing to an empty person is one built from zero values: Person(id: 0, first_name: "", last_name: "", age: 0). We’ll use that as the base record for casting params shortly.
Inserting data
Let’s insert a person. repo.insert takes a changeset (we’ll meet changesets properly in the next section); the simplest one is changeset.change, which wraps a record with no pending changes:
// src/friends.gleam
import db
import lode/adapters/postgres
import lode/changeset
import lode/repo
import person.{Person, person_schema}
pub fn main() {
let r = repo.new(postgres.new(db.connect()))
let nobody = Person(id: 0, first_name: "", last_name: "", age: 0)
let assert Ok(inserted) =
repo.insert(
repo: r,
schema: person_schema(),
changeset: changeset.change(data: nobody, schema: person_schema()),
)
echo inserted
// Person(id: 1, first_name: "", last_name: "", age: 0)
}
The insert runs as INSERT ... RETURNING *: the placeholder id: 0 was dropped (the field is autogenerated) and the returned record carries the id the database assigned — here 1.
repo.insert returns Result(Person, LodeError), so success pattern-matches as Ok(person). Where Ecto returns {:error, changeset} — say, when a unique constraint on an email column is violated — lode returns an Error carrying an LodeError value such as ConstraintError or ChangesetInvalid. There are no insert!-style raising variants anywhere in the library: every fallible operation returns a Result, and you choose between case, let assert Ok(_), or result combinators at the call site (see DESIGN.md in the repository).
Validating changes
We just stored a person with a blank name, which we probably don’t want. In lode, as in Ecto, data validation happens in changesets: you cast untrusted params onto a record, run validations, and only then hand the changeset to the repo.
It’s idiomatic to give the schema module a changeset function. Add this to src/person.gleam:
pub fn person_changeset(
person: Person,
params: Dict(String, Value),
) -> Changeset(Person) {
changeset.cast(
data: person,
schema: person_schema(),
params: params,
permitted: field.fields(["first_name", "last_name", "age"]),
)
|> changeset.validate_required(field.fields(["first_name", "last_name"]))
}
Two steps, just like Ecto’s cast/3 + validate_required/2:
changeset.castfilters the params down to thepermittedfields and casts each against its field type. Params are aDict(String, Value)—Value(VString,VInt, …) is lode’s tagged boundary type for dynamic data, the role raw terms play in Ecto. Unknown or absent params are simply skipped; a value of the wrong shape adds an"is invalid"error.changeset.validate_requiredrequires the named fields to be present and non-blank. Matching Ecto, an empty string counts as blank.
Now try inserting with no params at all:
import gleam/dict
let nobody = Person(id: 0, first_name: "", last_name: "", age: 0)
let cs = person.person_changeset(nobody, dict.new())
echo cs.valid
// False
case repo.insert(repo: r, schema: person_schema(), changeset: cs) {
Ok(saved) -> echo saved
Error(err) -> echo err
}
// ChangesetInvalid(errors: [
// #("first_name", FieldError(message: "can't be blank", meta: [#("validation", "required")])),
// #("last_name", FieldError(message: "can't be blank", meta: [#("validation", "required")])),
// ])
The repo refuses to insert an invalid changeset. Where Ecto hands you back the changeset inside {:error, changeset}, lode returns Error(ChangesetInvalid(errors: ...)) carrying the per-field errors; the changeset value you built is still in scope if you need it. Each error pairs a field name with a FieldError: a message plus metadata key/value pairs, mirroring Ecto’s error keyword lists. Because errors is already a plain list of tuples, there’s no traverse_errors/2 equivalent to learn — list.map over it to build human-readable output.
You can also interrogate the changeset before going anywhere near the database: cs.valid is the changeset.valid? of lode, and cs.errors holds the accumulated errors (newest first).
With valid params, the same function gives an insertable changeset:
import lode/value.{VString}
let cs =
person.person_changeset(
nobody,
dict.from_list([
#("first_name", VString("Ryan")),
#("last_name", VString("Bigg")),
]),
)
let assert Ok(ryan) = repo.insert(repo: r, schema: person_schema(), changeset: cs)
// Person(id: 2, first_name: "Ryan", last_name: "Bigg", age: 0)
One caveat carried straight over from Ecto: cs.valid only reflects casts and validations. Database-enforced rules — unique indexes, foreign keys — can only fail at insert time, coming back as an Error from the repo. See Constraints and upserts.
Our first queries
Let’s get some data worth querying. Since we’ve inserted a couple of junk rows along the way, this is a good moment to reset — as noted earlier there is no mix ecto.drop, so:
$ dropdb friends
$ createdb friends
$ gleam run -m migrate migrate
Then seed three people from main:
import gleam/list
let people = [
Person(id: 0, first_name: "Ryan", last_name: "Bigg", age: 28),
Person(id: 0, first_name: "John", last_name: "Smith", age: 27),
Person(id: 0, first_name: "Jane", last_name: "Smith", age: 26),
]
list.each(people, fn(p) {
let assert Ok(_) =
repo.insert(
repo: r,
schema: person_schema(),
changeset: changeset.change(data: p, schema: person_schema()),
)
})
Queries are built with lode/query and the expression helpers in lode/query/expr, then executed by the repo. Where Ecto needs two syntaxes (keyword and macro/pipe), lode has exactly one: ordinary function calls piped together.
Fetching a single record
Ecto’s Ecto.Query.first is shorthand for “order by primary key, take one”; lode has no shorthand, but the composition is short enough to write directly:
import lode/query
import lode/query/expr
import gleam/option.{Some}
import person.{person_id}
let first_query =
query.from(source: "people", alias: "p")
|> query.order_by([query.asc(expr.col(person_id()))])
|> query.limit(1)
This is a Query value — nothing has touched the database yet, exactly like an unexecuted Ecto.Query. To run it, pass it to repo.one:
let assert Ok(Some(ryan)) =
repo.one(repo: r, schema: person_schema(), query: first_query)
// Person(id: 1, first_name: "Ryan", last_name: "Bigg", age: 28)
For the last record, swap query.asc for query.desc.
repo.one returns Result(Option(row), LodeError), re-expressing Ecto’s behavior as values: where Repo.one returns nil for no rows, you get Ok(None); where it raises Ecto.MultipleResultsError for more than one, you get Error(MultipleResults(count: ...)).
Fetching all records
repo.all runs a query and returns every matching row, loaded through the schema:
let assert Ok(everyone) =
repo.all(repo: r, schema: person_schema(), query: query.from(source: "people", alias: "p"))
// [Person(id: 1, ...), Person(id: 2, ...), Person(id: 3, ...)]
Fetch a single record based on its ID
To fetch by primary key, use repo.get. The key is passed as a Value, since primary keys come in many types:
import lode/value.{VInt}
let assert Ok(Some(person)) =
repo.get(repo: r, schema: person_schema(), id: VInt(1))
// Person(id: 1, first_name: "Ryan", last_name: "Bigg", age: 28)
Fetch a single record based on a specific attribute
For lookups by something other than the key, repo.get_by takes a list of column/value pairs that must all match:
import lode/value.{VString}
let assert Ok(Some(person)) =
repo.get_by(
repo: r,
schema: person_schema(),
filters: [#("first_name", VString("Ryan"))],
)
// Person(id: 1, first_name: "Ryan", last_name: "Bigg", age: 28)
Like repo.one, both return Ok(None) when nothing matches.
Filtering results
For everyone with a given attribute, build a query with query.where. Conditions are built from the typed field accessors we wrote in src/person.gleam:
import person.{person_last_name}
let smiths =
query.from(source: "people", alias: "p")
|> query.where(expr.eq(field: person_last_name(), to: "Smith"))
let assert Ok(smith_family) =
repo.all(repo: r, schema: person_schema(), query: smiths)
// [Person(id: 2, "John", "Smith", 27), Person(id: 3, "Jane", "Smith", 26)]
If you’re curious what a query will run as, lode_sql can render any Query to SQL plus its parameters:
import lode/query/sql
let #(sql, params) = sql.to_sql(smiths)
// sql: "SELECT * FROM \"people\" AS p WHERE (p.\"last_name\" = $1)"
// params: [VString("Smith")]
This is also where one of Ecto’s sharpest learning curves simply disappears. In Ecto, writing where: p.last_name == last_name is a compile error — variables must be pinned (^last_name) so the macro knows to interpolate a runtime value as a query parameter. lode has no macros: expr.eq(field: person_last_name(), to: last_name) is a plain function call, the variable is just an argument, and every literal becomes a $n parameter — there is no pin operator and no string interpolation to be injection-prone:
let last_name = "Smith"
query.from(source: "people", alias: "p")
|> query.where(expr.eq(field: person_last_name(), to: last_name))
Better still, the accessors carry the column’s type, so the equivalent of Ecto’s CastError for age == "Smith" happens at compile time: expr.eq(field: person_age(), to: "Smith") does not compile, because person_age() is a FieldRef(Person, Int).
Composing queries
Queries are immutable values, so refining one is just more piping — each query.where ANDs another condition on (use query.or_where for OR):
let smiths =
query.from(source: "people", alias: "p")
|> query.where(expr.eq(field: person_last_name(), to: "Smith"))
// Later, somewhere else, narrow it further:
let jane =
smiths
|> query.where(expr.eq(field: person_first_name(), to: "Jane"))
let assert Ok(just_jane) = repo.all(repo: r, schema: person_schema(), query: jane)
// [Person(id: 3, first_name: "Jane", last_name: "Smith", age: 26)]
The base query is untouched, so it can be shared, stored, and extended in different directions — the same composability that makes Ecto queries pleasant, without any macro machinery. Because expressions like expr.eq(...) are themselves first-class values, Ecto’s dynamic/2 has no equivalent here for the simple reason that it isn’t needed; see Dynamic queries.
Updating records
Updating follows the same changeset path as inserting: fetch the record, cast the new params over it, and hand the changeset to repo.update. Let’s bump Ryan’s age:
import lode/value.{VInt}
let assert Ok(Some(ryan)) =
repo.get(repo: r, schema: person_schema(), id: VInt(1))
let cs = person.person_changeset(ryan, dict.from_list([#("age", VInt(29))]))
let assert Ok(ryan) = repo.update(repo: r, schema: person_schema(), changeset: cs)
echo ryan.age
// 29
repo.update filters by the record’s primary key, applies only the changed columns, and returns the updated record. If the changeset is invalid the database is never touched:
let cs = person.person_changeset(ryan, dict.from_list([#("first_name", VString(""))]))
let result = repo.update(repo: r, schema: person_schema(), changeset: cs)
// Error(ChangesetInvalid(errors: [
// #("first_name", FieldError(message: "can't be blank", meta: [#("validation", "required")])),
// ]))
In real code, handle both outcomes with a case:
case repo.update(repo: r, schema: person_schema(), changeset: cs) {
Ok(person) -> // do something with person
Error(err) -> // ChangesetInvalid, ConstraintError, AdapterError, ...
}
As with insert, there is no raising update! variant — the Result is the whole story.
Deleting records
Deleting closes out CRUD: fetch the record, then pass it to repo.delete, which removes the row matching its primary key:
let assert Ok(Some(person)) =
repo.get(repo: r, schema: person_schema(), id: VInt(1))
let assert Ok(Nil) = repo.delete(repo: r, schema: person_schema(), row: person)
One behavioral difference from Ecto: Repo.delete returns {:ok, struct} with the deleted struct (its metadata marked :deleted), while lode returns Ok(Nil) — Gleam records carry no metadata, and the record you deleted is already in hand if you need its fields. Failures (for example a foreign-key constraint protecting the row) come back as Error(LodeError) rather than {:error, changeset}.
That’s the full cycle: project setup, migrations, a schema, and create/read/update/delete through changesets and the typed query builder. From here, good next stops are CRUD for a deeper pass over these operations, Data mapping and validation for more on changesets, Associations for related records, and Divergences from Ecto for a complete map of what differs from Elixir’s Ecto.