Skip to main content

Data Types & Type Mapping

125 PostgreSQL types decode automatically, in both text and binary: every commonly used scalar and array type, plus the geometric, network, range, interval, bit-string, full-text-search and system-column types below. The scalar table just below covers the common ones; the rest get their own sections further down.

postgrejs decodes every PostgreSQL wire value into a native JS type through a DataType registry keyed by OID (object identifier), and lets you register your own for anything project-specific — an enum, a composite, an extension type.

Built-in scalar types​

Postgres typeJS typeNotes
boolboolean
byteaBufferRead back as a Buffer. A value you send may be any ArrayBuffer view — a Buffer, a Uint8Array (off fetch or subarray()), a DataView — and only the bytes that view covers are sent, so a subarray() of a larger buffer sends just its window. Before 3.14.1 only a Buffer worked: a Uint8Array was taken for JSON and the statement failed.
charstringsingle-byte "char" type
bpcharstringblank-padded character(n)
namestring
textstring
xmlstring
varcharstring
uuidstringcanonical xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx form
dateDate
timeDatethe clock time on 1970-01-01 in the client's local zone — read the wall-clock fields, or use temporalTypes for a Temporal.PlainTime
timetzstringclock time plus a UTC offset, e.g. 12:34:56.789+03 — a Date has no field for the offset, so this stays a string. See System Columns and Identifiers for the neighboring types
timestampDate
timestamptzDate
float4numberdecoded as the shortest decimal that reads back as the same value — 1.1 comes back 1.1, not 1.100000023841858
float8number
int2number
int4number
numericnumber | Numerica number when a double holds it exactly, a Numeric when it doesn't
moneynumber | Numericsame rule as numeric — see Money for the scale/format question this one adds
oidnumber
int8BigIntdowngraded to number automatically when the value fits in Number.MAX_SAFE_INTEGER
jsonstring | objectparsed with JSON.parse unless fetchAsString requests the raw text
jsonbstring | objectsame as json
int2vectornumber[]fixed-size vector type used internally by Postgres catalogs

The geometric types (point, circle, box, lseg, line, path, polygon), interval, and the range family each decode to a class of their own rather than a plain value — see Geometric Types, Interval, and Range and Multirange Types below. The network types, bit strings, full-text search types, and system-column types are covered in their own sections further down.

Array types​

Every scalar type above has an array counterpart registered under its own _-prefixed OID (e.g. int4 → _int4, timestamptz → _timestamptz). Arrays are:

  • Decoded from the binary wire format (dimension count, bounds, and element OID are read directly from the array header).
  • Able to represent PostgreSQL's multidimensional arrays — a _int4 column holding {{1,2},{3,4}} decodes to [[1, 2], [3, 4]].
  • Detected automatically for parameters: passing a JS array as a query parameter picks the array OID whose elementsOID matches the first element's detected type.
const qr = await connection.query(
'select array[1, 2, 3]::int4[] as nums',
);
console.log(qr.rows[0][0]); // [1, 2, 3]

An array sent as a binary parameter is written with PostgreSQL's own default lower bound (1), matching a text array literal — v[1] is the first element and array_lower(v, 1) is 1, whether the row was inserted through a parameter or a literal. int2vector/oidvector (0-based, matching their own catalog columns) are the only exception.

Temporal Values​

date, time, timestamp, timestamptz and interval normally decode to Date/Interval. temporalTypes decodes them into Temporal values instead — Temporal.PlainDate, PlainTime, PlainDateTime, ZonedDateTime and Duration, in that order:

const r = await connection.query(
"select now()::timestamptz t, '12:34:56.789123'::time tm",
{ temporalTypes: true },
);
r.rows[0][0]; // Temporal.ZonedDateTime, in the session's own time zone
r.rows[0][1]; // Temporal.PlainTime, with all six microsecond digits

true takes all five types; an array takes only the ones it names (DataTypeOIDs.date/.time/.timestamp/.timestamptz/.interval). Selecting one brings its array and range types along automatically — a date[] column and a daterange column both follow date once it's selected, so a row never answers a Temporal value for one column and a Date for its array neighbor. timeZone (also on DataMappingOptions) sets what zone a timestamptz reads as a ZonedDateTime in — a connection fills it in from the server's own TimeZone by default.

