Skip to main content
Drizzle ORMDrizzle ORM

Drizzle ORM

drizzle-postgrejs is a Drizzle ORM driver for postgrejs. Put it where drizzle-orm/node-postgres goes and everything above it stays the same — your schema, your queries, your migrations.

On large values it is faster and holds much less memory. A 4MB bytea comes back in 13.6 ms against 30.4 ms, at 4.5 MB against 51.7 MB; pg holds that column as hex text, twice the size, off the JS heap where a heap figure alone can't see it. A 100k-element int4[] runs 3.9x faster, at 2.3 MB against 33.6 MB. Ordinary queries gain less, but repeatably: a point read is the faster of the two in 100 of 101 alternated pairs. The client underneath can also do things Drizzle has no way to ask for.

Installation​

npm install drizzle-postgrejs drizzle-orm postgrejs

drizzle-orm (>=0.44.6 <0.46.0) and postgrejs (>=3.11.0 <4) are peer dependencies. Node >=22, PostgreSQL 14 or later — 14 and 18 are what CI runs.

The 0.45 line is what this targets. Drizzle's own 1.0 rewrites the driver seam — PgPreparedQuery becomes PgBasePreparedQuery, row mode moves from a flag to a method, type handling becomes a per-driver codec table — so a 1.0 driver will be a rewrite rather than an adaptation.

Quick start​

import { drizzle } from 'drizzle-postgrejs';

const db = drizzle('postgres://localhost:5432/mydb');

await db.select().from(users).where(eq(users.name, 'ada'));

That's the whole change.

Usage​

Connecting​

Four ways to say where the database is — the same four drizzle-orm/node-postgres takes:

drizzle('postgres://localhost:5432/mydb'); // a connection string
drizzle({ connection: 'postgres://…' }); // the same, named
drizzle({ connection: { host, port, database } }); // postgrejs's own options
drizzle(pool); // a Pool you made yourself

A pool this package opened is on db.$client, closed with await db.$client.close(). A Connection works in place of a Pool when a single connection is what's wanted.

Queries​

Relational queries need the schema, as usual:

const db = drizzle(pool, { schema });

await db.query.users.findMany({ with: { posts: true } });

Transactions hold one connection for the whole block and give it back however it ends; a nested transaction is a savepoint:

await db.transaction(async tx => {
await tx.insert(users).values({ name: 'ada' });
await tx.transaction(async inner => {
await inner.insert(posts).values({ userId: 1, title: 'one' });
});
});

Everything Drizzle's interface doesn't reach — COPY, LISTEN/NOTIFY, cursors, large objects, logical replication — is still there on the postgrejs pool passed in, or on db.$client.

Options​

Drizzle's own options (schema, logger, casing, cache) work as on any driver. These are this driver's:

OptionDefaultWhat it does
unknownTypesAsStringtrueAsk the server for text on any column postgrejs has no decoder for
fetchAsString[]Extra OIDs to fetch as text, on top of the ones Drizzle needs
preparepostgrejs's ownfalse keeps statements out of postgrejs's prepared-statement cache

The defaults are set so every column reaches Drizzle in the shape its own column mappers were written for, and so PostgreSQL types each parameter from where it lands rather than from the JavaScript value's own shape. See the drizzle-postgrejs driver-design notes for the measurement behind each default.

Migrations​

drizzle-kit generate needs nothing from this package — it reads your schema files and writes SQL. Applying them is migrate(), the same call drizzle-orm/node-postgres/migrator exposes, reaching Drizzle's own runner through this driver:

import { migrate } from 'drizzle-postgrejs';

await migrate(db, { migrationsFolder: './drizzle' });

drizzle-kit migrate and drizzle-kit push are the other way to apply them, and they connect on their own rather than through a driver — drizzle-kit reaches for pg for a postgresql dialect and has no seam a third-party driver can enter. So they need their own dbCredentials in drizzle.config.ts, unchanged by which driver the application uses.

Why​

It's a drop-in swap for drizzle-orm/node-postgres: the same drizzle(client) call, the same schema, the same queries, the same migrations. The differences:

  • It is faster where the payload is large: 2.2x on a 4MB bytea, and 3.9x on a 100k-element int4[] whose values use the whole type, on a fraction of the memory, because values arrive in PostgreSQL's binary format and are not parsed from text.
  • It is slightly faster on ordinary round trips, repeatably, because statements are prepared and reused without anyone asking for it.
  • The client underneath can do what Drizzle has no way to ask for: cursors, COPY, LISTEN/NOTIFY, large objects, logical replication and pipelining, on the same pool your queries use.
  • It is checked against Drizzle's own integration suite: 183 of its tests pass, with drizzle-orm/node-postgres run over the same server in the same invocation as the control.
