Skip to content

Queries

The SqlClient service is a callable sql function. Tag a template literal to build a query — interpolated values become bound parameters, so user input is never spliced into the SQL string. The result is an Effect that yields an array of rows and can fail with a SqlError. The same sql value also produces quoted identifiers, fragments, and helpers for inserts, updates, and IN clauses, so even dynamic queries stay parameterized.

import { Effect } from "effect"
import { SqlClient } from "effect/unstable/sql"
const findActiveUsers = Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
// Interpolated values (`true`, `10`) are sent as parameters, not text. The
// type argument names the row shape for the returned array.
const users = yield* sql<{ readonly id: number; readonly name: string }>`
SELECT id, name
FROM users
WHERE active = ${true}
LIMIT ${10}
`
return users
})

A tagged sql template is a Statement<A>, and a Statement<A> is an Effect<ReadonlyArray<A>, SqlError> — yielding it (or running it) executes the query and returns the rows. The query composes with everything else: retry it, add a span, race it, or run it under a transaction. users above is typed as ReadonlyArray<{ id: number; name: string }>, but those are raw driver values — to validate and transform them, decode with Schema (shown below).

Calling sql with a string returns a safely-quoted identifier, useful when a table or column name is dynamic. The constructor also exposes helpers that build SQL fragments for common shapes without hand-writing placeholder lists.

import { Effect } from "effect"
import { SqlClient } from "effect/unstable/sql"
const insertUser = Effect.fn("insertUser")(function*(name: string, email: string) {
const sql = yield* SqlClient.SqlClient
// `sql.insert` builds the column list and VALUES tuple from an object.
// `sql("users")` quotes the identifier for the active dialect.
yield* sql`
INSERT INTO ${sql("users")} ${sql.insert({ name, email })}
`
})
const usersByIds = Effect.fn("usersByIds")(function*(ids: ReadonlyArray<number>) {
const sql = yield* SqlClient.SqlClient
// `sql.in` expands an array into a parameterized `IN (?, ?, …)` clause.
return yield* sql<{ readonly id: number; readonly name: string }>`
SELECT id, name FROM users WHERE ${sql.in("id", ids)}
`
})
const setUserName = Effect.fn("setUserName")(function*(id: number, name: string) {
const sql = yield* SqlClient.SqlClient
// `sql.update` builds the SET clause from the object's keys; an optional
// second arg lists columns to omit from SET.
yield* sql`
UPDATE ${sql("users")} SET ${sql.update({ name })} WHERE id = ${id}
`
})

Other useful constructors: sql.and([...]) / sql.or([...]) chain WHERE clauses, sql.csv(...) builds comma-separated lists for ORDER BY / GROUP BY, sql.literal(str) inlines trusted SQL verbatim (no escaping — never pass user input), and sql.unsafe(text, params) builds a fully custom statement. All of these are enumerated with examples in the reference below.

Raw rows are untyped driver output. SqlSchema wraps a query’s execute callback with a request schema (encoded on the way in) and a result schema (decoded on the way out), so your code works with validated domain values and a SchemaError surfaces any row that doesn’t match.

import { Effect, Schema } from "effect"
import { SqlClient, SqlSchema } from "effect/unstable/sql"
const User = Schema.Struct({
id: Schema.Number,
name: Schema.String,
email: Schema.String
})
const makeQueries = Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
// findOne: encodes the request (here just an `id`), runs the query, then
// decodes the first row. Fails with NoSuchElementError when there are none.
const getUserById = SqlSchema.findOne({
Request: Schema.Number,
Result: User,
execute: (id) => sql`SELECT * FROM users WHERE id = ${id}`
})
// findAll: decodes every row into a typed array (empty array when none).
const searchUsers = SqlSchema.findAll({
Request: Schema.String,
Result: User,
execute: (term) => sql`SELECT * FROM users WHERE name ILIKE ${`%${term}%`}`
})
return { getUserById, searchUsers } as const
})