Three things a Date can't do are why this exists, each the reason one type is on the list: it can't hold the microseconds PostgreSQL actually stores (only milliseconds), it can't say a timestamp has no time zone at all rather than guessing one, and '0044-03-15'::date decodes correctly as a PlainDate where a Date puts it in 1944. This is decoding only — every type still accepts everything it accepted as a parameter before, Dates and strings included, so turning this on doesn't invalidate code that constructs values the old way.

A value with no Temporal equivalent is refused rather than silently rounded: an interval mixing a sign across its fields ('1 mon -3 days' — a Duration allows one sign for everything), time '24:00:00' (a PlainTime stops one nanosecond short of it), and a parameter carrying nanoseconds. timetz stays a string regardless — no Temporal type carries a clock time and a fixed offset with no date, which is what timetz is.

No JS runtime ships Temporal yet, so this reads globalThis.Temporal when the connection is built rather than importing it — install temporal-polyfill (or another polyfill) and import it before connecting:

import 'temporal-polyfill/global';
import { Connection } from 'postgrejs';

const connection = new Connection({ temporalTypes: true });

Numeric: Exact Decimals​

Breaking change in v3.7.0

numeric used to always decode to a number via parseFloat, silently rounding any value with more precision than a double can hold. It now decodes to a Numeric instance whenever that would happen — existing code that assumes every numeric column is a plain number (arithmetic, passing it to something expecting a number) needs to check for this, typically with typeof v === 'number' ? v : v.toNumber() where the same lossy behavior as before is acceptable, or by working with v.toString()/v.toNumber() explicitly. A column whose values never exceed number's precision is unaffected and keeps decoding to a plain number.

numeric decodes to a plain number whenever a double round-trips it faithfully (prints back the exact same digits), and to a Numeric instance otherwise, the same contract int8 has with number/BigInt:

const r = await connection.query('select 19.99::numeric as a, 12345678901234567.89::numeric as b');
r.rows[0][0]; // 19.99 - a plain number, since a double holds it exactly
r.rows[0][1]; // Numeric { value: '12345678901234567.89' }

The comparison is the round trip itself, not a rule about magnitude: it catches both ways a number loses a numeric: too many significant digits (12345678901234567.89 would decode as ...68), and a magnitude JavaScript prints in exponential notation, which PostgreSQL never writes (-0.00000000000000001 as -1e-17). A declared scale's trailing zeroes (numeric(40,6) holding 19.99 prints 19.990000) don't count as a difference.

Numeric has no valueOf() on purpose — arithmetic on one has to be an explicit choice:

const n = r.rows[0][1];
String(n); // '12345678901234567.89' - exact, via Symbol.toPrimitive
n.toNumber(); // 12345678901234568 - lossy, and says so by name
n + 1; // '12345678901234567.891' - string concatenation, not addition

It round-trips as a parameter too, unlike a bare string (which determine() would type as varchar, and PostgreSQL refuses to store a varchar in a numeric column):

import { Numeric } from 'postgrejs';

await connection.query('insert into prices (amount) values ($1)', {
params: [new Numeric('12345678901234567.89')],
});

NaN/Infinity/-Infinity always decode as the JS values of the same name, never as Numeric.

See API: Numeric for the full class reference.

Geometric Types​

point, circle, box, lseg, line, path and polygon each decode to their own class (Point, Circle, Box, LineSegment, Line, Path, Polygon) rather than a plain object. Every one has a toString() that prints exactly what PostgreSQL prints, so it casts back through its own type, and a toJSON() that gives its fields, the way any other structured value does:

const r = await connection.query('select point(1,2) as p, box(point(0,0),point(1,1)) as b');
r.rows[0][0]; // Point { x: 1, y: 2 }
r.rows[0][1]; // Box { x1: 1, y1: 1, x2: 0, y2: 0 } - PostgreSQL normalizes the corners
JSON.stringify(r.rows[0][0]); // '{"x":1,"y":2}'
String(r.rows[0][0]); // '(1,2)' - the literal, from toString()/toPostgres() instead

