drizzle-spanner
v0.1.1
Published
Google Cloud Spanner (GoogleSQL) dialect for Drizzle ORM v1
Maintainers
Readme
drizzle-spanner
drizzle-spanner connects Drizzle ORM v1 beta to
Google Cloud Spanner using the GoogleSQL
dialect. It ships as two packages: drizzle-spanner (the ORM adapter) and
drizzle-spanner-kit (the migration CLI). The full design is in
docs/spec/drizzle-spanner.md.
[!NOTE] This adapter targets Drizzle ORM v1 beta and is itself pre-release.
Installation
Install the adapter next to drizzle-orm and the Spanner driver:
npm install drizzle-spanner [email protected] @google-cloud/spanner@google-cloud/spanner is an optional peer dependency: the adapter takes the
driver's Database structurally and never imports the package itself, so
drizzle.mock() and pure SQL generation work without a driver install.
Tested versions
CI runs the integration suite against each entry in this matrix; a breaking
drizzle-orm beta fails here, not in your application.
| drizzle-orm | Runtimes |
| ------------ | --------------------- |
| 1.0.0-rc.4 | Node 22, Node 24, Bun |
This table is generated from
.github/tested-versions.json by
node scripts/sync-tested-versions.ts; CI fails when they drift.
Runtime support
| Runtime | Status |
| ------------- | ------------------------------------------------------------------------------------------- |
| Node | First-class. engines requires >= 22; CI tests the current LTS set. |
| Bun | Supported; the integration suite runs under Bun in CI (gRPC over node:http2). |
| Edge runtimes | Unsupported. @google-cloud/spanner requires gRPC, which edge runtimes do not provide. |
drizzle-spanner-kit needs Node >= 22.18 (it loads TypeScript config files
through the runtime's native TS support) or Bun.
Quickstart
Define a schema, hand drizzle() the driver's Database, and query. Spanner
requires a primary key on every table and has no auto-increment; the
recommended default is a STRING(36) UUID key:
import { Spanner } from '@google-cloud/spanner'
import { drizzle, spannerTable, string, timestamp } from 'drizzle-spanner'
export const singers = spannerTable('singers', {
id: string('id', { length: 36 }).primaryKey().defaultGenerateUuid(),
name: string('name', { length: 'max' }).notNull(),
updatedAt: timestamp('updated_at', { allowCommitTimestamp: true })
})
const spanner = new Spanner({ projectId: 'my-project' })
const database = spanner.instance('my-instance').database('my-database')
const db = drizzle(database)
const rows = await db.select().from(singers)Primary keys
Spanner has no auto-increment, and monotonically increasing keys hotspot its range-sharded storage. The adapter exposes three alternatives, in order of preference:
- UUID string keys —
string('id', { length: 36 }).primaryKey().defaultGenerateUuid(). The default choice. - Bit-reversed identity —
int64('id').primaryKey().generatedAsIdentity(). - Sequences — the
sequence()helper, for keys shared across tables.
Interleaving and relational queries
Interleaving is physical layout; relations are query semantics. The adapter
derives nothing from one to the other, so the canonical parent-child setup
declares both — interleaveInParent in the table's extra config and the
relation through defineRelations:
Spanner matches the interleave prefix by column name: the child's
primary key must start with the parent's key columns under the same names,
so the parent key here is singer_id, not id. The types reject a child
that omits a parent key column, declares it with a different type, or puts
it in the wrong key position; spannerTable enforces the full rule at
definition time, including the key orders the types cannot read.
import { defineRelations } from 'drizzle-orm/relations'
import {
index,
interleaveInParent,
primaryKey,
spannerTable,
string
} from 'drizzle-spanner'
const singers = spannerTable('singers', {
singerId: string('singer_id', { length: 36 }).primaryKey().defaultGenerateUuid(),
name: string('name', { length: 'max' }).notNull()
})
const albums = spannerTable(
'albums',
{
singerId: string('singer_id', { length: 36 }).notNull(),
albumId: string('album_id', { length: 36 }).notNull(),
title: string('title', { length: 1024 })
},
(t) => [
primaryKey({ columns: [t.singerId, t.albumId] }),
interleaveInParent(singers, { onDelete: 'cascade' }),
index('idx_albums_title').on(t.title).nullFiltered()
]
)
const relations = defineRelations({ singers, albums }, (r) => ({
singers: {
albums: r.many.albums()
},
albums: {
singer: r.one.singers({
from: r.albums.singerId,
to: r.singers.singerId
})
}
}))
const db = drizzle(database, { relations })
const withAlbums = await db.query.singers.findMany({
with: { albums: true }
})Nested collections compile to correlated ARRAY(SELECT AS STRUCT ...)
subqueries — one round trip, and Spanner's most efficient read path for
interleaved children.
Transactions
db.transaction runs a read-write transaction through the driver's retry
loop:
await db.transaction(
async (tx) => {
const [singer] = await tx.select().from(singers).limit(1)
await tx.update(singers).set({ name: 'Ada' })
},
{ maxRetries: 3, timeout: 60_000 }
)[!WARNING] The transaction callback must be free of external side effects. Spanner aborts read-write transactions under contention and the driver re-executes the whole callback, possibly several times. Anything that isn't a query — sending an email, writing a file, incrementing an in-memory counter — runs once per attempt, not once per transaction.
Read-only transactions and stale reads
Read-only transactions run on a snapshot and accept a staleness bound
(strong, exactStaleness, or readTimestamp):
await db.transaction(
async (tx) => {
return tx.select().from(singers)
},
{ readOnly: true, staleness: { exactStaleness: '15s' } }
)Single-use bounded reads support two additional bounds, maxStaleness and
minReadTimestamp, which Spanner accepts on single-use reads only:
const rows = await db.select().from(singers).withStaleness({ maxStaleness: '10s' })Buffered mutations
{ mode: 'bufferedMutations' } compiles inserts, updates, and deletes to
Spanner mutations buffered until commit — the cheapest write path for bulk
writes that don't read. Reads and .returning() throw typed errors in this
mode.
await db.transaction(
async (tx) => {
await tx.insert(events).values(batch)
},
{ mode: 'bufferedMutations' }
)Counting, raw SQL, and a client-less database
The standard drizzle idioms ship with the runtime: db.$count is a
COUNT(*) convenience, db.execute(sql) is the raw-SQL escape hatch, and
drizzle.mock() builds a database with no driver at all — SQL generation
and tests need no @google-cloud/spanner install:
const total = await db.$count(singers, eq(singers.name, 'Ada'))
const rows = await db.execute(sql`SELECT 1 AS one`)
const mockDb = drizzle.mock()
const { sql: text, params } = mockDb.select().from(singers).toSQL()Upsert
Spanner has no ON CONFLICT. .orUpdate() and .orIgnore() compile to
GoogleSQL's INSERT OR UPDATE / INSERT OR IGNORE, and .returning()
composes with both:
await db.insert(singers).values({ id, name: 'Ada' }).orUpdate().returning()[!IMPORTANT] On conflict,
INSERT OR UPDATEreplaces every column listed in the statement with the inserted value. There is noonConflictDoUpdate-style clause: you cannot update a different column set than you insert, and you cannot compute the update from the existing row (SET x = x + 1). See ADR 0002.
Migrations
drizzle-spanner-kit provides generate, migrate, pull, and push.
Configure it with a drizzle-spanner.config.ts:
import { defineConfig } from 'drizzle-spanner-kit'
export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
database: {
project: 'my-project',
instance: 'my-instance',
database: 'my-database'
}
})Generate a migration from your schema changes, then apply it:
npx drizzle-spanner-kit generate
npx drizzle-spanner-kit migrateApplications that migrate at startup use the runtime migrator instead of the CLI:
import { migrate } from 'drizzle-spanner/migrator'
await migrate(db, { migrationsFolder: './drizzle' })Applied migrations are recorded in a drizzle_migrations bookkeeping table
(UUID primary key and a sha256 hash per migration), so re-running is a no-op.
Two more commands round out the workflow: pull introspects an existing
database (INFORMATION_SCHEMA — tables, columns, interleaving, indexes,
sequences, foreign keys, check constraints) into schema files and a baseline
snapshot, and push diffs the schema against the live database directly —
useful against the emulator during development:
npx drizzle-spanner-kit pull
npx drizzle-spanner-kit pushWhen a schema diff requires DDL Spanner cannot express (a primary-key or
interleave change), generate refuses with a diagnostic that explains the
manual path — it never emits a silent DROP + CREATE. push shares the
differ, prints the full DDL plan, and asks for confirmation before applying.
The full diagnostics catalogue is in the
kit README.
Local development
The Spanner emulator covers the integration test suite and the dev loop:
npm run emulator:up
npm run test:integrationSee docs/emulator.md for the emulator's limits.
Examples
Two runnable examples live in examples/, verified against the
emulator in CI: basic-crud (schema, migrate, CRUD,
a read-write transaction) and interleaved-rqb
(interleaved singers/albums, relational queries, buffered mutations, a stale
read, schema applied via push).