SqlSchema offers findAll (zero or more rows), findNonEmpty (fails on an empty result), findOne (first row, fails if absent), findOneOption (first row as an Option), and void (encode the request, discard the result — for writes). The execute callback receives the encoded request, so any Schema transformations on the request shape apply before the value reaches SQL. To batch and de-duplicate many such queries into a single round trip, see Resolvers.

Loading millions of rows into an array is wasteful. Every statement exposes a .stream property — a Stream that pulls rows from the cursor incrementally, so memory stays bounded regardless of result size.

import { Effect, Stream } from "effect"
import { SqlClient } from "effect/unstable/sql"
const exportEvents = Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
yield* sql<{ readonly id: number; readonly payload: string }>`
SELECT id, payload FROM events ORDER BY id
`.stream.pipe(
// Process each row as it arrives without buffering the whole table.
Stream.runForEach((event) => Effect.log(`event ${event.id}`))
)
})

sql.withTransaction wraps any effect so every query it runs is part of one transaction: the client issues BEGIN up front, COMMIT on success, and ROLLBACK if the effect fails or is interrupted. Nested withTransaction calls reuse the same connection and use savepoints, so composing transactional effects is safe.

import { Effect } from "effect"
import { SqlClient } from "effect/unstable/sql"
// Transfer between two accounts: both updates commit together, or neither does.
const transfer = Effect.fn("transfer")(function*(from: number, to: number, amount: number) {
const sql = yield* SqlClient.SqlClient
yield* sql.withTransaction(
Effect.gen(function*() {
yield* sql`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${from}`
yield* sql`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${to}`
// If anything below fails, both updates roll back automatically.
const [account] = yield* sql<{ readonly balance: number }>`
SELECT balance FROM accounts WHERE id = ${from}
`
if (account.balance < 0) {
return yield* Effect.fail(new Error("insufficient funds"))
}
})
)
})

SqlClient.SqlClient is the Context.Service tag you yield to get the client. The client extends the sql constructor (so it is callable) and adds the members below.

import { Effect } from "effect"
import { SqlClient } from "effect/unstable/sql"
const program = Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient // SqlClient extends the sql constructor
return yield* sql<{ readonly n: number }>`SELECT 1 AS n`
})

A copy of the client typed as itself, intended for tools like safe-ql that want a stable client reference. In practice it is the same client value.

const safeClient = sql.safe // => the SqlClient

Returns a copy of the client with row/identifier transforms disabled (for example, the camelCase ↔ snake_case mapping some integrations install). Use it when you need the raw column names and values.

const raw = sql.withoutTransforms()
// raw`SELECT first_name FROM users` => rows keep `first_name`, not `firstName`

A scoped Effect<Connection, SqlError, Scope> that checks out a single connection from the pool for lower-level work. The connection is released when the Scope closes.

import { Effect } from "effect"
const withReserved = Effect.gen(function*() {
const conn = yield* sql.reserve // requires a Scope in the environment
yield* conn.executeUnprepared("SET statement_timeout = 5000", [], undefined)
}).pipe(Effect.scoped)

Runs effect inside a transaction, adding SqlError to its error channel. Nested calls become savepoints on the same connection. (Demonstrated in Transactions above.)

sql.withTransaction(effect) // => Effect<A, E | SqlError, R>

The per-client Context.Service tag that holds the active transaction connection and nesting depth (readonly [conn, depth]). It is what lets a query detect that it is inside withTransaction. You rarely touch it directly; it is exposed for advanced integrations that coordinate transactions across clients.

Turns a query effect into a Stream that re-emits whenever one of the given reactivity keys is invalidated (via the Reactivity service). keys is either an array of values or a record of table -> ids.

