Marmot - Type-Safe SQL for SQLite in Gleam
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. Inspired by
Squirrel (which does the same
for Postgres).
If you are an LLM, see llms.txt for a condensed context document.
What’s 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 probably fine if you have a few small queries but it can become quite the burden when you have a lot of queries:
- The SQL query you write is just a plain string, you do not get syntax
highlighting, auto formatting, suggestions… all the little niceties you
would otherwise get if you were writing a plain
*.sqlfile. - This also means you lose the ability to run these queries on their own with other external tools, inspect them and so on.
- You have to manually keep in sync the decoder with the query’s output.
One might be tempted to hide all of this by reaching for something like an ORM.
Marmot proposes a different approach: instead of trying to hide the SQL it
embraces it and leaves you in control.
You write the SQL queries in plain old *.sql files and Marmot will take care
of generating all the corresponding functions.
A code snippet is worth a thousand words, so let’s have a look at an example. Instead of the hand written example shown earlier you can instead just write the following query:
-- we're in file `src/users/sql/find_user.sql`
select name, email
from users
where username = ?
And run gleam run -m marmot. Just like magic you’ll now have a type-safe
function find_user you can use just as you’d expect:
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
}
Behind the scenes Marmot generates the decoders and functions you need. Generated functions use labelled
arguments (users_sql.find_user(db: db, username: "alice")) so call sites are
self-documenting.
So now you get the best of both worlds:
- You don’t have to take care of keeping encoders and decoders in sync, Marmot does that for you.
- You’re not compromising on type safety either: Marmot connects to your
SQLite database and uses
PRAGMA table_infoandEXPLAINto understand the types of your queries. - You can stick to writing plain SQL in
*.sqlfiles. - You can run each query on its own: need to
explaina query? No big deal, it’s just a plain old*.sqlfile. - No external tools required. No
sqlcbinary, nosqlite3CLI. Marmot usessqlightdirectly.
Usage
First you’ll need to add Marmot to your project as a dev dependency:
gleam add marmot --dev
You’ll also need sqlight as a runtime dependency (the generated code calls it):
gleam add sqlight
Then you can ask it to generate code running the marmot module:
gleam run -m marmot
And that’s it! As long as you follow a couple of conventions Marmot will just work:
- Marmot will look for all
*.sqlfiles in anysqldirectory under your project’ssrcdirectory. - Each
sqldirectory will be turned into a single Gleam module containing a function for each*.sqlfile inside it. Generated modules are placed insrc/generated/sql/by default, with a_sqlsuffix to avoid import aliasing conflicts. - Each
*.sqlfile must contain a single SQL query. And the name of the file is going to be the name of the corresponding Gleam function to run that query.
Let’s make an example. 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 └── test └── my_app_test.gleamRunning
gleam run -m marmotgeneratessrc/generated/sql/users_sql.gleamwith two functionsfind_userandlist_usersyou 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:
DATABASE_URLenvironment variable--databaseCLI flagdatabasefield in[marmot]section ofgleam.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 will show a helpful 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)
}
Supported types
Marmot maps SQLite column types to Gleam types. The types that are currently supported are:
| SQLite declared type | Gleam type | Notes |
|---|---|---|
INTEGER, INT | Int | |
REAL, FLOAT, DOUBLE | Float | |
TEXT, VARCHAR, CHAR | String | |
BLOB | BitArray | |
BOOLEAN, BOOL | Bool | Stored as 0/1 |
TIMESTAMP, DATETIME | timestamp.Timestamp | Stored as Unix seconds |
DATE | calendar.Date | Stored as ISO 8601 text |
| nullable column | Option(T) |
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:
- Annotation must appear before the first SQL statement
- Type name must end in
Rowand be valid PascalCase (OrgRow,UserProfileRow) - All queries with the same annotation must return the exact same columns (names, types, nullability, order). Mismatch is a generation-time error.
- Scope is per-directory:
OrgRowinadmin/sql/is distinct fromOrgRowinpublic/sql/ - Unannotated queries keep their per-query Row type (backwards compatible)
FAQ
What flavour of SQL does Marmot support?
Marmot only supports SQLite.
Why not use Squirrel?
Squirrel is excellent and you should use it if you use Postgres. Marmot exists because Squirrel doesn’t support SQLite, and SQLite introspection works fundamentally differently (PRAGMAs and EXPLAIN instead of the Postgres wire protocol).
How does Marmot infer types?
Marmot uses PRAGMA table_info for column types and nullability, and SQLite’s
EXPLAIN to trace query result columns and parameters back to their source
table columns. This is a heuristic approach that works well for straightforward
queries.
Why isn’t Marmot configurable in any way?
Following Squirrel’s lead, Marmot leans heavily on convention over
configuration. Small projects work with zero config. The two opt-in knobs –
output and query_function – exist for real needs that convention can’t
cover: centralizing generated code somewhere other than the default, and
wrapping queries with logging or timing. They don’t change the shape of the
generated code, just where it lands and which function it calls.
Known Limitations
Fixable, worth doing eventually
These are limitations of Marmot’s current implementation, not fundamental constraints. Contributions welcome.
- Table names containing SQL keywords (
RETURNING,INTO) may confuse the string-based parser. Use simple table names for now. A proper SQL tokenizer would fix this and unlocks the next two limitations too. INSERT INTO t VALUES (?, ?)without an explicit column list will not infer parameter names or types correctly. Always specify columns:INSERT INTO t (col1, col2) VALUES (?, ?). Marmot could look up the table schema and match positionally.- Complex expressions (subqueries, CTEs,
COALESCE) may not have their types inferred. UseCAST(... AS TYPE)to help Marmot. Incremental improvements are possible per expression kind.
Design decisions, not bugs
These work by choice. We could change them, but it would be a design shift.
TIMESTAMPandDATETIMEcolumns are stored as Unix seconds (integer). Sub-second precision (nanoseconds) is not preserved. We picked integer seconds for simplicity and interop withstrftime('%s', ...). Preserving nanos would need a new storage format or type mapping.
Hard limits
Not Marmot’s to fix.
- Repeated anonymous
?placeholders that refer to the same value generate a separate function argument for each occurrence (WHERE org_id = ? AND ... WHERE org_id = ?producesorg_idandorg_id_2). This is a SQLite protocol limitation: anonymous?are always distinct bind slots. Use named parameters (@nameor:name) instead – SQLite deduplicates them natively, soWHERE org_id = @org_id AND ... WHERE org_id = @org_idgenerates a singleorg_idargument. Named parameters are also self-documenting and generally preferable. WHERE id IN (?)with a dynamic list is not supported. SQLite has no native array parameter type, so there is no way to bind a list to a single?. Workarounds:- Write separate queries for known list sizes
- Use
json_each(?)with a JSON array string:WHERE id IN (SELECT value FROM json_each(?)) - Build the query string dynamically in your application code (outside Marmot)
Differences from Squirrel
Marmot is heavily inspired by Squirrel but has diverged in several ways:
- Target: SQLite instead of Postgres.
- Output directory: Marmot defaults to
src/generated/sql/with a_sqlfilename suffix (e.g.,users_sql.gleam). Squirrel placessql.gleamas a sibling of thesql/directory. - Named parameters: Marmot supports
@name,:name, and$nameplaceholders natively. Squirrel uses Postgres$1positional parameters and generatesarg_1,arg_2names. - Return type signatures: Generated functions include explicit
-> Result(List(RowType), sqlight.Error)return types. - Formatting: Generated code is run through
gleam formatautomatically, so it never causes diffs when users run the formatter. - Shared return types: Queries in the same
sql/directory can share a single Row type and decoder via a-- returns: EntityRowannotation. Eliminates adapter boilerplate when multiple queries return the same shape.
Why not just fork Squirrel?
Marmot deliberately mirrors Squirrel’s ergonomics, so this question comes up. The surface layers (SQL file conventions, code generation, formatting, configuration) could have been forked. The engine underneath couldn’t. Postgres and SQLite expose type information in fundamentally incompatible ways, so the “figure out the types” part had to be built from scratch.
Specifically:
- Postgres’ wire protocol returns full type info for parameters and result columns when a statement is prepared. SQLite doesn’t.
- SQLite columns have affinity, not types. A
TEXTcolumn can store anINTEGER, and the engine never commits to a type for a result slot. - Result columns have to be traced through
EXPLAINopcodes (OpenRead/Column/Rowid/ResultRow) back to physical table columns, then joined againstPRAGMA table_infofor the declared type. - Nullability has to be derived from joins, subqueries, and
COALESCEusage instead of reported by the engine. Marmot tracks nullable-cursor sets through the opcode trace. - Computed columns (
COUNT,CAST,COALESCE,CASE,SUM, window functions) carry no type information and need a shape-based inference fallback that pattern-matches on expression text. - Parameter types are reverse-engineered from usage sites (
WHERE col = ?,LIMIT ?,CAST(? AS TEXT)) instead of reported. - Repeated
@nameparameters need a dedup/unification pass to pick a single inferred type across occurrences. - SQLite-specific quirks (
WITHOUT ROWID,STRICTtables,INTEGER PRIMARY KEYaliasingrowid, affinity rules) have no Postgres analogue and required their own handling.
Squirrel’s output shape is the right one for SQLite too, and we’ve kept it. The path from SQL to “here are the types” is a different problem in SQLite, so Marmot’s type-inference pipeline is its own.
Credits
Marmot’s design, conventions, and approach are directly inspired by Squirrel by Giacomo Cavalieri. Squirrel targets Postgres with beautiful ergonomics. Marmot brings that same experience to SQLite. In fact, Marmot aims to be a near 1:1 mirror of Squirrel’s syntax and conventions, so switching between the two should feel seamless.
If you use Postgres, use Squirrel. If you use SQLite, use Marmot.
Contributing
If you think there’s any way to improve this package, or if you spot a bug don’t be afraid to open PRs, issues or requests of any kind! Any contribution is welcome.