Skip to Content
We are live but in Staging 🎉

Data types

The type matrix

The pg OID is what a Postgres driver sees in the row description, and therefore how it decodes the value.

Declare asStored aspg OIDNotes
VARCHAR, TEXT, CHAR, STRING, VARCHAR(n), CHAR(n)stringtext (25)(n) enforced, see Strings
UUIDuuiduuid (2950)native; a non-UUID value is 22P02
SMALLINT, INT2, TINYINT, SMALLSERIALshortint2 (21)range-checked on write, 22003
INTEGER, INT, INT4, BIGINT, INT8, INT64, SERIAL, BIGSERIALlongint8 (20)always 64-bit, see Integers
REAL, FLOAT4, FLOAT(1..24)floatfloat4 (700)32-bit; overflow to infinity is 22003
DOUBLE, DOUBLE PRECISION, FLOAT8, FLOAT(25..53)doublefloat8 (701)
NUMERIC(p,s), DECIMAL(p,s), NUMERICdecimal(p,s)numeric (1700)exact, p ≤ 38, bare NUMERIC = (38,9)
BOOLEAN, BOOLbooleanbool (16)
DATEdatedate (1082)
TIMESTAMP, TIMESTAMPTZ, TIMESTAMP(p), TIMESTAMP WITHOUT TIME ZONEtimestamptimestamptz (1184)µs, UTC, see Temporal
BYTEA, BLOB, BINARY, VARBINARYbinarybytea (17)
JSON, JSONBjsonjsonb (3802)drivers decode to a native object
T[], ARRAY<T>array<T>text (25)rendered as a {…} literal
STRUCT(f t, …)struct<…>jsonb (3802)rendered as an object
VECTOR(n)vector<n>text (25)rendered as […]; n is required

The sixteenth storage type, a 32-bit int (wire int4, 23, range-checked 22003), is not produced by any Postgres DDL spelling; it comes from the typed gRPC CreateTable request (int/int32) or from CREATE TABLE … AS SELECT inference.

A type outside this table (TIME, INTERVAL, MONEY, XML, enums, ranges) is Refused at CREATE TABLE, with the column named in the error. INTERVAL still works as an expression type (now() - INTERVAL '1 day').

Strings and widths

VARCHAR, TEXT and UUID are unbounded UTF-8. A declared width is a real constraint: since 0.1.43

FormStatusBehaviour
VARCHAR(n)Enforced22001An INSERT or UPDATE whose value exceeds n characters is refused. The value is never truncated and never stored whole.
CHAR(n)PartialLength-checked like VARCHAR(n) (22001). Values are not blank-padded to n as Postgres pads them.
UUIDSupported since 0.1.37Its own type on the wire (uuid, 2950; 16 raw bytes in binary format) and in the catalog. Works as a primary key and in indexes. A value that is not a UUID is 22P02 (invalid input syntax for column "u" (UUID): not-a-uuid). Generate with gen_random_uuid().
CREATE TABLE tags (id BIGINT PRIMARY KEY, code VARCHAR(3)); INSERT INTO tags VALUES (1, 'abcd'); -- ERROR: value too long for type character varying(3) — column "code" of "tags" got 4 characters -- SQLSTATE 22001

The width is visible to your tools: DESCRIBE tags reports string(3) and information_schema.columns.character_maximum_length is 3 (see Catalog).

Still openThe width check and TIMESTAMP(p) rounding run at the SQL write path. Rows written through COPY … FROM STDIN or the gRPC Upsert/WriteStream calls are typed and range-checked like any INSERT, but are not yet checked against (n) or rounded to (p). Validate width in the loader for those paths until the check lands. Tracked on Limits & differences.

Integers

