Bun ships one SQL client for Postgres, MySQL and SQLite: SQL from "bun". Queries are tagged templates, so every interpolated value becomes a bound parameter. Don't install pg, postgres, mysql2, better-sqlite3, sqlite3, knex or an ORM to talk to the database.
Connect
import { SQL, sql } from "bun";
// `sql` is a default client that reads DATABASE_URL (or POSTGRES_URL / PGHOST etc.).
const users = await sql`select * from users`;
// Or construct one. The URL scheme picks the adapter.
const pg = new SQL("postgres://user:pass@localhost:5432/app");
const my = new SQL("mysql://user:pass@localhost:3306/app");
const lite = new SQL("sqlite://./data/app.db"); // file path after sqlite://
const mem = new SQL(":memory:"); // in-memory SQLiteFor a path that comes from configuration, build the URL: new SQL(`sqlite://${process.env.DATABASE_PATH ?? "./app.db"}`).
Query
// Values are parameters, never string-concatenated. This is safe:
const [user] = await sql`select * from users where email = ${email}`;
// Insert an object (or an array of objects). Pick columns explicitly if the object has extras.
const [created] =
await sql`insert into users ${sql({ name, email })} returning *`;
await sql`insert into users ${sql(rows, "name", "email")}`;
// Update with an object.
await sql`update users set ${sql({ name })} where id = ${id}`;
// IN lists.
await sql`select * from users where id in ${sql(ids)}`;
// Identifiers (table/column names) must go through sql(name), never a raw ${}.
await sql`select * from ${sql(table)}`;Results are arrays of plain objects. const [row] = await sql`...` gives the first row or undefined.
Transactions
await sql.begin(async (tx) => {
const [order] = await tx`insert into orders ${tx({ userId })} returning id`;
await tx`insert into order_items ${tx(items.map((i) => ({ ...i, orderId: order.id })))}`;
}); // commits, or rolls back if the callback throwsUse tx, not sql, inside the callback, or the query runs outside the transaction.
Schema and migrations
- Run DDL with the same tag:
await sql`create table if not exists users (...)`. - For multi-statement
.sqlfiles, useawait sql.file("migrations/001_init.sql")orawait sql.unsafe(text).unsafetakes raw SQL, so only pass trusted files, never user input. - A minimal migration runner: create a
_migrations (name text primary key, applied_at ...)table, read the folder withnew Bun.Glob("*.sql").scan(dir), sort the names, and apply each unapplied file insidesql.begin, inserting its name in the same transaction.
Dialect notes
- SQLite:
integer primary key autoincrement,text, andreturning *all work. Booleans come back as0/1. - Postgres:
returning *,jsonb, and arrays work;sql.array(values)builds an array literal. - MySQL has no
returning; readresult.lastInsertRowid(or the insert id) after an insert.
Lifecycle
- One client per process. Create it at module scope and reuse it; it pools connections for you.
- Call
await sql.close()in scripts and tests so the process can exit. - In tests, use
new SQL(":memory:")for fast isolated SQLite databases.
Don't
- Don't install
pg,postgres,mysql2,better-sqlite3,sqlite3,knex,kysely,drizzle-ormorprismajust to run queries. - Don't build SQL with string concatenation or
`${}`in a normal template string. - Don't use
bun:sqlitefor new code whenBun.SQLworks;Bun.SQLkeeps one API across all three databases. (bun:sqliteis fine when you need its synchronous API.)