Hex | GitHub | Documentation
Execute raw SQL in strings or .sql files, get maps and structs back. Built on top of ecto_sql.
SqlKit provides two ways to execute SQL with automatic result transformation:
- Direct SQL execution - Execute SQL strings directly with any Ecto repo
- File-based SQL - Keep SQL in dedicated files with compile-time embedding
Sometimes raw SQL is the right tool for the job. Complex analytical queries, reports with intricate joins, or database-specific features often demand SQL that's awkward to express through an ORM.
You can do this already with Repo.query, however this returns a result struct with separate columns and rows lists. SqlKit handles this for you, returning maps [%{id: 1, name: "Alice"}, ...] or structs [%User{id: 1, name: "Alice"}, ...] directly.
For file-based SQL, keeping queries in .sql files brings other practical benefits like syntax highlighting, and SQL formatter support. It also makes your codebase more accessible to SQL-fluent team members who can read, review, and contribute queries without needing to learn Elixir first. How .sql files are loaded is configurable by environment: Reading from disk in development for fast iteration, and embedding at compile time in production to eliminate unnecessary I/O.
- Just SQL: No DSL or special syntax to learn.
- Automatic result transformation: Query results returned as maps or structs, not raw columns/rows
- Two APIs: Execute SQL strings directly or load from files
- Compile-time embedding: File-based SQL read once at compile time and stored as module attributes
- Dynamic loading in dev/test: Edit SQL files without recompiling
- Multi-database support: Works with PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, and ClickHouse
| Database | Ecto Adapter | Driver |
|---|---|---|
| PostgreSQL | Ecto.Adapters.Postgres | Postgrex |
| SQLite | Ecto.Adapters.SQLite3 | Exqlite |
| MySQL | Ecto.Adapters.MyXQL | MyXQL |
| MariaDB | Ecto.Adapters.MyXQL | MyXQL |
| SQL Server | Ecto.Adapters.Tds | Tds |
| ClickHouse | Ecto.Adapters.ClickHouse | Ch |
Add sql_kit to your dependencies in mix.exs:
def deps do
[
{:sql_kit, "~> 0.1.0"}
]
endExecute SQL strings directly with any Ecto repo:
# Get all rows as a list of maps
SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21])
# => [%{id: 1, name: "Alice", age: 30}, %{id: 2, name: "Bob", age: 25}]
# Get a single row
SqlKit.query_one!(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1])
# => %{id: 1, name: "Alice", age: 30}
# Cast results to structs
SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users", [], as: User)
# => [%User{id: 1, name: "Alice", age: 30}, ...]
# Non-bang variants return {:ok, result} or {:error, reason}
SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1])
# => {:ok, %{id: 1, name: "Alice"}}
# ClickHouse uses named parameters as a map
SqlKit.query_all!(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21})
# => [%{id: 1, name: "Alice", age: 30}, ...]For larger queries or better organization, keep SQL in dedicated files:
SQL files are housed in subdirectories under the root SQL directory. This is priv/repo/sql by default but is configurable via :root_sql_dir config option. The priv/ directory is recommended because these files are included in Mix releases by default.
-- priv/repo/sql/reports/stats.sql
SELECT id, name, total_sales
FROM users
WHERE id = $1defmodule MyApp.Reports.SQL do
use SqlKit,
otp_app: :my_app,
repo: MyApp.Repo,
dirname: "reports",
files: ["stats.sql", "activity.sql"]
end# Get a single row as a map
MyApp.Reports.SQL.query_one!("stats.sql", [user_id])
# => %{id: 1, name: "Alice", total_sales: 1000}
# You can also use query!/3, which is an alias for query_one!/3
MyApp.Reports.SQL.query!("stats.sql", [user_id])
# => %{id: 1, name: "Alice", total_sales: 1000}
# Get all rows
MyApp.Reports.SQL.query_all!("activity.sql", [company_id])
# => [%{id: 1, ...}, %{id: 2, ...}]
# Cast results to structs
MyApp.Reports.SQL.query_one!("stats.sql", [id], as: UserStats)
# => %UserStats{id: 1, name: "Alice", total_sales: 1000}
# Load the raw SQL string
MyApp.Reports.SQL.load!("stats.sql")
# => "SELECT id, name, total_sales..."# config/config.exs
config :my_app, SqlKit,
root_sql_dir: "priv/repo/sql" # default
# config/dev.exs and config/test.exs
config :my_app, SqlKit,
load_sql: :dynamic # read from disk at runtime
# config/prod.exs (or rely on default)
config :my_app, SqlKit,
load_sql: :compiled # use compile-time embedded SQLEach database uses different parameter placeholder syntax:
| Database | Syntax | Example |
|---|---|---|
| PostgreSQL | $1, $2, ... |
WHERE id = $1 AND age > $2 |
| MySQL | ? |
WHERE id = ? AND age > ? |
| SQLite | ? |
WHERE id = ? AND age > ? |
| SQL Server | @1, @2, ... |
WHERE id = @1 AND age > @2 |
| ClickHouse | {name:Type} |
WHERE id = {id:UInt32} AND age > {age:UInt32} |
ClickHouse uses named parameters with explicit types. Pass parameters as a map:
# SQL file: user_by_id.sql
# SELECT * FROM users WHERE id = {id:UInt32}
ClickHouseSQL.query_one!("user_by_id.sql", %{id: 1})For databases using positional parameters, wrap SqlKit calls in functions to get named parameter ergonomics:
# SQL string
defmodule MyApp.Users do
alias MyApp.Users.User
def get_active_users(company_id, min_age) do
SqlKit.query_all!(MyApp.Repo, """
SELECT id, name, email, age
FROM users
WHERE company_id = $1
AND age >= $2
AND active = true
ORDER BY name
""", [company_id, min_age], as: User)
end
end
# SQL file
defmodule MyApp.Users do
alias MyApp.Users.User
def get_active_users(company_id, min_age) do
MyApp.Users.SQL.query_all!("active_users.sql", [company_id, min_age], as: User)
end
end
# Usage
MyApp.Users.get_active_users(123, 21)
# => [%User{id: 1, name: "Alice", email: "[email protected]", age: 30}, ...]This pattern gives you named parameters through Elixir function arguments while keeping queries as plain SQL.
:otp_app(required) - Your application name:repo(required) - The Ecto repo module to use for queries:dirname(required) - Subdirectory within root_sql_dir for this module's SQL files:files(required) - List of SQL filenames to load
These functions are defined directly on the SqlKit module and work with any Ecto repo:
Executes SQL and returns all rows as a list of maps.
SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users")
# => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]
SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21], as: User)
# => [%User{id: 1, name: "Alice"}, ...]
# ClickHouse uses named parameters as a map
SqlKit.query_all!(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21})
# => [%{id: 1, name: "Alice"}, ...]Executes SQL and returns exactly one row as a map.
- Raises
SqlKit.NoResultsErrorif no rows returned - Raises
SqlKit.MultipleResultsErrorif more than one row returned
SqlKit.query_one!(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1])
# => %{id: 1, name: "Alice"}
SqlKit.query_one!(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1], as: User)
# => %User{id: 1, name: "Alice"}
# ClickHouse uses named parameters as a map
SqlKit.query_one!(ClickHouseRepo, "SELECT * FROM users WHERE id = {id:UInt32}", %{id: 1})
# => %{id: 1, name: "Alice"}Alias for SqlKit.query_one!/4. See SqlKit.query_one!/4 documentation.
Returns {:ok, results} on success, {:error, exception} on failure.
SqlKit.query_all(MyApp.Repo, "SELECT * FROM users")
# => {:ok, [%{id: 1, name: "Alice"}, ...]}
# ClickHouse uses named parameters as a map
SqlKit.query_all(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21})
# => {:ok, [%{id: 1, name: "Alice"}, ...]}Returns {:ok, result} on one result, {:ok, nil} on no results, or {:error, exception} on multiple results or errors.
SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1])
# => {:ok, %{id: 1, name: "Alice"}}
SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [999])
# => {:ok, nil}
# ClickHouse uses named parameters as a map
SqlKit.query_one(ClickHouseRepo, "SELECT * FROM users WHERE id = {id:UInt32}", %{id: 1})
# => {:ok, %{id: 1, name: "Alice"}}Alias for SqlKit.query_one/4. See SqlKit.query_one/4 documentation.
These functions are generated by use SqlKit and available on your SQL modules:
Executes a query and returns a single row as a map.
- Raises
SqlKit.NoResultsErrorif no rows returned - Raises
SqlKit.MultipleResultsErrorif more than one row returned
SQL.query_one!("user.sql", [user_id])
# => %{id: 1, name: "Alice"}
SQL.query_one!("user.sql", [user_id], as: User)
# => %User{id: 1, name: "Alice"}
# ClickHouse uses named parameters as a map
ClickHouseSQL.query_one!("user.sql", %{user_id: 1})
# => %{id: 1, name: "Alice"}Alias for query_one!/3. See query_one!/3 documentation.
Executes a query and returns all rows as a list of maps.
SQL.query_all!("users.sql", [company_id])
# => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]
SQL.query_all!("users.sql", [company_id], as: User)
# => [%User{id: 1, name: "Alice"}, %User{id: 2, name: "Bob"}]
# ClickHouse uses named parameters as a map
ClickHouseSQL.query_all!("users.sql", %{company_id: 123})
# => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]Returns the SQL string for the given file.
SQL.load!("users.sql")
# => "SELECT * FROM users"SQL.query_one("user.sql", [user_id])
# => {:ok, %{id: 1, name: "Alice"}}
SQL.query_one("missing_user.sql", [999])
# => {:ok, nil} # No results returns nil, not an error
SQL.query_one("all_users.sql", [])
# => {:error, %SqlKit.MultipleResultsError{count: 10}}
# ClickHouse uses named parameters as a map
ClickHouseSQL.query_one("user.sql", %{user_id: 1})
# => {:ok, %{id: 1, name: "Alice"}}Alias for query_one/3. See query_one/3 documentation.
SQL.query_all("users.sql", [company_id])
# => {:ok, [%{id: 1, name: "Alice"}, ...]}
# ClickHouse uses named parameters as a map
ClickHouseSQL.query_all("users.sql", %{company_id: 123})
# => {:ok, [%{id: 1, name: "Alice"}, ...]}SQL.load("users.sql")
# => {:ok, "SELECT * FROM users"}:as- Struct module to cast results into:unsafe_atoms- Iftrue, usesString.to_atom/1instead ofString.to_existing_atom/1for column names. Default:false:query_name- Custom identifier for exceptions (standalone API only; defaults to truncated SQL)
- ASDF (Elixir + Erlang version management)
- Docker (for database containers via
docker compose up) - SQLite3 (installed locally)
-
Clone the repository
-
Run
asdf install -
Install dependencies and compile:
mix do deps.get, deps.compile, compile -
Start the database containers:
docker compose up
-
Run the tests:
mix test
The test suite runs against all supported databases (PostgreSQL, MySQL, SQLite, SQL Server, and ClickHouse). All databases must be running for the full test suite to pass.
- PostgreSQL: 5432
- MySQL: 3306
- SQL Server: 1433
- ClickHouse: 8123, 9000
SQLite uses a local file and doesn't require Docker.
Run mix check.
MIT License. See LICENSE.md for details.