Postgres.js: A Tagged-Template PostgreSQL Client for Node.js, Deno, Bun and Cloudflare Workers
Postgres.js - The Fastest full featured PostgreSQL client for Node.js, Deno, Bun and CloudFlare
At a glance
- What is it?
- Postgres.js is a JavaScript PostgreSQL driver built around tagged template literals, with one source tree transpiled for Node.js, Deno and workerd. It suits teams that want parameterised SQL without an ORM, and it is the wrong tool if you need a query builder or an external pooler.
- Who is it for?
- Adopt Postgres.js if you write SQL by hand in JavaScript or TypeScript and want parameters bound through tagged templates rather than string concatenation, and if your runtime is Node.js 12 or newer, Deno, Bun or workerd. Do not adopt it if you need a query builder that constructs SQL from method calls, or if you expect the driver itself to pool connections across processes; the README documents client-side connection options, not an external pooler.
- Can I use it commercially?
- Yes. Unlicense is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 27 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 29, 2026, and from our analysis. They are not legal advice.
DEEP OPEN-SOURCE ANALYSIS
What Postgres.js replaces in a Node.js data layer
Most JavaScript PostgreSQL code ends up in one of two shapes. Either an ORM builds the SQL for you, or you concatenate strings and hope the escaping holds. Postgres.js takes a third position: you write SQL, but the parameters never enter the query string as text. The README frames the library as a full-featured client for Node.js and Deno, and the package keywords list driver, postgresql, client and sql, so the intended audience is people who already know SQL and want a thin layer over it.
The concrete problem it addresses is parameter handling. The README states that parameters are extracted and handled by the database so that SQL injection is not possible, and that any generic value is serialised according to an inferred type and replaced by a PostgreSQL protocol placeholder $1, $2 and so on. That is a different mechanism from client-side escaping. The values travel separately from the statement, and Postgres does the casting.
The second problem is dynamic query shape. Inserting a row whose column set varies at runtime normally means building a column list and a placeholder list by hand. Postgres.js exposes a helper, written as sql(value, 'name', 'age'), that expands an object into the column list and the matching placeholders in one expression. The README shows the expansion explicitly: insert into users ("name", "age") values ($1, $2).
There is a third, quieter problem the library solves: the same source has to run in more than one JavaScript runtime. The repository keeps separate src, cjs, deno and cf directories, and package.json exposes them through export conditions. A team shipping a Node.js service and a Cloudflare Worker against the same database schema can keep one query module instead of two, provided the queries stay within what the shared source supports.
How the tagged template mechanism actually works
The core of the library is a tagged template function. The README describes it as processing query parameters before interpolation, which is the opposite order from a naive template string. In a normal template literal, JavaScript substitutes the value into the string and you receive finished text. In a tagged template, the function receives the static string fragments and the interpolated values as separate arguments, so it can rewrite each value into a protocol placeholder and keep the fragments as the statement body.
The README gives a worked example with a name and an age: the query selects from users where name like ${ name + '%' } and age > ${ age }. The expression inside the interpolation is evaluated by JavaScript first, and the resulting value is then bound as a parameter. The README warns about the common mistake here. Wrapping an interpolated parameter in quotes produces '$1', which Postgres reads as a string literal rather than a parameter reference, and the README says this will cause an error.
Execution is lazy. The README notes that queries are first executed when awaited, or instantly when .execute() is called. That matters for code that builds a query object and passes it around: nothing has hit the network until the promise is awaited. It also means an unhandled rejection surfaces at the await site rather than at the point where the query was written, which changes where your error boundary belongs.
Results come back as a Result array of objects mapping column names to rows, which the README illustrates with an insert returning * producing [{ user_id: 1, name: 'Murray', age: 68 }]. The README's table of contents also lists listen and notify and realtime subscribe among the supported areas, alongside transactions, data transformation, custom types, reserving connections and error handling. Those are the parts of the surface a reader should skim before assuming this is only a query wrapper.
Installing Postgres.js and running a first query
The README gives one installation command, run from your project directory. It installs the package under the name postgres, which is also the name used in imports.
npm install postgresNext, create the client instance in its own module. The README's example passes an options object and notes that the client will use psql environment variables when no connection details are given, so a local development database reachable through the standard PG variables works without arguments.
// db.js
import postgres from 'postgres'
const sql = postgres({ /* options */ }) // will use psql environment variables
export default sqlThen import that instance where you need it and write a query. The README's users example interpolates an age value directly into the where clause. Because the value becomes a placeholder, you do not quote it. The comment in the README shows the expected shape of the result: an array of row objects.
// users.js
import sql from './db.js'
async function getUsersOver(age) {
const users = await sql`
select
name,
age
from users
where age > ${ age }
`
return users
}The README also shows an insert that returns the created row, which is the quickest way to confirm the connection is live and the placeholder binding is working. If the query returns a row object with the values you passed in, the client is talking to the database correctly.
const xs = await sql`
insert into users (
name, age
) values (
'Murray', 68
)
returning *
`If your project uses CommonJS or a bundler that resolves the default export, the README documents an ESM dynamic import form: const { default: postgres } = await import('postgres'). That is worth knowing before you file a bug about a missing default export, because package.json maps the bun, workerd, import and default conditions to four different entry files.
Dynamic inserts and the column-set problem
The feature that separates Postgres.js from a plain query wrapper is the sql() helper for building column lists. The README shows inserting an object with the call sql(user, 'name', 'age') inside the insert statement, which produces insert into users ("name", "age") values ($1, $2). The same helper works for update statements, where it expands into set "name" = $1, "age" = $2.
The README notes that you can also pass an array of column names instead of listing them as arguments, and that omitting column names entirely makes the object keys act as columns. It also warns, in the same paragraph, not to let users supply columns you do not want inserted. That warning is the design trade-off in one sentence: convenience in exchange for a rule you have to enforce yourself.
Bulk inserts get the same treatment. Passing an array of objects to sql() with a column list produces a multi-row values clause, which the README shows as insert into users ("name", "age") values ($1, $2), ($3, $4). The README's stated reason is speed: one statement instead of many round trips. The README's example array also carries a garbage key that is excluded by the column list, which is the practical demonstration that the column arguments, not the object keys, decide what reaches the table.
Dynamic column selection works the same way on the read side. The README shows sql(columns) inside a select list, with an array of column names producing select "name", "age" from users. The identifiers are quoted in the generated SQL, so a column name that needs quoting is handled without you writing the quotes.
Where Postgres.js is the wrong choice
The library assumes you are comfortable writing SQL. There is no query builder in the README. If your team wants to compose queries from method calls, or wants a schema definition that generates migrations, Postgres.js gives you neither; the README's table of contents covers connection, queries, building queries, transactions, data transformation, listen and notify, realtime subscribe, numeric handling, connection details, custom types, teardown, error handling, TypeScript support and reserving connections. Migrations and schema management are not on that list.
The tagged template mechanism also imposes a discipline that is easy to break in a large codebase. Any place where a parameter is wrapped in quotes, or where a value is concatenated into the template fragments rather than interpolated, defeats the binding. The README calls out the quoting case directly, which suggests it is a recurring source of confusion rather than a theoretical one. A reviewer who does not know the rule will read quoted interpolation as harmless string formatting.
Connection pooling is a second boundary. The README documents connection options on the client, including host, port, database, username and password, and mentions reserving connections. It does not describe an external pooler, and nothing in the README suggests the driver coordinates connections across separate processes. If your deployment needs that, the pooling layer is a separate decision from this library, and it is the decision that usually determines whether a serverless deployment survives a traffic spike.
Finally, the package targets several runtimes through transpiled output. The repository layout contains separate src, cjs, deno and cf directories plus transpile.cjs, transpile.deno.js and transpile.cf.js, and package.json lists node >=12 under engines. The README does not document rollback or a downgrade procedure for the build output, so version pinning is the only lever available. A team that vendors the source inherits the build step as well as the runtime code.
How Postgres.js differs from node-postgres
The closest comparison in the JavaScript PostgreSQL space is node-postgres, usually imported as pg. The difference is in the calling convention. A pg client takes a query string and a separate parameters array, so the statement and its values are two arguments you assemble yourself. Postgres.js puts the values inside the template literal and derives the parameters from it. Both end up sending placeholders to the server; the question is whether the placeholder positions are something you count or something the library counts for you.
The second difference is the dynamic column helper. With a parameters-array client, generating an insert from an object means building the column list, the placeholder list and the values array, then keeping the three in sync. Postgres.js collapses that into sql(object, columns), which the README shows expanding to the full statement fragment. The failure mode of the manual approach is an off-by-one between the column list and the placeholder list, which produces a wrong-shaped insert rather than an error.
A third difference is packaging. Postgres.js ships separate builds for Node.js, Deno, Bun and workerd under one package name, with export conditions in package.json selecting among them. A client that targets only Node.js does not need that machinery, and a runtime that is not Node.js cannot use a client that lacks it. That is the strongest argument for choosing this library on a Deno or Cloudflare Workers project, and a weaker one on a plain Node.js service where either option works.
A fourth difference is what the library does not do. Neither project is an ORM, so the comparison is not about features you would get from a schema layer. It is about how much of the SQL string you own. Postgres.js owns less of it, and in exchange you accept the interpolation rules it defines.
Licence and the cost of keeping up
Postgres.js is released under the Unlicense, which the repository includes as a UNLICENSE file at the top level. The Unlicense is a public-domain dedication rather than a permissive licence with attribution conditions, so it does not carry the notice requirements that MIT or Apache-2.0 impose. That is a description of the licence text, not legal advice; if your organisation has a policy on public-domain dedications, route it through the people who own that policy.
The maintenance picture is straightforward. The repository is not archived, and the last push was on 2026-09-02. The most recent release is v3.4.9 from 2026-04-05, preceded by v3.4.8 on 2026-01-06 and v3.4.7 on 2025-05-21. The gap between the last push and the last release is worth noting if you depend on tagged releases rather than the default branch.
Upgrade cost is shaped by the build step. package.json defines a prepare script that runs npm run build, which chains build:cjs, build:deno and build:cf through the three transpile scripts, and a prepublishOnly script that runs lint. If you install from the published package you get the prebuilt directories listed under files. If you vendor the source or build from a git checkout, you are running eslint over src and tests and the three transpilers yourself.
The README does not document a supported downgrade path. Pinning a version in package.json is the mechanism available, and the version field is the number to pin. If your project depends on the cjs or cf output specifically, the export conditions in package.json are what decides which file your bundler loads, so a version bump can change which build you get even when the public API has not moved.
Editorial conclusion
Adopt Postgres.js if you write SQL by hand in JavaScript or TypeScript and want parameters bound through tagged templates rather than string concatenation, and if your runtime is Node.js 12 or newer, Deno, Bun or workerd. Do not adopt it if you need a query builder that constructs SQL from method calls, or if you expect the driver itself to pool connections across processes; the README documents client-side connection options, not an external pooler. Before committing, verify that the interpolation rule fits your codebase: interpolated values become $1, $2 placeholders, so any code that wraps a parameter in quotes will send a literal string instead. Check the types/index.d.ts signatures against your TypeScript version, and confirm which export condition your bundler resolves, since package.json maps bun, workerd, import and default to different files.
Frequently asked questions
How do I install Postgres.js?
The README gives a single command, npm install postgres, run in your project. You then create a client with postgres() and export it from a module for use elsewhere.
Which runtimes does Postgres.js support?
The README describes it as a client for Node.js and Deno, and package.json adds export conditions for bun and workerd alongside the import and default conditions. The engines field lists node >=12.
Does Postgres.js prevent SQL injection?
The README states that parameters are extracted and handled by the database so that SQL injection is not possible, because interpolated values are replaced by protocol placeholders such as $1 and sent separately from the statement. The README warns that wrapping an interpolated parameter in quotes breaks this, since the server then sees a string literal.
What licence is Postgres.js released under?
The package licence field is Unlicense, and the repository contains a UNLICENSE file at the top level. That is a public-domain dedication rather than a permissive licence with attribution terms.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/porsager-postgres)
Community notes