import { Effect, Stream } from "effect"
const liveUsers = sql.reactive(
["users"],
sql<{ readonly id: number }>`SELECT id FROM users`
) // => Stream<ReadonlyArray<{ id: number }>, SqlError>
const drain = Stream.runForEach(liveUsers, (rows) => Effect.log(`${rows.length} users`))

Like reactive, but yields a scoped Queue.Dequeue you can pull from manually instead of a Stream. Useful when you want full control over consumption.

import { Effect, Queue } from "effect"
const program = Effect.gen(function*() {
const mailbox = yield* sql.reactiveMailbox(["users"], sql`SELECT id FROM users`)
const rows = yield* Queue.take(mailbox) // => latest result set
}).pipe(Effect.scoped)

A Context.Reference<boolean> (default false) that integrations read to opt in to safe (BigInt-style) integer handling for large numeric columns.

import { Effect } from "effect"
import { SqlClient } from "effect/unstable/sql"
const withSafeInts = Effect.provideService(myProgram, SqlClient.SafeIntegers, true)

A tagged template (and sql.unsafe) produces a Statement<A>. Besides being an Effect<ReadonlyArray<A>, SqlError>, it carries these members.

Executes and returns the raw driver result (Effect<unknown, SqlError>), bypassing row extraction — handy when you need the driver’s native result object (affected-row counts, command tags, etc.).

const result = yield* sql`UPDATE users SET active = ${false}`.raw
// => driver-specific result object (e.g. { rowCount: 3, command: "UPDATE" })

Executes the query with row/identifier transforms disabled, returning Effect<ReadonlyArray<A>, SqlError> with untransformed column names.

const rows = yield* sql`SELECT first_name FROM users`.withoutTransform
// => [{ first_name: "Ada" }] — not transformed to `firstName`

A Stream<A, SqlError> that pulls rows incrementally from a cursor. (See Streaming.)

sql`SELECT id FROM events`.stream // => Stream<{ id: number }, SqlError>

Executes and returns rows as positional arrays instead of objects: Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>. Useful for compact exports where column order is known.

const rows = yield* sql`SELECT id, name FROM users`.values
// => [[1, "Ada"], [2, "Linus"]]

Executes via the driver’s unprepared path (no prepared-statement cache), returning Effect<ReadonlyArray<A>, SqlError>. Use for one-off DDL or statements drivers refuse to prepare.

yield* sql`CREATE TABLE users (id INTEGER PRIMARY KEY)`.unprepared
// => [] (executed without preparing)

Compiles the statement to [sql, params] without executing it, using the active dialect. Pass true to compile with transforms disabled.

const [text, params] = sql`SELECT * FROM users WHERE id = ${42}`.compile()
// => ["SELECT * FROM users WHERE id = $1", [42]] (pg dialect)

The callable sql value (the Constructor interface) builds statements, identifiers, fragments, and helper segments. Below, .compile() is shown on a wrapping statement to reveal the generated SQL where it clarifies behavior. Dialect output varies; the examples assume the pg dialect ($1, "quoting").

The primary form. Interpolated values become bound parameters; nested fragments and helpers are spliced in. Returns Statement<A>.

sql<{ readonly id: number }>`SELECT id FROM users WHERE id = ${1}`.compile()
// => ["SELECT id FROM users WHERE id = $1", [1]]

Calling sql with a plain string returns a dialect-escaped Identifier segment — never an executable statement. Use it for dynamic table/column names.

sql`SELECT * FROM ${sql("user accounts")}`.compile()
// => ['SELECT * FROM "user accounts"', []]

Builds a Statement from raw SQL text plus optional bind params. The text is not escaped — only pass SQL you control.

sql.unsafe("SELECT * FROM users WHERE id = $1", [7]).compile()
// => ["SELECT * FROM users WHERE id = $1", [7]]

Returns a Fragment containing trusted SQL text inlined verbatim (no placeholder, no escaping). Compose it into a larger statement.

