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
})

The query is just an Effect, so it 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.

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.

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"))
}
})
)
})

Queries fail with SqlError from effect/unstable/sql. It wraps a single structured reason (a tagged union), and exposes message plus an isRetryable flag derived from that reason. Match on the 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)
)
)

Reasons include ConnectionError, UniqueViolation, ConstraintError, DeadlockError, SqlSyntaxError, and more — retryable ones (connection, deadlock, serialization, lock timeout) set isRetryable: true, which pairs well with Scheduling retries. For broader error-handling patterns see Error Management.

Next, see Models and migrations to generate repositories from a Model and manage your database schema over time.