Skip to content

Cloudflare D1

D1 is SQLite at the edge, so entities, queries and generated SQL are the ones you would run on SQLite. What changes is the runtime around it: the database arrives as a binding on env, and D1 has hard limits the dialect knows about.

wrangler.jsonc
{
"name": "my-app",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"d1_databases": [{ "binding": "DB", "database_name": "my-app", "database_id": "<id>" }],
}

The binding only exists inside a request, so build the pool there. It is a thin wrapper over env.DB: nothing to connect, and end() is a no-op.

src/index.ts
import { D1QuerierPool } from 'uql-orm/d1';
import { Todo } from './models';
export default {
async fetch(request: Request, env: Env) {
const pool = new D1QuerierPool(env.DB);
const todos = await pool.findMany(Todo, { $where: { completed: false }, $limit: 50 });
return Response.json(todos);
},
};

Importing the entities module is what registers them, so keep that import even where a route does not name every entity.

D1 rejects BEGIN TRANSACTION with D1_ERROR: not authorized; a single statement is its only atomic unit. So pool.transaction(...) cannot work there, and neither can the write routes of the HTTP core, which wrap every write in a transaction. Reads and single-statement writes work normally.

When several writes must land together, model them as one statement, make them idempotent, or move that workload to a Durable Object, whose storage API does have transactions.

Limit Value
Bound parameters per query 100 (insertMany chunks to fit)
SQL statement length 100 KB
Query duration 30 s
Database size 10 GB on the paid plan

The parameter cap is the one that surprises people: 655 times smaller than Postgres’, so a bulk insert that is one statement elsewhere becomes many here.

D1 also loads no extensions. FTS5 is available, so full-text search works through an FTS5 virtual table, but there are no vector functions and sqlite-vec cannot be loaded: a $vector sort throws, pointing at Vectorize.

The migrator needs a pool and a D1 binding only exists inside a Worker, so generate the DDL against a local SQLite pool and apply it with Wrangler. The SQL is identical; D1’s dialect differs only in the limits above.

Terminal window
npx uql-migrate sync --dry-run # prints the SQL for your entities
npx wrangler d1 migrations create my-app add_todos
npx wrangler d1 migrations apply my-app --remote

createFetchHandler mounts natively. Set the pool for the request first, since the handler resolves it globally, and remember its write routes cannot work on D1:

import { setQuerierPool } from 'uql-orm';
import { createFetchHandler } from 'uql-orm/http';
const handler = createFetchHandler({ include: [Todo], basePath: '/api' });
export default {
fetch(request: Request, env: Env) {
setQuerierPool(new D1QuerierPool(env.DB));
return handler(request);
},
};

For full SQLite semantics from the same runtime, including transactions, use Turso; for Postgres, Hyperdrive.