sql`SELECT * FROM users ${sql.literal("ORDER BY created_at DESC")}`.compile()
// => ["SELECT * FROM users ORDER BY created_at DESC", []]

With one array argument, returns an ArrayHelper that compiles to a parenthesized placeholder list. With (column, value), returns a full column IN (...) Fragment; an empty array yields the always-false 1=0.

sql`SELECT * FROM users WHERE id ${sql.in([1, 2, 3])}`.compile()
// => ["SELECT * FROM users WHERE id ($1,$2,$3)", [1, 2, 3]]
sql`SELECT * FROM users WHERE ${sql.in("id", [1, 2])}`.compile()
// => ['SELECT * FROM users WHERE "id" IN ($1,$2)', [1, 2]]
sql`SELECT * FROM users WHERE ${sql.in("id", [])}`.compile()
// => ["SELECT * FROM users WHERE 1=0", []]

Builds a RecordInsertHelper from one object or an array of objects, producing the (columns) VALUES (...) clause. Multiple objects insert multiple rows. Chain .returning(...) to append a RETURNING clause.

sql`INSERT INTO users ${sql.insert({ name: "Ada", age: 36 })}`.compile()
// => ['INSERT INTO users ("name","age") VALUES ($1,$2)', ["Ada", 36]]
sql`INSERT INTO users ${sql.insert([{ name: "Ada" }, { name: "Linus" }])}`.compile()
// => ['INSERT INTO users ("name") VALUES ($1),($2)', ["Ada", "Linus"]]
sql`INSERT INTO users ${sql.insert({ name: "Ada" }).returning("*")}`.compile()
// => ['INSERT INTO users ("name") VALUES ($1) RETURNING *', ["Ada"]]

Builds a RecordUpdateHelperSingle — the col = $n, ... assignments for a single-row update. The optional second argument lists keys to omit (e.g. the primary key). Chain .returning(...) for a RETURNING clause.

sql`UPDATE users SET ${sql.update({ name: "Ada", age: 36 })} WHERE id = ${1}`.compile()
// => ['UPDATE users SET "name" = $1, "age" = $2 WHERE id = $3', ["Ada", 36, 1]]
// omit `id` from the SET list while keeping it in the object
sql`UPDATE users SET ${sql.update({ id: 1, name: "Ada" }, ["id"])} WHERE id = ${1}`.compile()
// => ['UPDATE users SET "name" = $1 WHERE id = $2', ["Ada", 1]]

Builds a RecordUpdateHelper for updating multiple rows from a values list under a table alias (typically used with UPDATE ... FROM (VALUES ...)).

sql.updateValues([{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }], "data")
// => RecordUpdateHelper compiled into a (VALUES ...) AS data(...) clause

Join an array of string/fragment clauses with AND / OR, wrapping the group in parentheses. An empty array produces 1=1 (always true), which is convenient for optional filters.

sql`SELECT * FROM users WHERE ${sql.and(["active = true", sql`age > ${18}`])}`.compile()
// => ["SELECT * FROM users WHERE (active = true AND age > $1)", [18]]
sql`SELECT * FROM users WHERE ${sql.and([])}`.compile()
// => ["SELECT * FROM users WHERE 1=1", []]

Joins clauses with commas (no parentheses). The two-argument form prepends a prefix like ORDER BY or GROUP BY. An empty list produces an empty fragment.

sql`SELECT * FROM users ${sql.csv("ORDER BY", ["name", "age DESC"])}`.compile()
// => ["SELECT * FROM users ORDER BY name,age DESC", []]
sql`SELECT ${sql.csv(["id", "name"])} FROM users`.compile()
// => ["SELECT id,name FROM users", []]

The lower-level combinator behind and/or/csv: returns a function that joins clauses with the given separator. addParens (default true) wraps multiple clauses in parentheses; fallback (default "") is used for an empty list.

const joinPlus = sql.join(" + ", false, "0")
sql`SELECT ${joinPlus(["a", "b", "c"])}`.compile()
// => ["SELECT a + b + c", []]

