@keyv/postgres

PostgreSQL storage adapter for Keyv

build codecov npm npm

PostgreSQL storage adapter for Keyv.

Requires Postgres 9.5 or newer for ON CONFLICT support to allow performant upserts. Why?

Table of Contents

Install

npm install --save keyv @keyv/postgres

Usage

import Keyv from 'keyv';
import KeyvPostgres from '@keyv/postgres';

const store = new KeyvPostgres('postgresql://user:pass@localhost:5432/dbname');
store.on('error', (error) => {
  console.error(error);
});
const keyv = new Keyv({ store });

You can specify the table and schema options:

const keyvPostgres = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', table: 'cache', schema: 'keyv' });
const keyv = new Keyv({ store: keyvPostgres });

You can also use the createKeyv helper function to create Keyv with KeyvPostgres store:

import { createKeyv } from '@keyv/postgres';

const keyv = createKeyv({ uri: 'postgresql://user:pass@localhost:5432/dbname', table: 'cache', schema: 'keyv' });

Migrating to v6

Breaking changes

v6 changes how namespaces are stored and which base class the adapter extends. Configuration is now exposed through dedicated properties (getters/setters) on the instance rather than a generic options bag. The sections below describe the changes and how to migrate existing data.

Native namespace support

In v5, namespaces were stored as key prefixes in the key column (e.g. key="myns:mykey" with namespace=NULL). In v6, the namespace is stored in a dedicated namespace column (e.g. key="mykey", namespace="myns"). This enables more efficient queries and proper namespace isolation.

The adapter automatically adds the namespace column and creates the appropriate index when it connects, so no manual schema changes are needed for new installations.

Hookified integration

The adapter now extends Hookified instead of a custom EventEmitter. Use on / once / emit for events. Connection and query failures emit error. Middleware hooks (onHook, hook) are available but are not invoked automatically on get/set — Keyv core owns those hooks. See Events and Hooks.

keySize renamed to keyLength, dialect removed

The v5 keySize option is now keyLength, and the dialect option is gone. TypeScript rejects both old names. In JavaScript, v6 does not read them and passes them on to the pg pool config, so an old keySize has no effect.

// v5
const store = new KeyvPostgres({ uri, keySize: 512 });

// v6
const store = new KeyvPostgres({ uri, keyLength: 512 });

keyLength only sets the key column size when the adapter creates the table. An existing table keeps its current key column. The migration script is different: it resizes the key column to --keyLength (default 255). If you used a custom keySize in v5, pass the same value with --keyLength.

New features

Native TTL support with expires column

v6 adds an expires BIGINT column to the table. Keyv core converts a relative TTL into an absolute Unix-ms timestamp and passes it to set/setMany; the adapter stores that timestamp in the column. It does not parse expiry out of the serialized value. A partial index is created on expires for efficient cleanup queries.

The schema migration is automatic on connect — existing tables get the column added via ADD COLUMN IF NOT EXISTS. Legacy rows still need the migration script so namespaced keys and existing JSON expires values are rewritten.

clearExpired() method

A new utility method that deletes all rows where the expires column is set and the timestamp is in the past:

await store.clearExpired();

clearExpiredInterval option

Set an interval (in milliseconds) to automatically call clearExpired() on a schedule. Disabled by default (0). The timer uses unref() so it won't keep the Node.js process alive.

const store = new KeyvPostgres({
  uri: 'postgresql://user:pass@localhost:5432/dbname',
  clearExpiredInterval: 60_000, // clean up every 60 seconds
});

Bulk operations

New methods for efficient multi-key operations:

  • .setMany(entries) — bulk upsert using PostgreSQL UNNEST
  • .getMany(keys) — bulk retrieve using ANY
  • .deleteMany(keys) — bulk delete using ANY
  • .hasMany(keys) — bulk existence check

createKeyv() helper

A convenience function to create a Keyv instance with KeyvPostgres as the store in one call:

import { createKeyv } from '@keyv/postgres';

const keyv = createKeyv({ uri: 'postgresql://user:pass@localhost:5432/dbname' });

Improved iterator

The iterator now uses cursor-based (keyset) pagination instead of OFFSET. This handles concurrent deletions during iteration without skipping entries and is more efficient for large datasets.

Running the migration script