Circle's radius is the radius field, pg's own spelling. r, this client's name for it before the class existed, still works as an accessor onto the same number, and a plain {x, y, r} object is still accepted as a parameter alongside {x, y, radius}.

Box and LineSegment share the same four numbers (x1,y1,x2,y2) and exist as separate classes only because a plain object of them can't say which type is meant — same for Path/Polygon over a list of points. A parameter can still be a plain object matching the shape ({x,y} for Point, {x,y,radius}/{x,y,r} for Circle, {a,b,c} for Line) for code written before these classes existed, but Box/LineSegment/Path/Polygon need the actual class, since their shapes are ambiguous or collide with an array parameter (point[]).

import { Point, Path } from 'postgrejs';

await connection.query('select $1::point as p', { params: [new Point(5, 6)] });
await connection.query('select $1::path as p', {
params: [new Path([{ x: 1, y: 1 }, { x: 2, y: 2 }], false)], // open path
});

See API: Geometric Types for every class's full field list.

Interval​

interval decodes to an Interval instance holding the three quantities PostgreSQL actually keeps (a month count, a day count, and a time) rather than a total duration, since a month (28–31 days) and a day (23–25 hours across DST) aren't fixed lengths: only the server, holding a calendar and time zone, can resolve them against a timestamp.

const r = await connection.query("select interval '1 year 2 mons 3 days 04:05:06.789' as i");
const iv = r.rows[0][0];
iv.years; // 1
String(iv); // '1 year 2 mons 3 days 04:05:06.789'
iv.toISOString(); // 'P1Y2M3DT4H5M6.789S'

Only the fields that actually have a value are present — the wire format stores three quantities, and a zero among them isn't a field, so new Interval({ days: 1, hours: 2 }) has just days and hours and iv.minutes is undefined, not 0. JSON.stringify(iv) reflects the same fields. Reading one back needs to say what an absent field means (iv.hours ?? 0); everything this class derives (totalMonths, totalMicroseconds, toString()/toPostgres(), toISOString(), and the parameter path) already reads an absent field as the zero it stands for.

import { Interval } from 'postgrejs';

await connection.query('update events set duration = $1 where id = 1', {
params: [new Interval({ days: 1, hours: 2 })],
});

See API: Interval for the full field list and methods.

Range and Multirange Types​

int4range, int8range, numrange, daterange, tsrange and tstzrange and their multirange counterparts decode to a Range (a multirange to a plain Range[]): a pair of bounds, each possibly absent (unbounded) and each possibly inclusive, holding the element type's own JS values.

const r = await connection.query('select int4range(1,10) as r, tstzrange($1,$2) as tr', {
params: ['2020-01-01', '2020-02-01'],
});
r.rows[0][0]; // Range { lower: 1, upper: 10, lowerInclusive: true, upperInclusive: false, isEmpty: false }
r.rows[0][0].toString(); // '[1,10)'
r.rows[0][1].lower instanceof Date; // true - a tstzrange holds Dates

A Range decoded from a query already knows which of the six range types it came from and can be passed straight back as a parameter without naming it again:

await connection.query('select $1::int4range', { params: [r.rows[0][0]] }); // just works

A Range you build yourself has to be told which type it is, since nothing about a bare Range says whether its numbers are meant as int4range or numrange. Name it either through the constructor's fourth argument or BindParam; otherwise determine() throws rather than silently sending it as JSON:

import { Range, DataTypeOIDs, BindParam } from 'postgrejs';

new Range(1, 10, '[)', DataTypeOIDs.int4range);
// or:
await connection.query('select $1', {
params: [new BindParam(DataTypeOIDs.int4range, new Range(1, 10))],
});

PostgreSQL normalizes a discrete range's inclusive upper bound on the way in — int4range(1,10,'[]') is stored and returned as [1,11), since those describe the same integers — so upperInclusive reads false for every int4range/int8range/daterange. That's the server normalizing, not information this class lost.

See API: Range for the full reference.

Network and Bit-String Types​