Selects a value by the client’s active dialect. All five branches (sqlite, pg, mysql, mssql, clickhouse) are required.

const upsert = sql.onDialect({
sqlite: () => sql.literal("ON CONFLICT DO NOTHING"),
pg: () => sql.literal("ON CONFLICT DO NOTHING"),
mysql: () => sql.literal("ON DUPLICATE KEY UPDATE id = id"),
mssql: () => sql.literal(""),
clickhouse: () => sql.literal("")
})

Like onDialect, but orElse supplies the default and every dialect branch is optional.

const limit = sql.onDialectOrElse({
orElse: () => sql.literal("LIMIT 10"),
mssql: () => sql.literal("TOP 10")
})

The Dialect union is "sqlite" | "pg" | "mysql" | "mssql" | "clickhouse".

The Statement module exports dialect-agnostic fragment builders you can use without a SqlClient — handy in helper code that assembles fragments before a client is available. They mirror the sql.* helpers above.

import { Statement } from "effect/unstable/sql"
Statement.and(["a = 1", "b = 2"]) // => Fragment: (a = 1 AND b = 2)
Statement.or(["a = 1", "b = 2"]) // => Fragment: (a = 1 OR b = 2)
Statement.csv(["id", "name"]) // => Fragment: id,name
Statement.fragment([Statement.literal("NOW()")]) // => Fragment from raw segments
Statement.isFragment(Statement.csv(["x"])) // => true
Statement.isCustom("json")(someSegment) // => guard for Custom segments of kind "json"

A Context.Reference<Transformer | undefined> (default undefined). A Transformer is a hook — (self, sql, fiber, span) => Effect<Statement> — that can rewrite or wrap every statement just before execution (for query logging, multi-tenant rewrites, etc.). Provide it via the environment to install it.

import { Effect } from "effect"
import { Statement } from "effect/unstable/sql"
const logging = Effect.provideService(
program,
Statement.CurrentTransformer,
(self, _sql, _fiber, _span) => Effect.as(Effect.log(self.compile()[0]), self)
)

Queries fail with SqlError from effect/unstable/sql. SqlError is a Schema.TaggedErrorClass whose only field is a structured reason (a tagged union). It derives message from the reason (reason.message || reason._tag), sets cause to the reason, and delegates isRetryable to it. Match on error.reason._tag to handle specific database conditions.

import { Effect } from "effect"
import { SqlClient, SqlError } from "effect/unstable/sql"
const createUser = Effect.fn("createUser")(
function*(email: string) {
const sql = yield* SqlClient.SqlClient
yield* sql`INSERT INTO users ${sql.insert({ email })}`
},
// SqlError is the only error channel; branch on the structured reason.
// Cross-cutting handling rides as a trailing Effect.fn argument, not `.pipe`.
Effect.catchTag("SqlError", (error: SqlError.SqlError) =>
error.reason._tag === "UniqueViolation"
? Effect.fail(new Error("email already taken"))
: Effect.fail(error)
)
)

Several reasons are transient (connection drops, deadlocks, lock waits). Rather than enumerate tags, branch on isRetryable and pair it with Scheduling.

import { Effect, Schedule } from "effect"
import { SqlError } from "effect/unstable/sql"
const resilient = query.pipe(
Effect.retry({
while: (error: SqlError.SqlError) => error.isRetryable,
schedule: Schedule.exponential("10 millis").pipe(Schedule.both(Schedule.recurs(5)))
})
)

Every SqlError wraps exactly one SqlErrorReason. Each reason is a Schema.TaggedErrorClass carrying cause, optional message, and optional operation, plus an isRetryable flag. Adapters translate database-specific codes into this shared vocabulary.

