Types stay alive without an ORM: Building an edge query layer from scratch with Bun 1.2's built-in SQLite
When setting up a lightweight serverless backend, you'll inevitably hit this dilemma: "Prisma is too heavy, hand-writing SQL kills your types, but mapping domain objects without an ORM is just too tedious." For a while I was living with the compromise of better-sqlite3 + manual interface declarations as my way out of this triangle.
Then Bun 1.2 landed in January 2025 and shifted the landscape. bun:sqlite is a SQLite driver bundled into the runtime itself, no external packages needed, and it provides a generic API in the form of db.query<ResultType, [ParamType]>(sql). You can achieve basic type safety without any code generation tooling.
In this post, I'll walk through the pattern of designing a type-safe query layer from scratch with the bun:sqlite built-in driver, and what that means in an edge environment — with code. This isn't an argument to ditch ORMs entirely; it's an honest look at what alternatives exist when an ORM becomes a burden.
Why bun:sqlite Is Getting Attention Again
Rediscovering SQLite: From File DB to Edge DB
If you've been thinking of SQLite as "a prototype DB," it's worth reviewing what happened between 2024 and 2025. With Cloudflare D1, Turso, and Fly.io LiteFS reaching production stability, distributed SQLite has emerged as a realistic choice for edge databases. SQLite's in-process nature enables very low read latency with no network round-trips. Combined with a layer that replicates files to global regions, edge deployment becomes achievable at far lower operational complexity than with PostgreSQL.
Where bun:sqlite Fits
bun:sqlite offers a synchronous API inspired by better-sqlite3, and thanks to native bindings, the official Bun benchmarks report a meaningful edge over better-sqlite3 and deno.land/x/sqlite. The benchmarks generally show strong advantages on read-heavy workloads — results will vary depending on your actual project workload, so I recommend running your own measurements too.
From a bundling perspective, having no separate npm dependency is even more practical. You don't need to stuff a native add-on into your edge deployment bundle, and since it's already linked into the runtime, installation failure issues disappear entirely.
Honestly, when I first saw those numbers I was skeptical, but when you actually run it, the cold start difference is clearly noticeable. That said, I'll address later how much of this cold start benefit comes from Bun's own runtime startup time rather than the driver itself.
When to Use bun:sqlite and When Not To
It's not a tool for every situation. The flow below should help you decide.
bun:sqlite native binaries do not run in Cloudflare Workers. As of August 2026 this constraint is still unresolved, so if you're targeting Workers, you need to use the D1 API.
How to Build a Type-Safe Query Layer From Scratch
The Basic Generic API — Simpler Than You Think
The heart of bun:sqlite is the db.query<ResultType, ParamType>(sql) form. Specify the result row type and the binding parameter types as generics, and TypeScript takes care of the rest.
import { Database } from "bun:sqlite";
interface User {
id: number;
name: string;
email: string;
}
const db = new Database("app.db");
const getUser = db.query<User, [number]>(
"SELECT id, name, email FROM users WHERE id = ?"
);
const insertUser = db.query<User, [string, string]>(
"INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email"
);
const user = getUser.get(42);
const created = insertUser.get("Alice", "alice@example.com");This alone lets you move away from the pattern of receiving query results as any and casting manually. That said, you should understand exactly what the type system does and doesn't catch. It will catch a wrong number of arguments or a completely mismatched type, but it won't catch a swapped order of homogeneous parameters like [string, string]. Passing name and email in the wrong order will still compile. It protects against SQL injection and type errors, but don't expect it to catch semantically misplaced parameters.
The .run() Return Value and the void Generic
bun:sqlite's .run() returns { lastInsertRowid: number | bigint, changes: number }. Declaring the first generic as void keeps that return type intact but serves as idiomatic notation expressing that you don't need a result row type. If you need the new ID after an insert, you can either use the return value of .run() directly, or — as in the example above — pair a RETURNING clause with .get() for something more explicit.
const insert = db.query<User, [string, string]>(
"INSERT INTO users (name, email) VALUES (?, ?)"
);
const result = insert.run("Bob", "bob@example.com");
console.log(result.lastInsertRowid, result.changes);query.as(Class) — Domain Object Mapping Without an ORM
The query.as(Class) feature added in Bun 1.2 maps query results to instances of a specific class. You can attach methods or getters to that class to encapsulate domain logic.
class User {
id!: number;
name!: string;
email!: string;
get displayName() {
return `@${this.name}`;
}
isValidEmail() {
return this.email.includes("@");
}
}
const users = db
.query("SELECT id, name, email FROM users")
.as(User)
.all();
users.forEach((u) => {
console.log(u.displayName);
});One important pitfall here: combining SELECT * with .as(Class) breaks type safety. If columns are added or removed from the table, there's no way to catch the mismatch with the class properties at compile time. Always enumerate return columns explicitly — that's how you preserve the intent of the mapping.
Transaction Wrapper Pattern — Statement Reuse Is Key
db.transaction() takes a function and wraps it in a transaction, automatically rolling back on exception. Since it's synchronous, there's no async/await ceremony. There is, however, a common mistake to watch out for: calling db.query(...) inside the transaction function on every invocation recompiles the SQL each time. This completely throws away the benefit of prepared statements, so always prepare them outside.
const debit = db.query<void, [number, number]>(
"UPDATE accounts SET credits = credits - ? WHERE id = ?"
);
const credit = db.query<void, [number, number]>(
"UPDATE accounts SET credits = credits + ? WHERE id = ?"
);
const transferCredits = db.transaction(
(from: number, to: number, amount: number) => {
debit.run(amount, from);
credit.run(amount, to);
}
);
transferCredits(1, 2, 100);Compared to managing try/catch + manual rollback with an async ORM, the code is much simpler, and when you also reuse statements, the performance characteristics become predictable.
Query Layer Architecture — Creating Type Boundaries With File Structure
Scattering the generic API across the codebase makes it hard to maintain later. I prefer separating the query layer into distinct modules, like this:
// db/users.ts
import { Database } from "bun:sqlite";
interface UserRow {
id: number;
name: string;
email: string;
created_at: string;
}
interface CreateUserParams {
name: string;
email: string;
}
export function createUserRepository(db: Database) {
const findById = db.query<UserRow, [number]>(
"SELECT id, name, email, created_at FROM users WHERE id = ?"
);
const findAll = db.query<UserRow, never[]>(
"SELECT id, name, email, created_at FROM users"
);
const insert = db.query<UserRow, [string, string]>(
"INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email, created_at"
);
return {
findById: (id: number) => findById.get(id),
findAll: () => findAll.all(),
create: ({ name, email }: CreateUserParams) => insert.get(name, email),
};
}This way, the db instance is never exposed directly, and each module only holds the queries it needs. For parameter-less queries, passing never[] as the second generic — expressing "no arguments to bind" — conveys intent more clearly than an empty tuple [] (though omitting it is also valid).
Closing the Schema-Type Gap With Codegen (Conceptual Example)
The generic API doesn't validate whether your interfaces and SQL are in sync before runtime. If the users table's columns change, you still have to manually update the TypeScript interfaces. That gap is what codegen tools are for.
# Conceptual example: a tool that derives types from migrations
# Choose the actual tool based on your project's needs
your-sql-codegen --migrations ./migrations --queries ./src/queriesApproaches vary by tool: parsing migration SQL to infer the schema, applying the schema to a live SQLite instance and reading column types via PRAGMA table_info, or running queries through an execution plan analyzer to get result column types. The goal is the same regardless: when the schema changes, the types update with it, so that accesses like user.nonExistingField are caught at compile time.
As of August 2026, the codegen ecosystem targeting bun:sqlite directly is still thin, so before adopting a tool, always verify its activity level and the range of SQL syntax it supports.
Trade-offs — An Honest Assessment
Driver and Runtime Characteristics Compared
| Item | bun:sqlite (built-in) |
better-sqlite3 (Node.js) |
|---|---|---|
| Read performance | Ahead per Bun benchmarks | Baseline |
| Extra dependencies | None (bundled with runtime) | npm package + native build |
| API style | Synchronous (better-sqlite3 family) | Synchronous |
| Type safety | Generics + codegen combination | Manual interfaces |
| Relational mapping | Manual implementation required | Manual implementation required |
| Migrations | Separate tooling required | Separate tooling required |
It's tempting to squeeze cold start numbers into this table, but that's not a driver-level difference — it's the difference in startup time between the Bun runtime and the Node.js runtime as a whole. Choosing bun:sqlite doesn't mean the driver itself reduces cold starts; it's a side effect of running on the Bun runtime. Keeping that distinction clear prevents misunderstanding. Comparisons with ORMs like Prisma are directionally clear on the performance and bundle size axes, but to put them in a quantitative comparison table, you should back them up with each project's latest official benchmarks.
Common Pitfalls in Practice
Getting blocked when trying to use it with Cloudflare Workers: bun:sqlite's native binary does not run in the Workers environment. If you're targeting Workers, you need to use the D1 API. Design with this constraint in mind from the start.
Concurrent write bottleneck: SQLite's write-locking model makes it unsuitable for high-concurrency write workloads. Enabling WAL (Write-Ahead Logging) mode allows some separation of reads and writes, but if write requests exceed several hundred per second, PostgreSQL or MySQL is the better choice.
Schema change tracking: There is no built-in migration system. You'll need to manage SQL files manually or bring in a separate tool like Drizzle's migration feature or bun-migrate. Using Drizzle only for migrations while keeping everything else ORM-free is a realistic option.
Multi-instance deployment: Because it's a file-based DB, sharing state across multiple instances is a problem. Solve it by adding Turso or Fly.io LiteFS as a layer.
Coexisting With an ORM
You don't have to ditch ORMs entirely. Drizzle ORM officially supports bun:sqlite as an adapter and has a lightweight design focused on edge environments, so the bundle overhead is relatively small. Other ORMs like TypeORM, MikroORM, and Sequelize may have varying support status depending on when you check — always consult the latest official docs before adopting. Mixing Drizzle for complex relational queries with direct bun:sqlite calls for performance-critical simple read paths is a legitimate approach.
How Far to Go Based on Your Project
You don't have to progress through the three layers (generic API / .as(Class) / codegen) in order. Depending on where your project stands, it may make more sense to stop at a certain layer.
In short: if your schema changes often and you have many queries, the upfront investment in codegen pays back quickly. Conversely, if you have fewer than a few dozen queries and a stable schema, the generic API alone is sufficient — and having one fewer tool in the stack actually reduces maintenance burden. For .as(Class), the natural deciding factor is simply: "do I have logic I want to attach to my entities?"
Closing Thoughts
The core value bun:sqlite delivers is the ability to build a query layer where types remain alive, without an ORM, at low dependency cost. It's especially compelling when you need to minimize bundle size and cold starts in an edge environment, or when your domain is too small to justify the abstraction overhead of an ORM.
At the same time, the constraints — Cloudflare Workers incompatibility, concurrent write limits, and the lack of built-in migration tooling — are things you need to know upfront to avoid being caught off guard later. Tool selection is always a function of project context, so rather than making the decision for you, I hope this post gives you the materials to figure out "how far we can reasonably go in our situation."