Database resolution
Product.find(id) works with no setup, because a Model knows its D1 binding. But a Worker isn’t a long-running server, and that shapes how a query finds its database.
One isolate, many requests
A Worker runs in an isolate that serves many requests at the same time, and a Model class is shared by all of them. So anything stored on a class is shared by every request in flight. That rules out storing a connection on a class: some of what a connection carries belongs to one request only.
- A D1 Session keeps one user’s reads at least as new as their own writes. Shared, one user’s Session would serve another’s queries.
- An
onQuerylistener might tag each query with its request’s ID. Shared, it would mix requests.
Rails doesn’t keep its connection on the class either: each thread checks one out from a pool, and gives it back afterwards. The JavaScript equivalent of “per request, without passing it around” is AsyncLocalStorage, which follows a request through every await.
Three ways to find a database
A query finds its database in one of three ways, in this order:
- The request’s database. Inside
withDatabase(connect(env.DB, { session, onQuery }), fn), everythingfnawaits uses that database, kept in the request’sAsyncLocalStorage, never on a class. It’s matched to Models by binding, so a request can open several. - The Model’s binding. Outside
withDatabase, a Model uses the D1 binding its base class names (static binding = "DB"), read fromcloudflare:workers. The binding is the same object for every request, and the database made for it holds nothing per request (no Session, no listener), so sharing it is safe. This is what makesProduct.find(id)work with no setup. - An explicit database.
connect(env.DB).model(Product)always uses that database, whateverwithDatabasesays. It suits code that passes its database around, or uses two at once.
Most apps only ever use the second. The first exists for what belongs to a request: Sessions, per-request logging, and tests, which run outside a Worker and have no binding to find.
Which do I need?
| Your situation | What to do |
|---|---|
| A Worker with one D1 database | Nothing: User.find(id) uses its binding |
| D1 read replication, reading your own writes | A Session per request |
Logging queries, or Server-Timing | onQuery, app-wide or per request |
| Vitest tests, seed or migration scripts | withDatabase(connect(binding), …), or db.model(...) |
| Several D1 databases | One base class per binding |
A compatibility date before 2026-08-04, using withDatabase | Add nodejs_compat to your Wrangler config’s compatibility_flags |
What follows from it
- Records remember. A record keeps the database it was loaded through, or first saved through, and so do the records saved with it (autosaved records, a
belongsTotarget), sopost.save()andpost.user()use it later, even afterwithDatabasehas returned. - One query or write, one database.
merge,or, scopes, batches, and saves each run on one database. What counts is the database each side resolves to when it runs, so insidewithDatabasea loaded record’suser.posts()merges withPost.published(). Two databases are refused withIncompatibleRelation, before anything is written. - Errors say where the database should come from. A query outside a Worker with no
withDatabase, a Model with no binding, or a query at a module’s top level (where Workers allow no I/O) each throwsNoDatabase, saying what to do.
For the options themselves, see Connections.