FormStatusBehaviour
BIGINT, INT8, INT64Supported64-bit signed.
INTEGER, INTPartialStored as 64-bit, not 32-bit. information_schema reports bigint. Nothing overflows at 2³¹.
SMALLINT, INT2, TINYINTEnforced2200316-bit, advertised as int2 since 0.1.37; value 40000 is out of range for column "s" (SMALLINT), never wrapped.
SERIAL, SERIAL4, BIGSERIAL, GENERATED … AS IDENTITYPartialBecomes a BIGINT with a GENERATE_SNOWFLAKE_ID() default; SMALLSERIAL becomes SMALLINT. There is no sequence object, so nextval/currval do not exist; read the assigned id back with RETURNING id. An explicit DEFAULT on the column wins and keeps the declared width.

INTEGER is widened on purpose: generated keys are 64-bit snowflake ids, so a foreign-key column declared integer to match a parent’s declared type would otherwise truncate silently.

Over the HTTP JSON door, a long outside ±(2⁵³ − 1) should be sent as a JSON string to survive the client’s number parser. Postgres drivers bind int8 natively and are unaffected.

Exact numerics

FormStatusBehaviour
NUMERIC(p,s), DECIMAL(p,s) with p ≤ 38EnforcedExact 128-bit decimal end to end: SQL literal, text bind, binary bind, storage, and result. Never a float detour.
bare NUMERIC / DECIMALAccepted · NOTICEPostgres is unbounded; here it means NUMERIC(38,9), and CREATE TABLE raises NOTICE: column … bare NUMERIC stored as NUMERIC(38,9). Because the engine chose that scale, a value with more than 9 fractional digits is refused (22003) rather than rounded. Declare the scale yourself: NUMERIC(18,2) for money, NUMERIC(38,18) for token amounts, NUMERIC(38,0) for integer minor units.
NUMERIC(p)SupportedScale 0, as in Postgres.
NUMERIC(p,s) with p > 38RefusedRefused at DDL rather than narrowed.
a value with more digits than p allowsEnforced22003value for column … is out of range for DECIMAL(18,2).
decimal / decimal, decimal / integerEnforced since 0.1.43Exact quotient with at least 16 fractional digits; a CAST or a typed column then rounds half away from zero, as Postgres does.
division by zeroEnforced22012 since 0.1.43An error, never a NULL.
SELECT CAST(2.01 / 2 AS DECIMAL(10,2)); -- 1.01, as in Postgres (not 1.00) SELECT CAST(10.005 AS DECIMAL(10,2)); -- 10.01 SELECT 1.0 / 0; -- ERROR: division by zero (SQLSTATE 22012)

A value with more fractional digits than the declared scale is rounded half away from zero on write (1.005 into NUMERIC(10,2) stores 1.01), exactly as Postgres does. The digits are carried exactly on every entry path: SQL literal, text bind, binary bind and COPY, and read back exactly in text or binary. Exponent literals (1e-6) are exact numerics. since 0.1.37

Floats

REAL/FLOAT4 is stored in 32 bits and advertised as float4, so a round-tripped REAL may differ in the last digits from the 64-bit value you sent. DOUBLE PRECISION round-trips exactly. FLOAT(p) picks the width from p as Postgres does. Do not use floats for money; use NUMERIC.

Temporal

There is one timestamp type. It holds microseconds since the epoch in UTC and is described to drivers as timestamptz.

FormStatusBehaviour
TIMESTAMP, TIMESTAMPTZSupportedMicrosecond precision, UTC. Input with an offset is converted; input without one is taken as UTC.
TIMESTAMP(p) with p 0–6Enforced since 0.1.43Values are rounded half away from zero to p fractional digits on write. DESCRIBE shows timestamp(p); datetime_precision is p.
TIMESTAMP WITHOUT TIME ZONEAccepted · NOTICEAccepted and stored as the one UTC type. The CREATE TABLE raises a NOTICE: TIMESTAMP WITHOUT TIME ZONE is stored with timestamptz semantics. A naive local time you insert is treated as UTC.
SET TimeZone = '…', SET TIME ZONEAccepted · NOTICEAccepted for driver compatibility; has no effect. Timestamps are always rendered in UTC. SHOW TimeZone returns UTC.
DATESupportedCalendar date, no time zone.
TIME, INTERVAL columnsRefusedNot storage types. INTERVAL literals work in expressions (now() - INTERVAL '1 day').
CREATE TABLE t (id BIGINT PRIMARY KEY, at3 TIMESTAMP(3)); INSERT INTO t VALUES (1, TIMESTAMP '2026-09-18 10:11:12.987654'); SELECT at3 FROM t; -- 2026-09-18 10:11:12.988+00 SELECT at3 AT TIME ZONE 'Europe/London' FROM t; -- works; lowers to timezone()