inet, cidr, macaddr and macaddr8 decode to plain strings in the same form PostgreSQL prints (192.168.1.0/24, 2001:db8::1, 08:00:2b:01:02:03), since an address isn't a shape JavaScript has anything better for, and every Node API that takes one (net, dns, http) wants a string too. bit/varbit also decode to strings of 0 and 1, for the same reason: a number loses leading zeroes and anything past 53 bits, and a Buffer loses the exact bit count.

const r = await connection.query(
"select '192.168.1.0/24'::cidr as c, B'1011'::bit(4) as b",
);
r.rows[0][0]; // '192.168.1.0/24'
r.rows[0][1]; // '1011'

None of these five join parameter-type inference — a bare string is far more often plain text than an address or a bit string — so reach them explicitly with BindParam:

import { BindParam, DataTypeOIDs } from 'postgrejs';

await connection.query('insert into hosts (addr) values ($1)', {
params: [new BindParam(DataTypeOIDs.inet, '10.0.0.1')],
});

Full-Text Search and jsonpath​

tsvector, tsquery and jsonpath decode to the strings PostgreSQL itself prints: 'cat':1,3 'dog':2, 'a' & !'b' and $."a"[*]."b" (jsonpath's own normalized spelling, not necessarily what was written). The tree behind each has already been built by the server, and there is nothing more useful for an application to do with it than read or compare the text.

tsvector/tsquery are also the two exceptions to "postgrejs can send this back as a binary parameter": there is no encodeBinary for either, because the server's own input parser is what defines them — a tsvector literal is sorted and deduplicated on the way in, and a tsquery literal is a small expression language with operators added across releases. Reimplementing either here would risk 'a' & 'b' meaning one thing as a literal and another as a parameter depending on which format it happened to travel in, so both are always sent as text instead, which is exact:

const r = await connection.query(
"select to_tsvector('english', 'a fat cat sat on a mat') as v",
);
r.rows[0][0]; // "'cat':3 'fat':2 'mat':7 'sat':4"

await connection.query("select $1::tsquery as q", { params: ['cat & rat'] }); // sent as text

System Columns and Identifiers​

tid, xid, xid8, cid and pg_lsn back the columns and values applications reach for row identity and replication position — ctid, xmin/xmax/cmin/cmax, and pg_current_wal_lsn(). tid decodes to the (block,offset) string the server prints (a physical address VACUUM may move, so there's nothing to compute with it), xid/cid to numbers, xid8 to a number-or-BigInt the same way int8 is, and pg_lsn to the unpadded 16/B374D848 hex-pair string:

const r = await connection.query('select ctid, xmin, xmax from my_table limit 1');
r.rows[0]; // ['(0,1)', 1054813, 0]

(await connection.query('select pg_current_wal_lsn() as lsn')).rows[0][0]; // '3/6AC93898'

pg_snapshot/txid_snapshot (an older name for the same type) decode to the xmin:xmax:xip,xip string pg_current_snapshot() prints, for the same reason: the ids are 64-bit counters only the server's own operators usefully compare. refcursor (what a PL/pgSQL function returning a cursor hands back — its name, nothing more) and pg_node_tree (catalog columns like pg_attrdef.adbin) are plain strings on the wire and decode as such.

None of these seven join parameter-type inference, for the same reason the network types don't — name them with BindParam when sending one as a parameter.

Money​

money decodes the same way numeric does (a number when a double holds the value exactly, a Numeric otherwise), since PostgreSQL's money is an int64 count of the smallest currency unit, and how many of those make one unit (lc_monetary) is a server setting the wire format doesn't carry:

const r = await connection.query("select '1234.56'::money as m");
r.rows[0][0]; // 1234.56

A connection asks the server what the scale is the first time it sees a money column, rather than at connect (a caller who never touches money never asks), and caches the answer — moneyFormat on DataMappingOptions holds it ({ scale, decimalSeparator }); set it directly to skip that round trip, or to read a value rendered by a different server. Two other ways to read a money value are covered below: fetchAsString: [DataTypeOIDs.money] for the server's own locale-formatted text ('$1,234.56', symbol and grouping included), and decimalAsString for the exact decimal with neither.

Exact Decimal Strings: decimalAsString​

decimalAsString decodes numeric and money into the exact decimal string they carry, with no currency symbol, thousands separator or conversion, instead of a number/Numeric:

const r = await connection.query("select '1234.5000'::numeric as n, '12.34'::money as m", {
decimalAsString: true,
});
r.rows[0]; // ['1234.5000', '12.34'] - exact, scale preserved

true takes both types; an array names only the ones it wants (DataTypeOIDs.numeric/.money). Nothing is converted to produce it — both types already build this exact string while decoding and only then decide whether a number can carry it, so this is cheaper than decoding to a number and reversing it, and it can't lose precision the way re-parsing an already-lossy number would. numeric's non-finite values keep PostgreSQL's own spelling ('NaN', 'Infinity', '-Infinity') rather than becoming JS numbers, since the whole point of the option is that the value stays a string. This is different from fetchAsString: [DataTypeOIDs.numeric], which asks the server for text and gets back whatever numeric's own text representation is — the same string here, but decimalAsString reads it from the binary the statement was already fetching, with no extra ask.

Enum, Extension, and Other Unregistered Types​

A column whose type isn't registered (an enum, a composite, or an extension type) arrives as a raw Buffer by default. unknownTypesAsString asks the server for the column's own text instead, which is what pg hands back for the same column:

await connection.query('select status::order_status as s'); // Buffer, uninterpretable
await connection.query('select status::order_status as s', { unknownTypesAsString: true }); // 'shipped'

It's off by default, and not free: the column types have to be known before the Bind that asks for them, so a statement that hasn't been prepared yet is prepared on first sight — one extra round trip for that first call, on that connection, for that SQL text. A registered type is unaffected either way — nothing that already decodes starts arriving as a string.

DataTypeMap and GlobalTypeMap​

DataTypeMap is the registry class that maps an OID to a DataType descriptor. GlobalTypeMap is the default, process-wide instance every connection uses unless a call overrides it.

import { GlobalTypeMap, DataTypeOIDs } from 'postgrejs';

const boolType = GlobalTypeMap.get(DataTypeOIDs.bool);
MethodDescription
get(oid)Returns the DataType registered for an OID, or undefined.
register(dataTypes)Registers one or more DataType descriptors, keyed by their own oid. Registering an OID that already exists replaces it.
determine(value)Infers the best-matching OID for a plain JS value (used for parameters that are not wrapped in BindParam). Arrays are matched against each type's elementsOID.

See API: DataTypeMap for the full class reference.

Registering a custom type​

To support a Postgres type postgrejs doesn't ship a mapping for (a custom domain, extension type, or one of the catalog types not registered by default), implement the DataType interface and register it — either into GlobalTypeMap for every connection, or into a private DataTypeMap used only for specific calls:

import { DataTypeMap, GlobalTypeMap, type DataType } from 'postgrejs';

const MyEnumType: DataType = {
name: 'my_enum',
oid: 123456, // the type's OID in your database
jsType: 'string',
isType: (v: any): boolean => typeof v === 'string',
decodeText: (v: string) => v,
encodeText: (v: any) => String(v),
};

// Global — every connection sees it
GlobalTypeMap.register(MyEnumType);

// Or scoped to just the calls that pass this map
const myTypeMap = new DataTypeMap(GlobalTypeMap);
myTypeMap.register(MyEnumType);

A DataType descriptor at minimum needs name, oid, jsType, isType, and a decodeText/decodeBinary pair matching the wire format(s) you support; add encodeText/encodeBinary to also send values of that type as query parameters. Set elementsOID to register the type as the array element of another OID.

Set inferrable: false when a type's isType can genuinely match a plain JS value but no caller actually means that type when they pass one — PostgreSQL's own single-byte "char" is the built-in example: any one-character string satisfies its isType, but a bare 'A' parameter is never meant as "char", so determine() passes it over during inference while "char" columns still decode normally. Reach the type explicitly instead with new BindParam(oid, value) when that type is what you want.

Per-query type mapping​

Both QueryOptions and StatementPrepareOptions accept a typeMap field that overrides GlobalTypeMap for a single query()/execute() call or a single prepared statement, without touching the global registry:

import { DataTypeMap, GlobalTypeMap } from 'postgrejs';

const customMap = new DataTypeMap(GlobalTypeMap);
customMap.register(MyEnumType);

const qr = await connection.query('select * from widgets', {
typeMap: customMap,
});

typeMap, along with every other data-mapping option covered on this page — objectRows, rowDecoder, columnFormat, utcDates, fetchAsString, unknownTypesAsString, decimalAsString, temporalTypes, moneyFormat — can also be set once on the connection, instead of repeated on every call, and a value given on a specific call still wins over it:

const connection = new Connection({
typeMap: customMap,
objectRows: true,
});

await connection.query('select * from widgets'); // uses the connection's typeMap and objectRows
await connection.query('select * from widgets', { objectRows: false }); // this call's own wins

Text vs Binary Wire Format​

PostgreSQL's wire protocol can transfer column values as text or binary. postgrejs defaults every column to binary (DataFormat.binary, per DEFAULT_COLUMN_FORMAT in the library's constants) since it avoids the cost of formatting/parsing decimal text for every row.

Override the format with columnFormat — a single DataFormat for every column, or an array assigning a format per column position:

import { DataFormat } from 'postgrejs';

// All columns as text
await connection.query('select id, name from customers', {
columnFormat: DataFormat.text,
});

// Per-column: first column binary, second column text
await connection.query('select id, name from customers', {
columnFormat: [DataFormat.binary, DataFormat.text],
});

Fetching as String​

Some types lose information when decoded to their native JS type even where numeric doesn't — date/time/timestamp/timestamptz collapse Postgres's infinity/-infinity into JS Infinity, for instance. fetchAsString (on DataMappingOptions, inherited by QueryOptions) asks the server for specific columns in its own text format instead of decoding them at all, and hands the bytes back unparsed:

import { DataTypeOIDs } from 'postgrejs';

const qr = await connection.query(
'select price from products where id = $1',
{
params: [1],
fetchAsString: [DataTypeOIDs.numeric],
},
);
console.log(qr.rows[0][0]); // "19.995000" — exact string, not a lossy float

This is a wire-level request, not a reformatting of the value postgrejs would otherwise have decoded — so the string is exactly what PostgreSQL itself would print, and it takes any OID, not just a handful of numeric/date types:

const r = await connection.query('select count(*) as n from products', {
fetchAsString: [DataTypeOIDs.int8],
});
r.rows[0][0]; // '3' — a string, not a decoded int8 (number or BigInt)

Naming an array-typed column reaches it two different ways, depending on which OID is named. The scalar's own OID (timestamptz) asks for the elements, still in an array — the server's own text for each one, nulls left as null rather than the string 'NULL', the array's own separator honored, a multidimensional column keeping its shape:

await connection.query('select tags from products', {
fetchAsString: [DataTypeOIDs.timestamptz],
});
r.rows[0][0]; // ['2020-10-22 23:45:12.123+00', null, '2021-01-01 00:00:00+00']

The array's own OID (_timestamptz) asks for the whole column as one string — the array literal exactly as PostgreSQL would print it:

await connection.query('select tags from products', {
fetchAsString: [DataTypeOIDs._timestamptz],
});
r.rows[0][0]; // '{"2020-10-22 23:45:12.123+00",NULL,"2021-01-01 00:00:00+00"}'
timestamptz as a string follows the session's TimeZone

Because the string is the server's own rendering, a fetchAsString'd timestamptz looks like PostgreSQL's own output (2020-10-22 23:45:12.123+00) rather than an ISO 8601 string — it shifts with the session's TimeZone setting, same as select ... ::text would in psql. date, time, timestamp, json, and jsonb render the same either way.

Scoping to just the scalar: { oid, arrays: false }​

A bare OID asks for a type and every column of arrays of it together — naming numeric reaches numeric columns and numeric[] columns alike. An entry can be { oid, arrays: false } instead, to narrow that to the scalar columns only:

await connection.query('select price, prices from products', {
fetchAsString: [{ oid: DataTypeOIDs.numeric, arrays: false }],
});
r.rows[0]; // ['19.995000', [1.5, 2.5]] - the scalar as a string, the array still decoded numbers

This is the shape pg gives numeric: a scalar column as a string and a numeric[] column as numbers. Naming the bare OID would turn both into strings; naming arrays: false reproduces the pair.

See also​