Scenarionode-postgresdrizzle-postgrejsSpeedup / memory
Point read — 1 row of 9 columns0.283 ms / 28 KB0.248 ms / 31 KB1.14x, +11%
Page of 200 — 200 rows of 9 columns, mixed types0.721 ms / 398 KB0.632 ms / 298 KB1.14x, −25%
Concurrent reads — 20 at once, pool of 101.105 ms / 472 KB1.013 ms / 543 KB1.09x, +15%
int4[] of 100k, full width22.135 ms / 33.6 MB5.616 ms / 2.3 MB3.94x, −93%
float8 of 5k rows, full width1.104 ms / 1.4 MB0.728 ms / 1.5 MB1.52x, +1%
float8[] of 5k in one row2.231 ms / 2.8 MB0.579 ms / 232 KB3.85x, −92%
uuid of 5k rows1.202 ms / 1.5 MB0.875 ms / 1.6 MB1.37x, +3%
box of 5k rows2.006 ms / 2.0 MB1.036 ms / 2.0 MB1.94x, level
bytea of 4MB30.371 ms / 51.7 MB13.625 ms / 4.5 MB2.23x, −91%
Insert one row — 6 parameters0.308 ms / 28 KB0.272 ms / 35 KB1.13x, +23%
Insert 500 rows — 1 statement, 2500 parameters4.605 ms / 3.7 MB4.370 ms / 4.0 MB1.05x, +7%
Insert a 4MB bytea — binary both sides14.267 ms / 5.2 MB13.873 ms / 4.0 MB1.03x, −22%
Insert a 100k int4[] — text both sides16.765 ms / 27.3 MB12.800 ms / 1.8 MB1.31x, −93%
Twenty inserts in a transaction5.997 ms / 370 KB5.203 ms / 470 KB1.15x, +27%

drizzle-orm 0.45.3, postgrejs 3.12.1, PostgreSQL on loopback, Node 24.15.0. Medians — how that was measured is below.

The size of the gain depends on the payload, not on the query. An ordinary read or write gains a little, consistently; a column that carries bulk (an array, a bytea, anything large in a raw db.execute()) gains in both time and memory. A schema of text, integers and timestamps will see figures like the top rows of this table, not the bottom ones.

How the numbers were measured​

Both drivers run in one process and alternate inside every pair, with the order swapped each time, so neither gets a warmer machine than the other. Each figure above is a median of 101 pairs, or 61 and 41 for the heavier workloads.

The medians alone wouldn't be worth much — this is a shared machine, and the absolute figures drift (the same node-postgres point read came out at 0.270 ms and 0.521 ms in two runs an hour apart). What doesn't drift is which driver won each pair, so that's counted separately:

ScenarioPairsdrizzle-postgrejs faster inOdds of that by luck
Point read101100< 1 in 10²⁸
Page of 20010181< 1 in 10⁹
Concurrent reads6152< 1 in 10⁷
int4[] of 100k, full width4141< 1 in 10¹²
float8 of 5k rows, full width6161< 1 in 10¹⁸
float8[] of 5k in one row6161< 1 in 10¹⁸
uuid of 5k rows6161< 1 in 10¹⁸
box of 5k rows6161< 1 in 10¹⁸
bytea of 4MB4141< 1 in 10¹²
Insert one row101100< 1 in 10²⁸
Insert 500 rows6143p = 0.002
Insert a 4MB bytea4129p = 0.012
Insert a 100k int4[]4141< 1 in 10¹²
Twenty inserts in a transaction6161< 1 in 10¹⁸

That's a sign test — only which driver won counts, and by how much is thrown away, which is why it holds up on a noisy machine. Two drivers of equal speed would split the pairs evenly, so the last column is the probability of seeing a split that lopsided from a fair coin. Two rows say "not distinguishable" and are reported that way rather than rounded into a win.

Result columns arrive in PostgreSQL's binary format and are decoded per type, where pg asks for text and parses it. On bulk that's the whole difference: a 100k-element int4[] costs 5.6 ms and 2.3 MB here against 22.1 ms and 33.6 MB, because the text path has to materialize the array literal as one string before it can parse it. It's also cheaper on the wire: a bytea in text is \x-prefixed hex, two characters per byte, so the 4MB column costs 8MB of network under pg and 4MB here.

postgrejs names and caches a statement per connection — 64 by default, least-recently-used closed — so each distinct SQL string is parsed and planned once rather than on every call. Counted from the backend: three queries through this driver leave one prepared statement behind, and the same three through drizzle-orm/node-postgres leave none, because pg prepares only a query it was given a name for and Drizzle doesn't give it one. That accounts for the point read winning 100 of 101 pairs. Drizzle's own .prepare(name) still works as it always did; it's no longer the only way to get a statement prepared.

Where Drizzle's column mappers want PostgreSQL's own text — numeric, the date and time family, and their array forms — postgrejs asks the server for text, as a Bind format code, rather than decoding the value and printing it again. So the string is PostgreSQL's own and can't drift from what pg received, and nothing on this side has to track the session's DateStyle, IntervalStyle or TimeZone to produce it.

