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 as | Stored as | pg OID | Notes |
|---|---|---|---|
VARCHAR, TEXT, CHAR, STRING, VARCHAR(n), CHAR(n) | string | text (25) | (n) enforced, see Strings |
UUID | uuid | uuid (2950) | native; a non-UUID value is 22P02 |
SMALLINT, INT2, TINYINT, SMALLSERIAL | short | int2 (21) | range-checked on write, 22003 |
INTEGER, INT, INT4, BIGINT, INT8, INT64, SERIAL, BIGSERIAL | long | int8 (20) | always 64-bit, see Integers |
REAL, FLOAT4, FLOAT(1..24) | float | float4 (700) | 32-bit; overflow to infinity is 22003 |
DOUBLE, DOUBLE PRECISION, FLOAT8, FLOAT(25..53) | double | float8 (701) | |
NUMERIC(p,s), DECIMAL(p,s), NUMERIC | decimal(p,s) | numeric (1700) | exact, p ≤ 38, bare NUMERIC = (38,9) |
BOOLEAN, BOOL | boolean | bool (16) | |
DATE | date | date (1082) | |
TIMESTAMP, TIMESTAMPTZ, TIMESTAMP(p), TIMESTAMP WITHOUT TIME ZONE | timestamp | timestamptz (1184) | µs, UTC, see Temporal |
BYTEA, BLOB, BINARY, VARBINARY | binary | bytea (17) | |
JSON, JSONB | json | jsonb (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
| Form | Status | Behaviour |
|---|---|---|
VARCHAR(n) | Enforced22001 | An INSERT or UPDATE whose value exceeds n characters is refused. The value is never truncated and never stored whole. |
CHAR(n) | Partial | Length-checked like VARCHAR(n) (22001). Values are not blank-padded to n as Postgres pads them. |
UUID | Supported since 0.1.37 | Its 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 22001The width is visible to your tools: DESCRIBE tags reports string(3) and information_schema.columns.character_maximum_length is 3 (see Catalog).
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
| Form | Status | Behaviour |
|---|---|---|
BIGINT, INT8, INT64 | Supported | 64-bit signed. |
INTEGER, INT | Partial | Stored as 64-bit, not 32-bit. information_schema reports bigint. Nothing overflows at 2³¹. |
SMALLINT, INT2, TINYINT | Enforced22003 | 16-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 IDENTITY | Partial | Becomes 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
| Form | Status | Behaviour |
|---|---|---|
NUMERIC(p,s), DECIMAL(p,s) with p ≤ 38 | Enforced | Exact 128-bit decimal end to end: SQL literal, text bind, binary bind, storage, and result. Never a float detour. |
bare NUMERIC / DECIMAL | Accepted · NOTICE | Postgres 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) | Supported | Scale 0, as in Postgres. |
NUMERIC(p,s) with p > 38 | Refused | Refused at DDL rather than narrowed. |
a value with more digits than p allows | Enforced22003 | value for column … is out of range for DECIMAL(18,2). |
decimal / decimal, decimal / integer | Enforced since 0.1.43 | Exact quotient with at least 16 fractional digits; a CAST or a typed column then rounds half away from zero, as Postgres does. |
| division by zero | Enforced22012 since 0.1.43 | An 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.
| Form | Status | Behaviour |
|---|---|---|
TIMESTAMP, TIMESTAMPTZ | Supported | Microsecond precision, UTC. Input with an offset is converted; input without one is taken as UTC. |
TIMESTAMP(p) with p 0–6 | Enforced since 0.1.43 | Values are rounded half away from zero to p fractional digits on write. DESCRIBE shows timestamp(p); datetime_precision is p. |
TIMESTAMP WITHOUT TIME ZONE | Accepted · NOTICE | Accepted 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 ZONE | Accepted · NOTICE | Accepted for driver compatibility; has no effect. Timestamps are always rendered in UTC. SHOW TimeZone returns UTC. |
DATE | Supported | Calendar date, no time zone. |
TIME, INTERVAL columns | Refused | Not 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.
| Default | Effect | Column type |
|---|---|---|
CURRENT_TIMESTAMP, NOW() | write time, UTC | timestamp |
CURRENT_TIMESTAMP_MICROS() | write time as µs since epoch | BIGINT |
GENERATE_UUID(), UUID() | random UUID v4, 36 chars | string |
GENERATE_ULID(), ULID() | ULID, 26 chars, time-sortable | string |
GENERATE_SNOWFLAKE_ID(), SNOWFLAKE_ID() | 64-bit time-ordered id (what SERIAL uses) | BIGINT |
42, 'pending', true, NULL | the literal | must 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.
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.