If you have existing data from v5, you need to run the migration script to move namespace prefixes from keys into the new namespace column. The script ships in the npm package at scripts/migrate-v6.ts. Run it from your project root with tsx. Node.js refuses to strip TypeScript types from files under node_modules, so running the script with plain node from there fails with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.

Preview the changes first with --dry-run. Dry-run mode only reads schema metadata and previews affected rows; it does not modify the schema or data:

npx tsx node_modules/@keyv/postgres/scripts/migrate-v6.ts --uri postgresql://user:pass@localhost:5432/dbname --dry-run

Run the migration:

npx tsx node_modules/@keyv/postgres/scripts/migrate-v6.ts --uri postgresql://user:pass@localhost:5432/dbname

You can also specify a custom table, schema, and column lengths:

npx tsx node_modules/@keyv/postgres/scripts/migrate-v6.ts --uri postgresql://user:pass@localhost:5432/dbname --table cache --schema keyv
npx tsx node_modules/@keyv/postgres/scripts/migrate-v6.ts --uri postgresql://user:pass@localhost:5432/dbname --keyLength 512 --namespaceLength 512

From a clone of this repo you can run node scripts/migrate-v6.ts in storage/postgres with the same flags.

The data rewrite runs inside a transaction and will roll back automatically if it fails.

Important notes:

  • Dry-run does not add columns, drop the legacy primary key, or rewrite rows.
  • The script drops the legacy single-column primary key on key before rewriting rows. That is required so two namespaces can share the same unprefixed key (for example ns1:foo and ns2:foo both become key=foo with different namespace values).
  • After the data rewrite it creates the unique (key, COALESCE(namespace, '')) index and the partial expires index, matching what the adapter creates on connect.
  • The script only migrates rows where namespace IS NULL (or all colon-prefixed keys if the column does not exist yet). Rows that already have a namespace value (e.g. from a partial earlier migration) are skipped.
  • Keys are split on the first colon — the part before becomes the namespace, the rest becomes the key. Namespaces containing colons are not supported.
  • Keyv v5 used keyv as the namespace when none was set, so rows written that way migrate into namespace keyv. Pass namespace: 'keyv' to Keyv afterwards to keep reading them. See the v5 to v6 migration guide.
  • The expires column is populated from legacy JSON envelopes ({"value":...,"expires":...}). Non-JSON values (compressed, encrypted, custom serializers) are left with expires NULL; new writes from Keyv v6 store expiry in the column directly.

Constructor Options

KeyvPostgres accepts a connection URI string or an options object. The options object accepts the following properties along with any PoolConfig properties from the pg library (e.g. max, idleTimeoutMillis, connectionTimeoutMillis):