postgrejs can put several statements on one connection at a time. This driver doesn't ask it to: every query gets a connection to itself, exactly as under pg, and that is why the concurrency row above is level. This driver leaves that capacity unused.

Run it yourself with npm run bench in the drizzle-postgrejs repo; its doc/BENCHMARKS.md has the peak-heap figures and the rest of the method.

Tested against Drizzle's own suite​

integration-tests/tests/pg/pg-common.ts in the drizzle-orm repository is a shared suite every driver Drizzle ships points at itself, declaring what it can't pass through skipTests(). This driver runs it with no skips at all, twice — once on itself and once on drizzle-orm/node-postgres over the same server in the same invocation, as the control:

node-postgres (control) 183 / 183
drizzle-postgrejs 183 / 183

Both run the same tests and both pass every one of them, so there is no test this driver loses that node-postgres wins. There's no expected-failure list either: the control run measures the baseline on the machine it's run on, so that's the only thing the comparison can fail on. It has to be a control rather than a fixed number, because the suite asserts row order in three places without writing an ORDER BY, so its score moves with the PostgreSQL version — 183 of 183 on the 14 its own Docker setup pins, 180 of 183 on 18, for both drivers alike. On drizzle-orm 0.44.6, the other end of the peer range, the suite is 179 tests and both score 179. A weekly CI job runs the matrix of both Drizzle versions against PostgreSQL 14 and 18.

The package also has 212 tests of its own, including a differential suite that runs every case through drizzle-orm/node-postgres as well and compares the two.

What changes when you switch​

Measured against drizzle-orm/node-postgres on the same schema and the same server.

Values​

Drizzle has a column for none of these, so they reach you only through a raw db.execute():

Casenode-postgresdrizzle-postgrejs
money"$12.34"12.34
int4range and the rest of the range family"[1,5)"a Range
path, polygon, box, lsegthe text literaltheir own classes
circle{ x, y, radius }a Circle — same fields
point{ x, y }the text "(1,2)"
line"{1,2,3}"the same

point is the one that runs the other way, and on purpose: Drizzle's own point column hands the driver's value back untouched, so a class instance would reach the caller where pg gives a plain object. It's asked for as text instead and Drizzle parses it — so through Drizzle's own columns both drivers answer [1, 2] and [1, 2, 3], identically, and the difference above exists only in a raw execute().

Everywhere else the two agree, checked by a differential suite that runs the same Drizzle calls through both drivers and deep-compares the rows — including the awkward cases: numeric keeps every digit, timestamp is the same instant, date in string mode doesn't shift a day, and an array keeps its empty-string elements. Enums work with nothing registered anywhere.

Results​

db.execute() results carry a commandTag as well as command. pg keeps only the first word of the server's command tag, so four kinds of CREATE and four kinds of DROP are one word each; command matches pg for compatibility and commandTag is the whole tag — CREATE INDEX rather than CREATE.

fields is postgrejs's own FieldInfo, with fieldName and dataTypeId rather than pg's name and dataTypeID, plus the JS type and whether the column is an array. pg's other Result properties (oid, _parsers, _types, RowCtor) aren't there.

Errors​

Errors are DatabaseError, and Drizzle still wraps them in DrizzleQueryError with the driver error as cause. Every field worth branching on matches — code, severity, detail, hint, schema, table, column, constraint — but position is a number where pg gives a string, line means a different thing on each side (pg's is PostgreSQL's own C source line, postgrejs's is the line of SQL), file/routine are absent (lineNr/colNr instead), and instanceof against pg's class doesn't hold.

Several statements in one db.execute()​

It works here too, though not the way it does under pg. pg accepts several statements in one call because a parameterless query goes over PostgreSQL's simple protocol — a guess from whether the call happens to carry parameters. postgrejs's query() is always the extended protocol and its execute() is the simple-protocol counterpart, so the driver has to choose: it asks isMultiStatement(), a scanner over everything a ; can hide inside, and sends the right one first time.

A multi-statement call still gets the server's own error if it carries parameters, since a simple query has nowhere to put them. And if the scanner is ever wrong the other way, the 42601 fallback is still behind it — the server raises that while parsing, before any statement has run, so the retry costs nothing and risks nothing. The result is a bare array, one entry per statement, as pg returns it.

connectionString​

Accepted. pg's spelling is translated into postgrejs's own options, so a DATABASE_URL and the rest of an existing node-postgres setup move over unchanged — new Pool({ connectionString }) alone would otherwise open on the default localhost:5432/postgres without complaint, since connectionString isn't postgrejs's own spelling.

Full documentation​

See the drizzle-postgrejs README for the full benchmark method and more on what's ahead, its migration checklist for a closer look at what to check when switching, and orm.drizzle.team for the query builder and schema APIs — none of that is postgrejs-specific.