reason._tagisRetryableMeaning
ConnectionErrortrueConnection or open failure.
AuthenticationErrorfalseInvalid credentials.
AuthorizationErrorfalsePermission / authorization failure.
SqlSyntaxErrorfalseInvalid SQL syntax.
UniqueViolationfalseUnique constraint violation (also carries constraint: string).
ConstraintErrorfalseNon-unique constraint violation.
DeadlockErrortrueDatabase deadlock.
SerializationErrortrueTransaction serialization / isolation conflict.
LockTimeoutErrortrueTimed out waiting on a lock.
StatementTimeoutErrortrueStatement / query timeout.
UnknownErrorfalseUnclassified database failure.

UniqueViolation is the canonical signal for a duplicate-key conflict and adds a best-effort constraint field:

import { Effect } from "effect"
import { SqlError } from "effect/unstable/sql"
const handled = query.pipe(
Effect.catchTag("SqlError", (error: SqlError.SqlError) => {
if (error.reason._tag === "UniqueViolation") {
// error.reason.constraint => e.g. "users_email_key" (or "unknown")
return Effect.fail(new Error(`duplicate: ${error.reason.constraint}`))
}
return Effect.fail(error)
})
)

SqlError.isSqlError / SqlError.isSqlErrorReason

Section titled “SqlError.isSqlError / SqlError.isSqlErrorReason”

Runtime guards for boundaries that receive unknown values (e.g. catching a Cause defect or interop with non-Effect code).

import { SqlError } from "effect/unstable/sql"
SqlError.isSqlError(value) // => value is SqlError
SqlError.isSqlErrorReason(value) // => value is SqlErrorReason

A Schema.Union of all eleven reason classes, used to encode/decode reasons (for transporting errors across RPC, persisting them, etc.). The SqlError class itself is also a schema since it extends Schema.TaggedErrorClass.

import { Schema } from "effect"
import { SqlError } from "effect/unstable/sql"
const decode = Schema.decodeUnknownSync(SqlError.SqlErrorReason)
// decode(json) => one of ConnectionError | UniqueViolation | ...

Maps a native SQLite error cause (by its code/errno) into the appropriate SqlErrorReason. Driver integrations use it; application code rarely calls it directly. For example a SQLITE_CONSTRAINT_UNIQUE becomes a UniqueViolation with an extracted constraint name.

import { SqlError } from "effect/unstable/sql"
const reason = SqlError.classifySqliteError(
{ code: "SQLITE_BUSY" },
{ operation: "execute" }
)
// => LockTimeoutError (isRetryable: true)

A separate Schema.TaggedErrorClass (expected, actual) raised when an ordered batched resolver gets a different number of result rows than requests. It is the error you handle when working with Resolvers, not a SqlError reason.

import { SqlError } from "effect/unstable/sql"
new SqlError.ResultLengthMismatch({ expected: 3, actual: 2 }).message
// => "Expected 3 results but got 2"

Underneath SqlClient sits SqlConnection.Connection — a Context.Service that executes already-compiled SQL with positional parameters. Most applications never touch it; it exists for driver authors and for code that reserves a connection via sql.reserve.

  • SqlConnection.Connection — the Context.Service tag for a low-level connection. Its methods (execute, executeRaw, executeStream, executeValues, executeUnprepared) take (sql, params, transformRows?).
  • SqlConnection.Acquirer — the type Effect<Connection, SqlError, Scope>: a scoped effect that checks out a connection (pool checkout, transaction pinning) and releases it when the scope closes.
  • SqlConnection.Row — the generic row shape { readonly [column: string]: unknown } used as the default Statement element type.
import { Effect } from "effect"
import { SqlConnection } from "effect/unstable/sql"
const direct = Effect.gen(function*() {
const conn = yield* SqlConnection.Connection
return yield* conn.execute("SELECT 1 AS n", [], undefined)
// => [{ n: 1 }]
})

Next, see Resolvers for batching and schema-mapped queries, or Models and migrations to generate repositories from a Model and manage your database schema over time.