Option Type Default Description
uri string 'postgresql://localhost:5432' PostgreSQL connection URI
table string 'keyv' Table name for key-value storage
keyLength number 255 Maximum key column length (VARCHAR length)
namespaceLength number 255 Maximum namespace column length (VARCHAR length)
schema string 'public' PostgreSQL schema name (created automatically if it doesn't exist)
ssl boolean | ConnectionOptions undefined SSL/TLS configuration passed to the pg driver (true/false or a Node.js tls.ConnectionOptions object)
iterationLimit number 10 Number of rows fetched per batch during iteration
useUnloggedTable boolean false Use a PostgreSQL UNLOGGED table for better write performance
clearExpiredInterval number 0 Interval in milliseconds to automatically clear expired entries (0 = disabled)

Properties

All configuration options are exposed as properties with getters and setters on the KeyvPostgres instance. namespace, iterationLimit, and clearExpiredInterval take effect immediately. uri and ssl are applied when the connection pool is created (constructor); changing them later does not reconnect. table, schema, keyLength, namespaceLength, and useUnloggedTable are used to create/migrate the table at construction — changing table/schema afterward retargets subsequent queries but does not create the new table.

capabilities

Read-only capability descriptor from Keyv core. Declares the v6 absolute-expires storage contract (expires: true) so Keyv does not wrap the adapter in a relative-TTL bridge.

  • Type: KeyvStorageCapability (from keyv)
  • Default: computed from the instance (compatible, store: 'keyvStorage', method map, expires: true)
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname' });
console.log(store.capabilities.expires); // true

uri

Get or set the PostgreSQL connection URI. The URI is used when the connection pool is created, so changing it afterwards does not reconnect; disconnect() still releases the pool the adapter connected with.

  • Type: string
  • Default: 'postgresql://localhost:5432'
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname' });
console.log(store.uri); // 'postgresql://user:pass@localhost:5432/dbname'

table

Get or set the table name used for storage.

  • Type: string
  • Default: 'keyv'
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname' });
console.log(store.table); // 'keyv'
store.table = 'cache';

keyLength

Get or set the maximum key length (VARCHAR length) for the key column.

  • Type: number
  • Default: 255
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', keyLength: 512 });
console.log(store.keyLength); // 512

namespaceLength

Get or set the maximum namespace length (VARCHAR length) for the namespace column.

  • Type: number
  • Default: 255
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', namespaceLength: 512 });
console.log(store.namespaceLength); // 512

schema

Get or set the PostgreSQL schema name. Non-public schemas are created automatically if they don't exist.

  • Type: string
  • Default: 'public'
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', schema: 'keyv' });
console.log(store.schema); // 'keyv'

ssl

Get or set the SSL configuration for the PostgreSQL connection. Passed directly to the pg driver.

  • Type: boolean | ConnectionOptions | undefined
  • Default: undefined
const store = new KeyvPostgres({
  uri: 'postgresql://user:pass@localhost:5432/dbname',
  ssl: { rejectUnauthorized: false },
});
console.log(store.ssl); // { rejectUnauthorized: false }

iterationLimit

Get or set the number of rows to fetch per iteration batch.

  • Type: number
  • Default: 10
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', iterationLimit: 50 });
console.log(store.iterationLimit); // 50

useUnloggedTable

Get or set whether to use a PostgreSQL unlogged table for better write performance. Unlogged tables are faster but data is lost on crash.

  • Type: boolean
  • Default: false
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', useUnloggedTable: true });
console.log(store.useUnloggedTable); // true

clearExpiredInterval

Get or set the interval in milliseconds between automatic expired-entry cleanup runs. When set to a value greater than 0, the adapter will automatically call clearExpired() at the specified interval. The timer uses unref() so it won't keep the Node.js process alive. Setting to 0 disables the automatic cleanup. An interval longer than about 24.8 days (2,147,483,647 ms, the longest delay Node.js timers accept) runs cleanup at that limit.

  • Type: number
  • Default: 0 (disabled)
// Clean up expired entries every 60 seconds
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', clearExpiredInterval: 60_000 });
console.log(store.clearExpiredInterval); // 60000

// Disable it later
store.clearExpiredInterval = 0;

namespace

Get or set the namespace for the adapter. Used for key prefixing and scoping operations like clear().

  • Type: string | undefined
  • Default: undefined
const store = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname' });
store.namespace = 'my-namespace';
console.log(store.namespace); // 'my-namespace'

Methods

.get(key)

Get a value by key. Returns undefined if the key does not exist or has expired. A SQL NULL value is normalized to undefined (the adapter never returns null).

  • key (string) - The key to retrieve.
  • Returns: Promise<Value | undefined>
const value = await store.get('foo'); // 'bar'

.getMany(keys)

Get multiple values at once. Returns an array of values in the same order as the keys, with undefined for missing, expired, or SQL NULL entries (never null).

  • keys (string[]) - The keys to retrieve.
  • Returns: Promise<Array<Value | undefined>>
const values = await store.getMany(['foo', 'baz']); // ['bar', 'qux']

.set(key, value, expires?)

Set a key-value pair. Returns true on success, false on failure (an error event is also emitted).

  • key (string) - The key to set.
  • value (any) - The value to store.
  • expires (number, optional) - Absolute expiry as Unix milliseconds since epoch. undefined means no expiry. A value <= Date.now() is already expired.
  • Returns: Promise<boolean>

When you call keyv.set(key, value, ttl) on a Keyv instance, core converts the relative TTL into this absolute expires timestamp before it reaches the adapter. Do not pass a relative TTL to KeyvPostgres#set directly.

await store.set('foo', 'bar');
await store.set('foo', 'bar', Date.now() + 5000); // expires in 5 seconds

.setMany(entries)

Set multiple key-value pairs at once using a single atomic PostgreSQL INSERT ... UNNEST ... ON CONFLICT statement. Each entry is a KeyvStorageEntry<Value> object ({ key: string, value: Value, expires?: number }), where expires is an absolute Unix-ms timestamp and Value is inferred from the entries provided. Returns a boolean[] indicating whether each entry was set successfully. Since the SQL statement is atomic, all entries either succeed (true) or all fail (false) together. On failure, an error event is emitted. An empty input returns [].

  • entries (KeyvStorageEntry[]) - The entries to upsert.
  • Returns: Promise<boolean[]>
const results = await store.setMany([
  { key: 'foo', value: 'bar' },
  { key: 'baz', value: 'qux', expires: Date.now() + 5000 },
]); // [true, true]

.delete(key)

Delete a key. Returns true if the key existed, false otherwise.

  • key (string) - The key to delete.
  • Returns: Promise<boolean>
const deleted = await store.delete('foo'); // true

.deleteMany(keys)

Delete multiple keys at once. Returns a boolean[] indicating whether each key existed.

  • keys (string[]) - The keys to delete.
  • Returns: Promise<boolean[]>
const results = await store.deleteMany(['foo', 'baz']); // [true, true]

.has(key)

Check if a key exists. Expired entries are deleted on check and reported as missing.

  • key (string) - The key to check.
  • Returns: Promise<boolean>
const exists = await store.has('foo'); // true

.hasMany(keys)

Check if multiple keys exist. Returns an array of booleans in the same order as the input keys. Expired entries are deleted on check and reported as missing.

  • keys (string[]) - The keys to check.
  • Returns: Promise<boolean[]>
await store.set('foo', 'bar');
await store.set('baz', 'qux');

const results = await store.hasMany(['foo', 'baz', 'unknown']); // [true, true, false]

.clear()

Clear all keys in the current namespace. If no namespace is set, only keys without a namespace (SQL NULL) are removed.

  • Returns: Promise<void>
await store.clear();

.clearExpired()

Utility helper method to delete all expired entries from the store. This removes any rows where the expires column is set and the timestamp is in the past. This is useful for periodic cleanup of expired data.

  • Returns: Promise<void>
await store.clearExpired();

.iterator()

Iterate over all key-value pairs. The iterator uses the namespace configured on the instance. Uses cursor-based pagination controlled by the iterationLimit property. SQL NULL values are yielded as undefined (never null). Expired rows are skipped.

  • Returns: AsyncGenerator<[string, string | undefined]>
const iterator = store.iterator();
for await (const [key, value] of iterator) {
  console.log(key, value);
}

.disconnect()

Disconnect from the PostgreSQL database and release this instance's connection-pool reference. The shared pg.Pool is closed when the last adapter using it disconnects. Also stops the automatic expired-entry cleanup interval if running.

The reference released is the one taken when the adapter connected, even if uri or ssl changed since. It is released only once, so calling disconnect() again has no further effect.

  • Returns: Promise<void>
await store.disconnect();

Events and Hooks

KeyvPostgres extends Hookified with throwOnEmptyListeners: false, so emitting error with no listeners does not throw.

Events (on, once, emit):

  • error — connection failures (constructor init) and query failures (set / setMany). Listen on the adapter, not only on the wrapping Keyv instance.
const store = new KeyvPostgres('postgresql://user:pass@localhost:5432/dbname');
store.on('error', (error) => {
  console.error(error);
});

Hooks (onHook, hook): available for custom middleware. The adapter does not call hook() on get/set/delete — those hooks belong to Keyv core. Register a hook and invoke it yourself when you need adapter-level middleware:

store.onHook('audit', (action, key) => {
  console.log(action, key);
});
await store.hook('audit', 'set', 'foo');

Using an Unlogged Table for Performance

By default, the adapter creates a logged table. If you want to use an unlogged table for performance, you can pass the useUnloggedTable option to the constructor.

const keyvPostgres = new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', useUnloggedTable: true });
const keyv = new Keyv({ store: keyvPostgres });

From the PostgreSQL documentation:

If specified, the table is created as an unlogged table. Data written to unlogged tables is not written to the write-ahead log (see Chapter 28), which makes them considerably faster than ordinary tables. However, they are not crash-safe: an unlogged table is automatically truncated after a crash or unclean shutdown. The contents of an unlogged table are also not replicated to standby servers. Any indexes created on an unlogged table are automatically unlogged as well.

If this is specified, any sequences created together with the unlogged table (for identity or serial columns) are also created as unlogged.

Connection Pooling

The adapter automatically uses the default settings on the pg package for connection pooling. You can override these settings by passing the options to the constructor such as setting the max pool size.

const keyv = new Keyv({ store: new KeyvPostgres({ uri: 'postgresql://user:pass@localhost:5432/dbname', max: 20 }) });

Adapters that share the same URI and pool options reuse a single pg.Pool. disconnect() releases this instance's reference; the underlying pool is closed only when the last adapter using it disconnects.

SSL/TLS Connections

You can configure SSL/TLS connections by passing the ssl option. This is passed directly to the underlying pg driver.

const keyvPostgres = new KeyvPostgres({
  uri: 'postgresql://user:pass@localhost:5432/dbname',
  ssl: {
    rejectUnauthorized: false,
  },
});
const keyv = new Keyv({ store: keyvPostgres });

For more details on SSL configuration, see the node-postgres SSL documentation.

Amazon RDS for PostgreSQL

@keyv/postgres connects to Amazon RDS for PostgreSQL with the same uri and ssl options it uses for any PostgreSQL server. The adapter passes uri to pg as its connectionString and passes ssl to pg unchanged, so the TLS settings below are standard pg settings. createKeyv accepts the same options.

Connection URI

The endpoint and port are on the Connectivity & security tab of the DB instance in the RDS console. Put them in a regular PostgreSQL URI along with the database user, password, and database name:

const uri = 'postgresql://user:[email protected]:5432/dbname';

If the user name or password contains reserved URI characters such as @, :, /, or #, encode it first:

const password = encodeURIComponent(process.env.RDS_PASSWORD);
const uri = `postgresql://keyv:${password}@mydb.123456789012.us-east-1.rds.amazonaws.com:5432/dbname`;

RDS for PostgreSQL 15 and later reject connections that don't use SSL, because the rds.force_ssl parameter defaults to 1. On earlier versions it defaults to 0. Encrypt and verify the connection in production either way.

Verifying the server certificate with the RDS CA bundle

RDS signs its server certificates with Amazon RDS certificate authorities, and Node.js does not trust those by default. Download the CA bundle from Using SSL/TLS to encrypt a connection to a DB instance. global-bundle.pem covers every commercial AWS Region, and each Region also has its own bundle. Pass the bundle as ssl.ca:

import fs from 'node:fs';
import Keyv from 'keyv';
import KeyvPostgres from '@keyv/postgres';

const keyvPostgres = new KeyvPostgres({
  uri: 'postgresql://user:[email protected]:5432/dbname',
  ssl: {
    ca: fs.readFileSync('/path/to/global-bundle.pem').toString(),
  },
});
const keyv = new Keyv({ store: keyvPostgres });

rejectUnauthorized defaults to true, so Node.js checks that the certificate was signed by an RDS CA and that it was issued for the hostname in uri. Connect with the RDS endpoint hostname. An IP address or your own DNS alias fails the hostname check.

Don't use these settings for RDS in production:

  • ssl: true encrypts the connection but checks the certificate only against Node.js's built-in CAs. Verification fails because the RDS CAs aren't among them.
  • ssl: { rejectUnauthorized: false } connects, but accepts any certificate, so it does not protect against an attacker in the middle. The SSL/TLS Connections sample uses it, and it is only suitable for local or test servers.

Configuring TLS in the URI

pg also reads SSL settings from query parameters on the URI, so you can configure TLS there instead of with the ssl option:

const keyvPostgres = new KeyvPostgres(
  'postgresql://user:[email protected]:5432/dbname?sslmode=verify-full&sslrootcert=/path/to/global-bundle.pem',
);
  • Use sslmode=verify-full, which verifies both the CA and the hostname. pg 8 treats prefer, require, and verify-ca as verify-full and logs a security warning. As a result, sslmode=require without sslrootcert fails against RDS for the same reason ssl: true does.
  • sslrootcert is a file path, and pg reads the bundle from that file.
  • When the URI has SSL parameters, pg builds its SSL settings from the URI and ignores the ssl option. Configure TLS in one place, either on the URI or with ssl.

Testing

When testing you can use our docker compose postgresql instance by having docker installed and running. This will start a postgres server, run the tests, and stop the server:

At the root of the Keyv mono repo:

pnpm test:services:start

To just test the postgres adapter go to the postgres directory (storage/postgres) and run:

pnpm test

License

MIT © Jared Wray

Edit this page