Marmot - Type-Safe SQL for SQLite in Gleam

Package Version Hex Docs

Marmot is a code generator for Gleam that turns plain .sql files into type-safe functions for SQLite. Write your queries in SQL, point Marmot at your database, and it generates the Gleam functions, row types, and decoders you need. No ORM, no query builder, no manual decoder boilerplate. Heavily inspired by Squirrel (which does the same for Postgres).

If you are an LLM, see llms.txt for a condensed context document.

Why Marmot?

If you need to talk to a SQLite database in Gleam, you’ll have to write something like this:

import gleam/dynamic/decode
import sqlight

pub type FindUserRow {
  FindUserRow(name: String, email: String)
}

pub fn find_user(db: sqlight.Connection, username: String) {
  let decoder = {
    use name <- decode.field(0, decode.string)
    use email <- decode.field(1, decode.string)
    decode.success(FindUserRow(name:, email:))
  }

  sqlight.query(
    "select name, email from users where username = ?",
    on: db,
    with: [sqlight.text(username)],
    expecting: decoder,
  )
}

This is fine for a small project but gets hairy when you have a lot of queries:

Instead of hiding SQL behind an ORM, Marmot embraces it. Write your queries in plain *.sql files and Marmot generates the corresponding functions.

Instead of the hand-written example above, write the following query:

-- we're in file `src/users/sql/find_user.sql`
select name, email
from users
where username = ?

Run gleam run -m marmot. You now have a type-safe function find_user you can use as expected:

import sqlight
import generated/sql/users_sql

pub fn main() {
  use db <- sqlight.with_connection("my_app.sqlite")
  let assert Ok([user]) = users_sql.find_user(db: db, username: "alice")
  // user.name, user.email are fully typed
}

Generated functions use labelled arguments (users_sql.find_user(db: db, username: "alice")) so call sites are self-documenting.

Usage

Add Marmot as a dev dependency:

gleam add marmot --dev

Add sqlight as a runtime dependency (the generated code calls it):

gleam add sqlight

Generate code by running the marmot module:

gleam run -m marmot

As long as you follow a couple of conventions, Marmot will work out of the box:

Imagine you have a Gleam project that looks like this

src
├── users
│   └── sql
│       ├── find_user.sql
│       └── list_users.sql
├── generated
│   └── sql
│       └── users_sql.gleam   -- generated by marmot
└── my_app.gleam

Running gleam run -m marmot generates src/generated/sql/users_sql.gleam with two functions find_user and list_users you can then import and use:

import generated/sql/users_sql

users_sql.find_user(db, id: 1)

Talking to the database

In order to understand the types of your queries, Marmot needs to open the SQLite database file where your schema is defined. Marmot reads the database path with the following precedence:

  1. DATABASE_URL environment variable
  2. --database CLI flag
  3. database field in [marmot] section of gleam.toml
# Environment variable
DATABASE_URL=dev.sqlite gleam run -m marmot

# CLI flag
gleam run -m marmot -- --database dev.sqlite

# gleam.toml
# [marmot]
# database = "dev.sqlite"

If no database is configured, Marmot shows an error message listing all three options.

Configuring the output directory

By default, generated modules are placed in src/generated/sql/. If you prefer a different location, set it in gleam.toml or via CLI flag:

[marmot]
output = "src/server/generated/sql"
gleam run -m marmot -- --output src/server/generated/sql

The output directory must be under src/ (Gleam compiles modules from there).

Custom query wrapper (query_function)

By default the generated code calls sqlight.query directly. If you want to add logging, timing, or other instrumentation without forking Marmot, you can point query_function at your own wrapper:

[marmot]
database = "dev.sqlite"
query_function = "app/db.query"

With that config, the generated code imports your module and calls your function instead of sqlight.query:

// Without query_function (default):
import sqlight

pub fn find_user(db db: sqlight.Connection, username username: String) {
  sqlight.query(
    "select name, email from users where username = ?",
    on: db,
    with: [sqlight.text(username)],
    expecting: decoder,
  )
}

// With query_function = "app/db.query":
import app/db
import sqlight

pub fn find_user(db db: sqlight.Connection, username username: String) {
  db.query(
    "select name, email from users where username = ?",
    on: db,
    with: [sqlight.text(username)],
    expecting: decoder,
  )
}

Your wrapper must match sqlight.query’s labelled signature exactly:

import gleam/dynamic/decode
import sqlight

pub fn query(
  sql: String,
  on connection: sqlight.Connection,
  with parameters: List(sqlight.Value),
  expecting decoder: decode.Decoder(a),
) -> Result(List(a), sqlight.Error) {
  // Your logging / timing / instrumentation here, then delegate:
  sqlight.query(sql, on: connection, with: parameters, expecting: decoder)
}

Shared return types

When multiple queries in the same sql/ directory return the same shape, you can annotate them to share a single Row type and decoder:

-- src/app/sql/get_org.sql
-- returns: OrgRow
SELECT id, name FROM orgs WHERE id = @id
-- src/app/sql/list_orgs.sql
-- returns: OrgRow
SELECT id, name FROM orgs

Both queries now share one pub type OrgRow and one decoder function in the generated module. This eliminates the need for adapter boilerplate when multiple queries return the same columns.

Rules:

Supported types

Marmot maps SQLite column types to Gleam types. The types that are currently supported are:

SQLite declared typeGleam typeNotes
INTEGER, INTInt
REAL, FLOAT, DOUBLEFloat
TEXT, VARCHAR, CHARString
BLOBBitArray
BOOLEAN, BOOLBoolStored as 0/1
TIMESTAMP, DATETIMEtimestamp.TimestampStored as Unix seconds
DATEcalendar.DateStored as ISO 8601 text
nullable columnOption(T)

Known Limitations

Fixable, worth doing eventually

These are limitations of Marmot’s current implementation, not fundamental constraints. Contributions welcome.

Design decisions, not bugs

These work by choice. We could change them, but it would be a design shift.

Hard limits

Not Marmot’s to fix.

Differences from Squirrel

Marmot mirrors Squirrel’s ergonomics but targets SQLite instead of Postgres. The surface conventions (SQL file layout, code generation, formatting) are deliberately similar so switching between the two feels seamless. The type-inference engine underneath had to be built from scratch because Postgres and SQLite expose type information in fundamentally incompatible ways.

Where Marmot diverges on the surface (might change over time):

Where it diverges under the hood:

License

MIT

Search Document