date_trunc('day', at3) and EXTRACT(hour FROM at3) evaluate in UTC regardless of the client’s zone; use AT TIME ZONE in the query, or convert in the application, for display (see Functions).

Boolean

BOOLEAN/BOOL, wire bool. Text input accepts true/false, t/f, 1/0. Postgres’s extra spellings (yes/no, on/off) are refused, and so is any other string (22P02 on a write, Could not convert string … to BOOL in a SELECT); nothing becomes NULL. The same rule holds for every type: a datetime string into DATE, a bare date into TIMESTAMP and 'true' into BOOLEAN coerce as in Postgres; an impossible date (2026-13-45) or an unreadable number is 22P02.

Binary

BYTEA, BLOB, BINARY, VARBINARY store raw bytes and are returned as bytea. The text form is Postgres hex, '\xdeadbeef', in and out; Postgres drivers bind binary natively. Over HTTP JSON the value travels base64-encoded.

JSON

JSON and JSONB are one type, advertised as jsonb (3802) in metadata and on the wire, so psycopg returns a dict, SQLx a serde_json::Value, node-postgres an object. The document is stored and served verbatim: key order, duplicate keys and integers beyond 2⁵³ are preserved. The Postgres operators ->, ->> and the DuckDB json_* functions work; see Functions → JSON. For fields you filter or aggregate on constantly, promote them to typed columns.

Arrays and structs

T[] / ARRAY<T> and STRUCT(f t, …) are native nested columns. On the pg wire an array is advertised as text and rendered as a {…} literal, a struct as jsonb. Text and binary result formats are therefore both valid, and binary-binding drivers never fail on them; native pg array OIDs are not yet used for results. Binding an array parameter (WHERE id = ANY($1)) works with every tested driver.

Vectors

VECTOR(n) holds n 32-bit floats; n is required (Refused for a bare VECTOR). On the wire it is text in pgvector’s […] spelling, so pgvector client helpers parse it. Distance operators and index kinds are on Functions → Vector operators and Schema changes → Indexes.

Default expressions

A column default is validated when the table is created, not on first insert, and fires only when the column is absent from the row. An explicit NULL is stored as NULL.

DefaultEffectColumn type
CURRENT_TIMESTAMP, NOW()write time, UTCtimestamp
CURRENT_TIMESTAMP_MICROS()write time as µs since epochBIGINT
GENERATE_UUID(), UUID()random UUID v4, 36 charsstring
GENERATE_ULID(), ULID()ULID, 26 chars, time-sortablestring
GENERATE_SNOWFLAKE_ID(), SNOWFLAKE_ID()64-bit time-ordered id (what SERIAL uses)BIGINT
42, 'pending', true, NULLthe literalmust match

Any other default expression, and any function or literal whose type does not match the column, is refused at CREATE TABLE (42703, DEFAULT literal … does not match column type). The trailing () is optional on the function forms. In ordinary query SQL, use gen_random_uuid() or uuid(); GENERATE_UUID() is only valid as a default.

Still openA literal DEFAULT on a NUMERIC column is refused at DDL: DEFAULT 0, 0.0 and 0.00 all fail with DEFAULT literal 0 does not match column type decimal(18,2). Literal defaults work for BIGINT, VARCHAR and BOOLEAN. For money columns, declare NOT NULL without a default and insert the value explicitly.