| name | effect-sql |
| description | Type-safe SQL with Effect — SqlClient tagged-template queries, SqlSchema, SqlModel CRUD repositories, SqlResolver batching, and Migrator. Use when working with databases, writing queries, defining models, or setting up migrations. |
You are an Effect TypeScript expert specializing in type-safe SQL database access using the Effect SQL modules.
Effect Source Reference
The Effect v4 source is available at ~/.cache/effect-v4/.
Browse and read files there directly to look up APIs, types, and implementations.
Reference this for:
packages/effect/src/unstable/sql/ — Core SQL modules (SqlClient, SqlSchema, SqlModel, SqlResolver, Migrator, Statement)
packages/effect/src/unstable/schema/Model.ts — Model class with variant schemas
packages/sql/pg/src/PgClient.ts — PostgreSQL driver example
Core Imports
All SQL modules live under the effect/unstable/sql path:
import { SqlClient } from 'effect/unstable/sql/SqlClient';
import * as SqlSchema from 'effect/unstable/sql/SqlSchema';
import * as SqlModel from 'effect/unstable/sql/SqlModel';
import * as SqlResolver from 'effect/unstable/sql/SqlResolver';
import * as Migrator from 'effect/unstable/sql/Migrator';
Alternatively, the barrel exports namespace modules:
import { SqlClient, SqlSchema, SqlModel, SqlResolver, Migrator } from 'effect/unstable/sql';
For Model schemas (used with SqlModel):
import { Model } from 'effect/unstable/schema';
SqlClient — Tagged Template Queries
SqlClient is a service accessed via yield* SqlClient. It doubles as a tagged template literal function for building parameterized queries.
Basic Queries
import { Effect } from 'effect';
import { SqlClient } from 'effect/unstable/sql/SqlClient';
const program = Effect.gen(function* () {
const sql = yield* SqlClient;
const users = yield* sql`SELECT * FROM users`;
const user = yield* sql`SELECT * FROM users WHERE id = ${userId}`;
yield* sql`INSERT INTO users ${sql.insert({ name: 'Alice', email: 'alice@example.com' })}`;
yield* sql`INSERT INTO users ${sql.insert([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' }
])}`;
const [inserted] =
yield* sql`INSERT INTO users ${sql.insert({ name: 'Alice' }).returning('*')}`;
yield* sql`UPDATE users SET ${sql.update(userData, ['id'])} WHERE id = ${userData.id}`;
yield* sql`DELETE FROM users WHERE id = ${userId}`;
});
Statement Properties
Each tagged template expression produces a Statement<A> which is also an Effect<ReadonlyArray<A>, SqlError>. Statements expose additional accessors:
const stmt = sql`SELECT * FROM users`;
yield* stmt;
const stream = stmt.stream;
yield* stmt.withoutTransform;
yield* stmt.raw;
yield* stmt.values;
yield* stmt.unprepared;
const [sqlString, params] = stmt.compile();
Identifiers, Literals, and Helpers
const sql = yield* SqlClient;
sql('users');
sql`SELECT * FROM ${sql('users')}`;
sql.literal('NOW()');
yield* sql.unsafe<User>('SELECT * FROM users WHERE id = $1', [userId]);
sql`SELECT * FROM users WHERE ${sql.in('id', [1, 2, 3])}`;
sql`SELECT * FROM users WHERE ${sql.and([sql`name = ${'Alice'}`, sql`active = ${true}`])}`;
sql`SELECT * FROM users ORDER BY ${sql.csv(['name', 'created_at'])}`;
Transactions
const sql = yield* SqlClient;
yield*
sql.withTransaction(
Effect.gen(function* () {
yield* sql`INSERT INTO orders ${sql.insert(order)}`;
yield* sql`UPDATE inventory SET quantity = quantity - 1 WHERE id = ${itemId}`;
})
);
Transaction context is attached to the active SqlClient service instance. Queries join a transaction only when they run with that same client; avoid mixing clients or manually reserved connections for one atomic unit of work.
Dialect Branching
const sql = yield* SqlClient
const result = sql.onDialectOrElse({
pg: () => sql`SELECT * FROM users LIMIT 10`,
mysql: () => sql`SELECT * FROM users LIMIT 10`,
sqlite: () => sql`SELECT * FROM users LIMIT 10`,
orElse: () => sql`SELECT TOP 10 * FROM users`
})
sql.onDialect({
pg: () => ...,
mysql: () => ...,
sqlite: () => ...,
mssql: () => ...,
clickhouse: () => ...
})
SqlSchema — Schema-Validated Queries
SqlSchema wraps SQL queries with Effect Schema encoding/decoding for type-safe request and result handling.
import { Schema } from 'effect';
import { SqlClient } from 'effect/unstable/sql/SqlClient';
import * as SqlSchema from 'effect/unstable/sql/SqlSchema';
const sql = yield* SqlClient;
const listUsers = SqlSchema.findAll({
Request: Schema.Void,
Result: User,
execute: () => sql`SELECT * FROM users`
});
const users = yield* listUsers(void 0);
const getUserById = SqlSchema.findOne({
Request: Schema.Number,
Result: User,
execute: (id) => sql`SELECT * FROM users WHERE id = ${id}`
});
const user = yield* getUserById(42);
const findUser = SqlSchema.findOneOption({
Request: Schema.String,
Result: User,
execute: (email) => sql`SELECT * FROM users WHERE email = ${email}`
});
const maybeUser = yield* findUser('alice@example.com');
const getActiveUsers = SqlSchema.findNonEmpty({
Request: Schema.Void,
Result: User,
execute: () => sql`SELECT * FROM users WHERE active = true`
});
const deleteUser = SqlSchema.void({
Request: Schema.Number,
execute: (id) => sql`DELETE FROM users WHERE id = ${id}`
});
yield* deleteUser(42);
Model — Schema Variant Classes
The Model module provides a schema class system with built-in variants for database operations (select, insert, update) and JSON APIs (json, jsonCreate, jsonUpdate).
import { Schema } from 'effect';
import { Model } from 'effect/unstable/schema';
const UserId = Schema.Number.pipe(Schema.brand('UserId'));
class User extends Model.Class<User>('User')({
id: UserId.pipe(Model.FieldExcept(["insert"])),
searchText: Model.GeneratedByDb(Schema.String),
name: Schema.String,
email: Schema.String,
passwordHash: Model.Sensitive(Schema.String),
createdAt: Model.DateTimeInsertFromDate,
updatedAt: Model.DateTimeUpdateFromDate,
bio: Model.FieldOption(Schema.String)
}) {}
User;
User.insert;
User.update;
User.json;
User.jsonCreate;
User.jsonUpdate;
Model Field Helpers
| Helper | select | insert | update | json | Description |
|---|
Model.GeneratedByDb(S) | S | — | — | S | DB-generated read-only field |
S.pipe(Model.FieldExcept(["insert"])) | S | — | S | S | DB-generated repository ID that updates must include |
Model.GeneratedByApp(S) | S | S | S | S | App-generated, required everywhere |
Model.Sensitive(S) | S | S | S | — | Excluded from JSON variants |
Model.FieldOption(S) | Option | Option | Option | Option | Nullable/optional across all variants |
Model.DateTimeInsertFromDate | DateTime | auto | — | DateTime | Timestamp set on insert |
Model.DateTimeUpdateFromDate | DateTime | auto | auto | DateTime | Timestamp set on insert+update |
Model.Field({...}) | custom | custom | custom | custom | Per-variant field configuration |
Use GeneratedByDb only for fields that are truly read-only after selection, such as computed columns. For a database-generated primary key used by SqlModel.makeRepository or update calls, keep the key in the update variant with FieldExcept(["insert"]) or an explicit Model.Field({ select, update, json }) shape; upstream prose may lag, but the constructor and SqlModel tests require this distinction.
SqlModel — CRUD Repository
SqlModel.makeRepository generates a complete CRUD interface from a Model class.
import * as SqlModel from 'effect/unstable/sql/SqlModel';
const UserRepo =
yield*
SqlModel.makeRepository(User, {
tableName: 'users',
spanPrefix: 'UserRepo',
idColumn: 'id'
});
const user =
yield* UserRepo.insert({ name: 'Alice', email: 'alice@example.com' });
yield* UserRepo.insertVoid({ name: 'Bob', email: 'bob@example.com' });
const updated = yield* UserRepo.update({ id: userId, name: 'Alice Updated' });
yield* UserRepo.updateVoid({ id: userId, name: 'Alice Updated' });
const found = yield* UserRepo.findById(userId);
yield* UserRepo.delete(userId);
Batched Resolvers (CRUD)
SqlModel.makeResolvers creates RequestResolver values for the same insert, insert-void, find-by-id, and delete operations — ideal for solving N+1 problems while keeping single-request call sites.
import { RequestResolver } from 'effect';
import * as SqlModel from 'effect/unstable/sql/SqlModel';
import * as SqlResolver from 'effect/unstable/sql/SqlResolver';
const UserResolvers =
yield*
SqlModel.makeResolvers(User, {
tableName: 'users',
spanPrefix: 'UserResolver',
idColumn: 'id'
});
const findById = SqlResolver.request(UserResolvers.findById);
const user = yield* findById(userId);
const inserted = yield* SqlResolver.request(
User.insert.make({ name: 'Alice', email: 'alice@example.com' }),
UserResolvers.insert
);
yield* SqlResolver.request(userId, UserResolvers.delete);
const cappedFindById = UserResolvers.findById.pipe(
RequestResolver.setDelay('50 millis'),
RequestResolver.batchN(100)
);
Soft Deletes
makeRepository and makeResolvers accept softDeleteColumn. When supplied, reads and updates add an is null filter for that column, and delete updates the column to CURRENT_TIMESTAMP instead of removing the row.
class SoftDeleteUser extends Model.Class<SoftDeleteUser>('SoftDeleteUser')({
id: UserId.pipe(Model.FieldExcept(["insert"])),
name: Schema.String,
deletedAt: Schema.NullOr(Schema.String).pipe(
Model.FieldOnly(["select", "update"])
)
}) {}
const repo = yield* SqlModel.makeRepository(SoftDeleteUser, {
tableName: 'users',
spanPrefix: 'UserRepo',
idColumn: 'id',
softDeleteColumn: 'deletedAt'
});
yield* repo.delete(userId);
Resolver versions created by SqlModel.makeResolvers honor the same soft-delete filter and delete behavior.
SqlResolver — Request Batching
SqlResolver creates RequestResolver instances for batching SQL queries. Use these when you need fine-grained control or custom query shapes beyond the resolvers returned by SqlModel.makeResolvers.
Ordered Resolver
Results map 1:1 to requests by position. Result count must match request count.
import * as SqlResolver from 'effect/unstable/sql/SqlResolver';
const insertResolver = SqlResolver.ordered({
Request: User.insert,
Result: User,
execute: (requests) =>
sql`INSERT INTO users ${sql.insert(requests).returning('*')}`
});
const insertUser = SqlResolver.request(insertResolver);
const user = yield* insertUser({ name: 'Alice', email: 'alice@example.com' });
FindById Resolver
Batches lookups by ID, matching results back by a key function.
const findByIdResolver = SqlResolver.findById({
Id: UserId,
Result: User,
ResultId: (user) => user.id,
execute: (ids) => sql`SELECT * FROM users WHERE ${sql.in('id', ids)}`
});
Grouped Resolver
Returns multiple results per request, grouped by a key.
const userPostsResolver = SqlResolver.grouped({
Request: UserId,
RequestGroupKey: (userId) => userId,
Result: Post,
ResultGroupKey: (post) => post.userId,
execute: (userIds) =>
sql`SELECT * FROM posts WHERE ${sql.in('user_id', userIds)}`
});
const posts = yield* SqlResolver.request(userPostsResolver)(userId);
Void Resolver
For side-effect-only batched operations (deletes, updates without return).
const deleteResolver = SqlResolver.void({
Request: UserId,
execute: (ids) => sql`DELETE FROM users WHERE ${sql.in('id', ids)}`
});
Configuring Resolvers
Resolvers already batch same-turn/concurrently queued requests by default (Effect.yieldNow). Use RequestResolver.setDelay only to widen the collection window, and RequestResolver.batchN to cap batch size.
import { RequestResolver } from 'effect';
const resolver = SqlResolver.ordered({ ... }).pipe(
RequestResolver.setDelay('50 millis'),
RequestResolver.batchN(100),
RequestResolver.withSpan('UserRepo.insert')
);
Migrator — Schema Migrations
The Migrator module runs sequential, transactional migrations tracked in a effect_sql_migrations table.
Migration File Convention
Files must be named <id>_<name>.js, <id>_<name>.ts, <id>_<name>.mjs, or <id>_<name>.mts, where id is a numeric identifier (e.g. 0001_create_users.ts). Unsupported extensions are ignored by the file and glob loaders.
Each migration file exports a default Effect:
import { Effect } from 'effect';
import { SqlClient } from 'effect/unstable/sql/SqlClient';
export default Effect.gen(function* () {
const sql = yield* SqlClient;
yield* sql`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
});
Running Migrations
import * as Migrator from 'effect/unstable/sql/Migrator';
const migrate = Migrator.make({
dumpSchema: (path, table) => Effect.void
});
const completed =
yield*
migrate({
loader: Migrator.fromFileSystem('./migrations'),
schemaDirectory: './migrations',
table: 'effect_sql_migrations'
});
Migration Loaders
Migrator.fromFileSystem('./migrations');
Migrator.fromGlob(import.meta.glob('./migrations/*.{js,ts,mjs,mts}'));
Migrator.fromRecord({
'0001_create_users': Effect.gen(function* () {
const sql = yield* SqlClient;
yield* sql`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`;
}),
'0002_add_email': Effect.gen(function* () {
const sql = yield* SqlClient;
yield* sql`ALTER TABLE users ADD COLUMN email TEXT`;
})
});
Migrator.fromBabelGlob(migrations);
Migration Errors
import * as Migrator from 'effect/unstable/sql/Migrator';
Driver Packages and Layer Setup
Effect SQL uses driver-specific packages that provide SqlClient layers.
Common Drivers
| Package | Database |
|---|
@effect/sql-pg | PostgreSQL (via pg) |
@effect/sql-pglite | Embedded PostgreSQL/PGlite |
@effect/sql-mysql2 | MySQL (via mysql2) |
@effect/sql-sqlite-node | SQLite (via better-sqlite3) |
@effect/sql-libsql | libSQL / Turso |
@effect/sql-mssql | Microsoft SQL Server |
@effect/sql-clickhouse | ClickHouse |
PostgreSQL Setup
import { Effect, Layer } from 'effect';
import { PgClient } from '@effect/sql-pg';
import { SqlClient } from 'effect/unstable/sql/SqlClient';
const DatabaseLayer = PgClient.layer({
host: 'localhost',
port: 5432,
database: 'myapp',
username: 'postgres',
password: Redacted.make('secret'),
maxConnections: 10,
idleTimeout: '30 seconds',
transformResultNames: (s) => camelCase(s),
transformQueryNames: (s) => snakeCase(s)
});
const DatabaseLayerConfig = PgClient.layerConfig({
url: Config.redacted('DATABASE_URL')
});
const program = Effect.gen(function* () {
const sql = yield* SqlClient;
const pg = yield* PgClient;
});
const main = program.pipe(Effect.provide(DatabaseLayer));
PgClient-Specific Features
const pg = yield* PgClient;
sql`INSERT INTO data ${sql.insert({ metadata: pg.json({ key: 'value' }) })}`;
const notifications = pg.listen('my_channel');
yield* pg.notify('my_channel', 'hello');
PGlite Setup
Use @effect/sql-pglite for embedded PostgreSQL-compatible databases backed by @electric-sql/pglite. Its layer provides both the PGlite-specific service and the generic SqlClient service.
import { Config, Effect } from 'effect';
import { PgliteClient, PgliteMigrator } from '@effect/sql-pglite';
import * as Migrator from 'effect/unstable/sql/Migrator';
import { SqlClient } from 'effect/unstable/sql/SqlClient';
const PgliteLayer = PgliteClient.layer({
dataDir: 'idb://myapp'
});
const PgliteLayerConfig = PgliteClient.layerConfig({
dataDir: Config.string('PGLITE_DATA_DIR')
});
const program = Effect.gen(function* () {
const sql = yield* SqlClient;
const pglite = yield* PgliteClient.PgliteClient;
yield* sql`INSERT INTO data ${sql.insert({ metadata: pglite.json({ key: 'value' }) })}`;
const notifications = pglite.listen('my_channel');
yield* pglite.notify('my_channel', 'hello');
yield* pglite.refreshArrayTypes;
const snapshot = yield* pglite.dumpDataDir('gzip');
});
const runPgliteMigrations = PgliteMigrator.run({
loader: Migrator.fromFileSystem('./migrations')
});
PgliteClient.layerFrom wraps an existing acquired client. PgliteMigrator reuses the shared migrator loaders, but it does not currently write schema dumps for schemaDirectory; use PGlite data-dir persistence or PgliteClient.dumpDataDir for embedded snapshots.
Connection Reservation
const sql = yield* SqlClient;
const conn = yield* sql.reserve;
Streaming Large Result Sets
Use .stream on any statement for memory-efficient processing of large result sets:
import { Stream } from 'effect';
const sql = yield* SqlClient;
const allUsers = sql`SELECT * FROM users`.stream;
yield*
allUsers.pipe(
Stream.filter((user) => user.active),
Stream.map((user) => user.email),
Stream.runCollect
);
Error Handling
All SQL operations can fail with SqlError:
import { SqlError } from 'effect/unstable/sql/SqlError';
yield*
sql`SELECT * FROM users`.pipe(
Effect.catchTag('SqlError', (err) => {
console.error('SQL failed:', err.message);
console.error('Cause:', err.cause);
return Effect.succeed([]);
})
);
Unique constraint failures classify as err.reason._tag === 'UniqueViolation' when the driver exposes enough detail. The constraint field names the violated constraint; classifiers fall back to 'unknown' when the name is missing.
const constraintName = (err: SqlError) =>
err.reason._tag === 'UniqueViolation'
? err.reason.constraint || 'unknown'
: undefined;
Keep non-unique integrity failures on their own paths; they remain ConstraintError rather than UniqueViolation.
SqlResolver also exposes ResultLengthMismatch for ordered resolvers when result count doesn't match request count.
Complete Example
import { Effect, Layer, Schema } from 'effect';
import { Model } from 'effect/unstable/schema';
import { SqlClient } from 'effect/unstable/sql/SqlClient';
import * as SqlModel from 'effect/unstable/sql/SqlModel';
import * as Migrator from 'effect/unstable/sql/Migrator';
import { PgClient } from '@effect/sql-pg';
const UserId = Schema.Number.pipe(Schema.brand('UserId'));
class User extends Model.Class<User>('User')({
id: UserId.pipe(Model.FieldExcept(["insert"])),
name: Schema.String,
email: Schema.String,
createdAt: Model.DateTimeInsertFromDate,
updatedAt: Model.DateTimeUpdateFromDate
}) {}
const makeUserRepo = Effect.gen(function* () {
const repo = yield* SqlModel.makeRepository(User, {
tableName: 'users',
spanPrefix: 'UserRepo',
idColumn: 'id'
});
return repo;
});
const runMigrations = Migrator.make({})({
loader: Migrator.fromFileSystem('./migrations')
});
const DatabaseLayer = PgClient.layer({
host: 'localhost',
database: 'myapp',
username: 'postgres'
});
const program = Effect.gen(function* () {
yield* runMigrations;
const repo = yield* makeUserRepo;
const user = yield* repo.insert({
name: 'Alice',
email: 'alice@example.com'
});
const found = yield* repo.findById(user.id);
yield* Effect.log(`Created user: ${found.name}`);
});
Effect.runPromise(program.pipe(Effect.provide(DatabaseLayer)));
Anti-Patterns
- String concatenation in queries — Always use tagged template interpolation or
sql.unsafe(). Never build SQL strings manually.
- Forgetting
sql.insert() / sql.update() — Use the helpers for INSERT/UPDATE instead of manually listing columns and values.
- Not using transactions — Wrap multi-statement mutations in
sql.withTransaction() for atomicity.
- Ignoring
SqlSchema — Raw queries return untyped rows. Use SqlSchema.findOne/findAll/void for validated I/O.
- Assuming custom delay is required for batching —
SqlResolver resolvers batch concurrently queued requests by default via Effect.yieldNow. Add RequestResolver.setDelay only to widen the collection window when the latency tradeoff is acceptable.