Why we wrote an ORM only for Cloudflare D1
Cloudflare D1 has limits and features with no counterpart in other databases, and an ORM that abstracts for portability hides them. This is why we wrote orm-d1 (formerly d1zzle), an ORM targeting only D1 and Workers, and how it is designed.
The API matches Drizzle, so schemas and queries are written the same way.
import { drizzle, eq, sqliteTable, text } from 'orm-d1';
const db = drizzle(env.DB);
await db.select().from(users).where(eq(users.id, 1)).get();
Per-plan limits, warned at runtime
D1’s limits differ between the free and paid plans, and the library can be told which one it runs on.
const db = drizzle(env.DB, { plan: 'free' }); // 'paid' is also accepted
| free | paid | |
|---|---|---|
| Queries per Worker invocation | 50 | 1,000 |
| Database size | 500 MB | 10 GB |
Neither is known until statements actually run, so these are development-only warnings. Queries
inside batch() are counted individually, because D1 counts them that way, which means
batch() is not a way around the limit. For size, the meta.size_after D1 returns on every
statement is checked, and one warning is emitted once it passes 90% of the limit.
Fixed limits, checked at build time
Limits that do not depend on the plan are checked while the query is built. A Worker isolate compiles once, and the query is already being walked at that point, so the check costs almost nothing.
| Limit | Value |
|---|---|
| Bind parameters per query | 100 |
| SQL statement length | 100,000 bytes |
| Arguments to a SQL function | 32 |
| Columns per table | 100 |
SQLite’s too many SQL variables does not say which inArray caused it; an error at build
time can name the call site. The limit is also avoided: inserts are split automatically, and
inArray expands to json_each, folding a long list into a single parameter.
Decisions that follow from D1
- Reads are positional (
.raw())..all()builds a keyed object per row, so duplicate column names in a join lose one of the values. batch()is the unit of atomicity. It is the only atomicity guarantee D1 offers.- No
transaction(). D1 has no interactive transactions, and aBEGINmay reach a different connection. - Sessions and bookmarks are supported. They are D1’s read replication mechanism and have no counterpart in Postgres or MySQL drivers.
rows_read/rows_writtenare returned. They are D1’s billing unit and are present in every response.
Bundle size
A Worker containing the driver, the schema DSL and one query, bundled with esbuild.
| minified | gzipped | |
|---|---|---|
drizzle-orm/d1 + drizzle-orm/sqlite-core | 77.8 kB | 22.2 kB |
orm-d1 | 44.1 kB | 15.3 kB |
The difference is not tree-shaking. Only unreachable code can be dropped, and both the dialect indirection and the transaction implementation are reachable from the SQLite entry point.
orm-d1 runs in production in arukutomaru, with Better Auth on top of it.
Nami Smart LLC
Fast, cost-efficient, high-quality app development. If you would like to talk about a project, please get in touch.