Scythe compiles annotated SQL into type-safe database access code. You write SQL queries, scythe generates the boilerplate -- structs, functions, type mappings -- in 10 languages across 10 databases with 70+ backend drivers. Built-in linting (93 rules) and formatting catch SQL bugs before they ship.
cargo install scythe-cli
# or
brew install Goldziher/tap/scythe # uses pre-built binaries for faster installScythe provides pre-commit / prek hooks for SQL formatting and linting:
repos:
- repo: https://github.com/Goldziher/scythe
rev: v0.6.0
hooks:
- id: scythe-fmt # Format SQL files
- id: scythe-lint # Lint SQL with auto-fix
- id: scythe-generate # Regenerate code on SQL changes
- id: scythe-check # Validate SQL without generatingSee Pre-commit Hooks for all available hooks and configuration options.
1. Write annotated SQL queries:
-- @name GetUserOrders
-- @returns :many
SELECT u.id, u.name, o.total, o.notes
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = $1;2. Configure scythe.toml:
[scythe]
version = "1"
[[sql]]
name = "main"
engine = "postgresql"
schema = ["sql/schema.sql"]
queries = ["sql/queries.sql"]
output = "src/generated"
[[sql.gen]]
backend = "rust-sqlx"3. Generate code:
scythe generate4. Use the generated code:
Scythe knows o.total and o.notes are nullable (right side of LEFT JOIN) and generates precise types:
Rust (sqlx)
#[derive(Debug, sqlx::FromRow)]
pub struct GetUserOrdersRow {
pub id: i32,
pub name: String,
pub total: Option<rust_decimal::Decimal>,
pub notes: Option<String>,
}
pub async fn get_user_orders(
pool: &sqlx::PgPool, status: &str,
) -> Result<Vec<GetUserOrdersRow>, sqlx::Error> {
sqlx::query_as!(GetUserOrdersRow,
"SELECT u.id, u.name, o.total, o.notes
FROM users u LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = $1", status)
.fetch_all(pool).await
}Python (psycopg3)
@dataclass
class GetUserOrdersRow:
id: int
name: str
total: decimal.Decimal | None
notes: str | None
async def get_user_orders(
conn: AsyncConnection, *, status: str,
) -> list[GetUserOrdersRow]:
cur = await conn.execute(
"SELECT u.id, u.name, o.total, o.notes "
"FROM users u LEFT JOIN orders o ON u.id = o.user_id "
"WHERE u.status = %(status)s",
{"status": status},
)
rows = await cur.fetchall()
return [GetUserOrdersRow(id=r[0], name=r[1], total=r[2], notes=r[3]) for r in rows]TypeScript (pg)
interface GetUserOrdersRow {
id: number;
name: string;
total: string | null;
notes: string | null;
}
export async function getUserOrders(
client: PoolClient, status: string,
): Promise<GetUserOrdersRow[]> {
const { rows } = await client.query<GetUserOrdersRow>(
`SELECT u.id, u.name, o.total, o.notes
FROM users u LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = $1`, [status]);
return rows;
}Go (pgx)
type GetUserOrdersRow struct {
ID int32 `json:"id"`
Name string `json:"name"`
Total *string `json:"total"`
Notes *string `json:"notes"`
}
func GetUserOrders(ctx context.Context, pool *pgxpool.Pool, status string) ([]GetUserOrdersRow, error) {
rows, err := pool.Query(ctx,
"SELECT u.id, u.name, o.total, o.notes FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = $1",
status)
// ... scan rows into []GetUserOrdersRow
}Java (JDBC)
public record GetUserOrdersRow(
int id,
String name,
@Nullable java.math.BigDecimal total,
@Nullable String notes
) {}
public static List<GetUserOrdersRow> getUserOrders(
Connection conn, String status
) throws SQLException {
// PreparedStatement + ResultSet scanning
}Kotlin (JDBC)
data class GetUserOrdersRow(
val id: Int,
val name: String,
val total: java.math.BigDecimal?,
val notes: String?,
)
fun getUserOrders(conn: Connection, status: String): List<GetUserOrdersRow> {
conn.prepareStatement("...").use { ps ->
ps.setObject(1, status)
ps.executeQuery().use { rs -> /* scan rows */ }
}
}C# (Npgsql)
public record GetUserOrdersRow(
int Id, string Name, decimal? Total, string? Notes
);
public static async Task<List<GetUserOrdersRow>> GetUserOrders(
NpgsqlConnection conn, string status
) {
await using var cmd = new NpgsqlCommand("...", conn);
cmd.Parameters.AddWithValue("p1", status);
await using var reader = await cmd.ExecuteReaderAsync();
// read rows
}Elixir (Postgrex)
defmodule GetUserOrdersRow do
@type t :: %__MODULE__{
id: integer(), name: String.t(),
total: Decimal.t() | nil, notes: String.t() | nil
}
defstruct [:id, :name, :total, :notes]
end
@spec get_user_orders(pid(), String.t()) :: {:ok, [%GetUserOrdersRow{}]} | {:error, term()}
def get_user_orders(conn, status) do
case Postgrex.query(conn, "...", [status]) do
{:ok, %{rows: rows}} -> {:ok, Enum.map(rows, &to_struct/1)}
{:error, err} -> {:error, err}
end
endRuby (pg)
module Queries
GetUserOrdersRow = Data.define(:id, :name, :total, :notes)
def self.get_user_orders(conn, status)
result = conn.exec_params(
"SELECT u.id, u.name, o.total, o.notes ...", [status])
result.map do |row|
GetUserOrdersRow.new(
id: row["id"].to_i, name: row["name"],
total: row["total"], notes: row["notes"])
end
end
endPHP (PDO)
readonly class GetUserOrdersRow {
public function __construct(
public int $id, public string $name,
public ?string $total, public ?string $notes,
) {}
}
final class Queries {
public static function getUserOrders(
\PDO $pdo, string $status
): \Generator {
$stmt = $pdo->prepare("SELECT ...");
$stmt->execute(["p1" => $status]);
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
yield GetUserOrdersRow::fromRow($row);
}
}
}See the full quickstart for complete examples with imports and full function bodies.
- 10 languages -- Rust, Python, TypeScript, Go, Java, Kotlin, C#, Elixir, Ruby, PHP
- 10 databases -- PostgreSQL, MySQL, SQLite, DuckDB, CockroachDB, MSSQL, Oracle, MariaDB, Redshift, Snowflake
- 70+ backend drivers -- sqlx, tokio-postgres, psycopg3, asyncpg, pg, postgres.js, pgx, JDBC, R2DBC, Exposed, Npgsql, PDO, tiberius, oracledb, pyodbc, and more
- 93 lint rules -- UPDATE without WHERE, SELECT *, NULL comparisons, leading wildcard LIKE, plus 71 sqruff rules
- SQL formatting -- consistent style via sqruff integration
- Smart type inference -- nullability from JOINs, COALESCE, window functions, CASE WHEN, aggregates
@optionalparameters -- SQL rewriting for conditional filters (WHERE ($1 IS NULL OR col = $1)):batchexecution -- bulk inserts and batch operations@returns :grouped-- result grouping with@group_byfor grouped query results- R2DBC reactive backends -- non-blocking database access for Java and Kotlin
- Kotlin Exposed -- first-class Exposed ORM backend for Kotlin
- Configurable row types -- Pydantic, msgspec, Zod, dataclass, interface per backend
- CTEs and window functions -- ROW_NUMBER, RANK, LAG, LEAD, recursive CTEs with correct type inference
- Enums, composites, arrays -- PostgreSQL types mapped to language-native equivalents
- Custom type overrides -- map ltree, citext, PostGIS geometry to any target type
| Language | PostgreSQL | MySQL | SQLite | DuckDB | CockroachDB | MSSQL | Oracle | MariaDB | Redshift | Snowflake |
|---|---|---|---|---|---|---|---|---|---|---|
| Rust | sqlx, tokio-postgres | sqlx | sqlx | -- | sqlx | tiberius | sibyl | sqlx | sqlx | -- |
| Python | psycopg3, asyncpg | aiomysql | aiosqlite | python-duckdb | psycopg3 | pyodbc | oracledb | aiomysql | psycopg3 | snowflake-connector |
| TypeScript | pg, postgres.js | mysql2 | better-sqlite3 | typescript-duckdb | pg | mssql | oracledb | mysql2 | pg | snowflake-sdk |
| Go | pgx | database/sql | database/sql | database/sql | pgx | go-mssqldb | godror | database/sql | pgx | gosnowflake |
| Java | JDBC, R2DBC | JDBC | JDBC | JDBC | JDBC | JDBC, R2DBC | JDBC, R2DBC | JDBC | JDBC | JDBC |
| Kotlin | JDBC, R2DBC, Exposed | JDBC | JDBC | JDBC | JDBC | JDBC, R2DBC | JDBC, R2DBC | JDBC | JDBC | JDBC |
| C# | Npgsql | MySqlConnector | Microsoft.Data.Sqlite | -- | Npgsql | Microsoft.Data.SqlClient | ODP.NET | MySqlConnector | Npgsql | Snowflake.Data |
| Ruby | pg, Trilogy | mysql2, Trilogy | sqlite3 | -- | pg | tiny_tds | ruby-oci8 | mysql2 | pg | -- |
| PHP | PDO, AMPHP | PDO | PDO | -- | PDO | PDO | PDO | PDO | PDO | PDO |
| Elixir | Postgrex, Ecto | MyXQL | Exqlite | -- | Postgrex | tds | jamdb_oracle | MyXQL | Postgrex | -- |
Full documentation at goldziher.github.io/scythe:
- Quickstart -- zero to generated code in 5 minutes
- Philosophy -- why compile SQL instead of using an ORM
- Alternatives -- how scythe compares to sqlc, SQLDelight, jOOQ, and ORMs
- Custom Types -- type overrides for PostgreSQL extensions
- Configuration -- full scythe.toml reference
- Annotations -- @name, @returns, @optional, @nullable, @json, and more
- Lint Rules -- all 93 rules with codes and examples
See CONTRIBUTING.md for setup, architecture, and how to add backends/engines/lint rules.