KDB knowledge

PostgreSQL protocol (port 8832)

Connect to KDB with PostgreSQL tools and drivers - psql, DBeaver, Python, Node, C#, Go - and what works and what does not.

KDB is not PostgreSQL, but kdb serve speaks PostgreSQL's wire protocol: the messages a Postgres driver and the database send each other. So a normal Postgres driver or tool can connect to KDB, send SQL and read the rows.

Tutorial: chapter 14, PostgreSQL protocol. The Postgres port cannot be run by this help's example runner, so every example here is plain text; which ones were run on a real server is said under each.

Start the Postgres port

Syntax:

kdb serve [--pg-port 8832] [--pg-timestamptz] [--bind 127.0.0.1] [--token <secret>]
OptionMeaningDefault
--pg-port NThe Postgres port. --pg-port 0 turns it off.8832
--pg-timestamptzSend KDB TIMESTAMP columns as Postgres timestamptz (a moment in time with a zone) instead of timestamp. See times.off
--pg-int-division on|offon: whole number / whole number gives a whole number on this port, as in PostgreSQL (7 / 2 is 3). Also KDB_PG_INT_DIVISION. See integer division.off
--pg-compat on|offon: PostgreSQL 14's answers for every database without its own pg_compat setting. Also KDB_PG_COMPAT. Waiting for the owner's decision; keep it off. See PostgreSQL-compatible results.off
--bind ADDRESSThe network address of both ports (HTTP and Postgres)127.0.0.1
--token SECRETThe password of every Postgres user when there are no KDB usersnone

When the port is on, kdb serve prints a postgres line. Real output from chapter 14:

KDB 0.2.0-dev
  console   http://127.0.0.1:9900
  postgres  127.0.0.1:8832 (PostgreSQL protocol, no TLS; password = the token)
  ...

Connection settings

SettingValue
Host127.0.0.1 (or the --bind address)
Port8832
Usera KDB user name; without KDB users, any name (for example app)
Passwordthe KDB user's password, or the --token (passwords)
Databasea KDB database name, like db= of the HTTP API. Empty, postgres, template1 or the user name (when no database has that name) means the default database.
SSL modewithout TLS: disable or prefer (require fails). With TLS on: require, or verify-full with the certificate.

A connection string (URL form and key=value form):

postgres://app:my-long-secret@127.0.0.1:8832/market?sslmode=disable
host=127.0.0.1 port=8832 dbname=market user=app password=my-long-secret sslmode=disable

Passwords and logins

ServerHow to log in
no KDB users, no --tokenno password. Only programs on the same computer (127.x.x.x) may connect; others get 28000 connections from other computers need a password.
--token <secret>, no usersany user name; the password is the token. Full rights.
KDB users (CREATE USER, kdb user add)a KDB user name and its password. Each statement runs with that user's role.
users and --tokenKDB users with their passwords; a name that is not a KDB user may log in with the token (full rights)

psql

psql is PostgreSQL's command line tool. Not run while writing this help (psql was not installed on the test PC).

$env:PGPASSWORD = "my-long-secret"
psql "host=127.0.0.1 port=8832 dbname=market user=app sslmode=disable"
market=> SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' LIMIT 2;
market=> BEGIN;
market=*> INSERT INTO brokers VALUES (99, 'Test Broker');
market=*> ROLLBACK;
market=> SHOW TABLES;
CommandShows
\l, \dt, \dt+, \di, \di+, \dv, \dudatabases, tables, indexes, views, users
\d table, \d+ tablecolumns, types, defaults, NOT NULL, indexes, CHECKs, foreign keys, "Referenced by"
\d view, \d+ viewthe view's columns and its query
\d indexthe index's columns, method and table

DBeaver

DBeaver is a free database tool with a SQL editor. Not run while writing this help.

  1. New Database Connection, then PostgreSQL.
  2. Host 127.0.0.1, port 8832, database market, user app, password = the token (or the KDB user's password).
  3. Driver properties: set sslmode to disable.
  4. Connection settings, Initialization: turn off "Show all databases".
  5. Open an SQL editor and run KDB SQL.

The SQL editor and the result grid work. The Database Navigator (the tree of schemas and tables) reads the Postgres catalog; KDB has a read-only one (catalog), but not everything DBeaver asks for. When the tree stays empty, use SHOW TABLES in the editor.

Python (psycopg, pg8000)

With psycopg (version 3). Not run on the test PC (no psycopg build for its Python version yet):

import psycopg

with psycopg.connect("host=127.0.0.1 port=8832 dbname=market user=app password=my-long-secret sslmode=disable") as con:
    # %s are parameters: psycopg sends the values apart from the SQL.
    for row in con.execute("SELECT symbol, close FROM ochl_day WHERE close > %s ORDER BY close DESC LIMIT 5", (500,)):
        print(row)

    with con.transaction():   # BEGIN ... COMMIT (ROLLBACK on an exception)
        con.execute("INSERT INTO brokers VALUES (%s, %s)", (99, "Test Broker"))

With pg8000 (a Python-only driver). This was tested: tests/pg_python/check_pg8000.py runs these steps against a real server and prints ALL OK:

import pg8000.native

con = pg8000.native.Connection("app", host="127.0.0.1", port=8832, database="market", password="my-long-secret")
print(con.run("SELECT version()")[0][0])
rows = con.run("SELECT time, close FROM ochl_day WHERE symbol = :s ORDER BY time", s="CITY")
for t, close in rows:
    print(t, close)
con.close()

Node (pg)

The pg package. Tested (chapter 14):

const { Client } = require("pg");

const client = new Client({ host: "127.0.0.1", port: 8832, user: "app", password: "my-long-secret", database: "market" });
await client.connect();
const res = await client.query("SELECT symbol, close FROM ochl_day WHERE symbol = $1 ORDER BY time DESC LIMIT 3", ["CITY"]);
console.log(res.rows);
await client.end();

C# (Npgsql)

Not tested with KDB. Npgsql normally reads the Postgres type catalog when it connects, and KDB's catalog is only a small one: turn that off with Server Compatibility Mode=NoTypeLoading.

using Npgsql;

var cs = "Host=127.0.0.1;Port=8832;Username=app;Password=my-long-secret;Database=market;" +
         "SSL Mode=Disable;Server Compatibility Mode=NoTypeLoading";
await using var dataSource = NpgsqlDataSource.Create(cs);

await using (var cmd = dataSource.CreateCommand("SELECT time, close FROM ochl_day WHERE symbol = $1 ORDER BY time"))
{
    cmd.Parameters.Add(new NpgsqlParameter { Value = "CITY" });
    await using var reader = await cmd.ExecuteReaderAsync();
    while (await reader.ReadAsync())
        Console.WriteLine($"{reader.GetDateTime(0):yyyy-MM-dd} {reader.GetDouble(1)}");
}

await using var conn = await dataSource.OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync();
await using (var insert = new NpgsqlCommand("INSERT INTO brokers VALUES ($1, $2)", conn, tx))
{
    insert.Parameters.Add(new NpgsqlParameter { Value = 99 });
    insert.Parameters.Add(new NpgsqlParameter { Value = "Test Broker" });
    await insert.ExecuteNonQueryAsync();
}
await tx.CommitAsync();

For times, pick one setting: with --pg-timestamptz, send and read DateTime values with Kind = Utc.

Go (pgx)

This is how the NChartPro backend connects. Tested: tests/pg_go/main.go checks all of this against a real server.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, "postgres://app:my-long-secret@127.0.0.1:8832/market?sslmode=disable")
	if err != nil {
		panic(err)
	}
	defer pool.Close()

	// $1, $2, ... are parameters: the values never become part of the SQL text.
	_, err = pool.Exec(ctx, "INSERT INTO securities (symbol, name, sector) VALUES ($1, $2, $3)", "COAST", "Coastal Bank", "Banking")
	if err != nil {
		panic(err)
	}

	rows, err := pool.Query(ctx, "SELECT time, close, volume FROM ochl_day WHERE symbol = $1 AND time >= $2 ORDER BY time",
		"CITY", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
	if err != nil {
		panic(err)
	}
	defer rows.Close()
	for rows.Next() {
		var t time.Time
		var close, volume float64
		if err := rows.Scan(&t, &close, &volume); err != nil {
			panic(err)
		}
		fmt.Println(t.Format("2006-01-02"), close, volume)
	}
}

Real output of tests/pg_go (from chapter 14):

ok   connect and ping (SCRAM-SHA-256 login)
ok   version() = "PostgreSQL 14.0 (KDB 0.2.0-dev), compatible server", database "default"
ok   create table
ok   insert with parameters (int, text, float, numeric, bool, time, NULL)
ok   select with a parameter: {ID:1 Email:asha@example.com Balance:10.5 Active:true Created:2026-09-11 05:15:00.123 +0000 UTC} price 512.40
ok   pgx.CollectRows into structs: 2 rows
ok   LIKE, IN and LIMIT parameters
ok   transactions: rollback and commit
ok   unique violation: SQLSTATE 23505 (a row with id='1' already exists in 'users'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it)
ok   unknown table 42P01; error inside a transaction, then rollback
ok   batch of 4 statements
ok   query modes: simple protocol, exec, describe exec, cache describe
ALL OK

Everything pgx does works: the pool, QueryRow, pgx.CollectRows with RowToStructByName, Begin/Commit/Rollback, batches (SendBatch) and all query modes. Error codes: errors.As(err, &pgErr) and pgErr.Code.

Parameters ($1, $2)

Syntax:

SELECT ... WHERE symbol = $1 AND time >= $2
INSERT INTO t (a, b) VALUES ($1, $2)

A parameter is a place for a value that the driver sends apart from the SQL text. Always use parameters for values that come from users: the value can never be read as SQL (no SQL injection).

Types

KDB typePostgres type (OID)Go (pgx)PythonNode (pg)
BOOLbool (16)boolboolboolean
BYTE, SHORTint2 (21)int16intnumber
INTint4 (23)int32intnumber
LONGint8 (20)int64intstring
FLOATfloat4 (700)float32floatnumber
DOUBLEfloat8 (701)float64floatnumber
DECIMAL(s)numeric (1700)pgtype.Numeric, float64, stringDecimalstring
DATEdate (1082)time.TimedateDate
TIMESTAMPtimestamp (1114), or timestamptz (1184) with --pg-timestamptztime.TimedatetimeDate
a column declared TIMESTAMPTZ, x::timestamptz, now(), CURRENT_TIMESTAMPtimestamptz (1184), alwaystime.TimedatetimeDate
SYMBOL, STRINGtext (25)stringstrstring
JSONjson (114)string, []byte, a structstr or parsedobject
BYTESbytea (17)[]bytebytesBuffer
a column declared INTERVALinterval (1186)time.Duration, pgtype.Intervaltimedeltaobject
a column of an ENUM typethe type's own oid (as in pg_type), sent as textstringstrstring

Parameters sent as varchar, char, name or uuid are taken as text; jsonb as JSON; oid as LONG.

A column declared INTERVAL keeps its length in milliseconds (a LONG). The Postgres port sends it as a real interval, in text (00:15:03.548) and in binary, and a parameter for it is typed interval. Over the HTTP API the same column is a number of milliseconds (903548), typed LONG.

A parameter next to such a column is typed interval too, and KDB turns the interval you send into milliseconds before it runs. So a Go time.Duration works everywhere:

Statement$1Comes back as
WHERE d > $1, d BETWEEN $1 AND $2, SET d = $1interval-
SELECT d + $1, SET d = d - $1intervalinterval
SELECT d * 2, d / 2-interval
SELECT sum(d), avg(d), min(d), max(d)-interval

To send milliseconds as a number instead, cast the parameter: WHERE d > $1::bigint. A client that types the parameter int8 itself also sends milliseconds. Text that is not an interval is refused with 22007.

Times and --pg-timestamptz

KDB keeps a TIMESTAMP as one exact moment in UTC, in milliseconds (time and time zones).

By default it is sent as Postgres timestamp (without zone), like QuestDB does. With this type, drivers drop the time zone of a value you send:

Start the server with --pg-timestamptz to send TIMESTAMP as timestamptz. Then every driver keeps the exact moment, in any time zone. Choose one setting for a server and keep it.

A column declared TIMESTAMPTZ is always sent as timestamptz, with or without the option. So are x::timestamptz, now() and CURRENT_TIMESTAMP, and a parameter next to them is typed timestamptz. pgx then sends a Go time as the moment it is: 11:00 +05:45 is stored as 05:15 UTC.

The session's time zone comes from the connection's TimeZone parameter (pgx ?timezone=Asia/Kathmandu, libpq options=-c TimeZone=...), else from SET TIME ZONE, else the server's zone. A timestamptz shows that clock as text (2026-09-25 11:00:00+05:45), and timestamptz text without an offset is read on that clock. RESET TIME ZONE goes back to the connection's zone.

Transactions

Syntax:

BEGIN [ISOLATION LEVEL ...] | START TRANSACTION
COMMIT | END
ROLLBACK | ABORT

BEGIN, COMMIT and ROLLBACK work across messages, one transaction per connection. Every KDB transaction is serializable; an isolation level you name is accepted and ignored. More: transactions.

Error codes (SQLSTATE)

Errors carry a SQLSTATE: a five-character code that drivers show (pgErr.Code in Go, e.sqlstate in psycopg).

CodeMeaning
42601Syntax error (the message has line X, column Y; the error also has the position)
42P01Unknown table or view
42703Unknown column
42P07The object already exists
23505A unique key used twice (PRIMARY KEY, UNIQUE)
23503REFERENCES: the row it points to is missing, or is still used
23502A NOT NULL column without a value
23514A CHECK rule failed
25P02A statement after a failed one in the same transaction (or the transaction was idle too long): send ROLLBACK
P0001A RAISE EXCEPTION in a trigger function, a DO block or a procedure, with no code given
55P03Busy: another connection's transaction is open; try again
40P01Deadlock: two transactions waited for each other; run it again
57014The statement was stopped: the statement timeout, a cancel request, or DELETE /exec/<id> (protection)
54000A limit was passed: result memory, the number of groups, a message over 256 MB, a COPY too big
25006The database or the server is read-only
53100The disk is full: nothing of the statement was saved. It works again once space is freed (disk full).
22P02, 22001, 22003, 22012A value that does not fit: bad text for the type, too long, a number too big, division by zero
42883Unknown function
XX001A damaged data file (after damage)
0A000Not supported: data of a newer KDB, or a changed prepared statement
42501The user's role does not allow this
28P01Wrong password or unknown user
28000Login refused (no password allowed from another computer, too many failed logins)
3D000Unknown database
53300Too many connections (500 unless --max-connections sets another number)
08P01A message KDB does not support (old function calls)
XX000Anything else: read the message

Which constraint. A 23505 or 23503 error also says which table and which constraint it was about. The constraint has the name PostgreSQL would give it, or the name you gave it with CONSTRAINT name:

Error fieldGo (pgx)Example
s schemapgErr.SchemaNamepublic
t tablepgErr.TableNameusers
n constraintpgErr.ConstraintNameusers_pkey, users_email_key (a UNIQUE column), orders_user_id_fkey (a REFERENCES column)
c columnpgErr.ColumnNameemail (when the constraint has one column)

When a row is still referred to (DELETE of a parent row), the table is the one that refers to it, as in PostgreSQL.

Your own code. A trigger function or a DO block can choose the code with RAISE EXCEPTION '...' USING ERRCODE = '23001'. A condition name works too (ERRCODE = 'restrict_violation'), and so do RAISE SQLSTATE '23001' USING MESSAGE = '...' and RAISE unique_violation. The client gets that code, so it can tell your rule from a real duplicate key. A name KDB does not know gives P0001.

Commands KDB answers itself

Drivers and tools send a few PostgreSQL commands that KDB SQL does not have. KDB answers them itself:

CommandAnswer
SET name = value, SET name TO value, SET NAMES ...checked against KDB's list of settings and remembered; some change what KDB does (work_mem, enable_hashjoin, ...), a restart-only one gives 55P02. See settings. client_encoding must be UTF8 (22023 otherwise)
SET TIME ZONE 'Asia/Kathmandu', SET timezone = ..., RESET TIME ZONEthe session's clock for CURRENT_DATE, ::date, date_trunc, EXTRACT, to_char (time zones); the driver gets TimeZone back. A zone with daylight saving time gives 22023
SET app.name = 'value', SET LOCAL app.name = 'value'your own setting for this connection; current_setting('app.name') reads it, in triggers too. LOCAL ends with the transaction; outside BEGIN it only warns (25P01). See session settings
SET synchronous_commit = off, SET LOCAL synchronous_commit = offthis connection's commits (or this transaction's) do not wait for the disk sync, which follows within about 10 ms; a power cut or a crash of the program can lose the last of them. SHOW and RESET work. See performance tips
SET SESSION CHARACTERISTICS ..., SET TRANSACTION ...accepted, ignored
SHOW server_version, SHOW TimeZone, SHOW transaction_isolation, ...the setting (server_version is 14.0 (KDB ...), TimeZone is the session's zone, UTC unless set); an unknown name gives 42704. SHOW ALL lists every setting (settings)
SELECT version()PostgreSQL 14.0 (KDB 0.2.0-dev), compatible server
SELECT current_database()the KDB database name
SELECT current_user, session_user, current_schema(), pg_backend_pid()the user name, the user name, public, the connection number (its pid in pg_stat_activity)
RESET ..., DISCARD ALL, DEALLOCATE ...accepted
LISTEN, UNLISTEN, NOTIFY, pg_notify()as in PostgreSQL: see LISTEN and NOTIFY

SHOW TABLES, SHOW COLUMNS FROM t, SHOW DATABASES and the other KDB SHOW statements still work as KDB statements.

The result cache is on

Syntax:

SET result_cache = off | on      this connection
SHOW result_cache

A SELECT over this port uses the server's result cache, like /exec: the same query again gives the kept result while no table it read has changed. It is on for every new connection. For a benchmark that must time the engine, turn it off on each connection first (in a pgx pool: in AfterConnect). Without that, repeated queries with the same values measure the cache, not the query. SHOW result_cache says which one a connection has; RESET result_cache turns it on again.

A comment does not make a query a different one: /* run 2 */ SELECT ... is answered from the kept result of SELECT ... (the key is the statement as KDB reads it, without comments or extra spaces). To time a second run, turn the cache off; a changed comment is not enough.

Integer division

Syntax:

SET int_division = integer | float | default      this connection
SHOW int_division
postgres://app:secret@127.0.0.1:8832/market?sslmode=disable&int_division=integer

PostgreSQL gives 7 / 2 = 3; KDB gives 3.5 unless the connection asks for integer. An app that reads sum(ms) / 1000 into a Go int64 needs integer: 3.5 cannot be scanned into it.

HowFor
kdb serve --pg-int-division on (or KDB_PG_INT_DIVISION=on)every Postgres connection, from its start
int_division=integer in the connection string (a startup parameter; pgx and libpq send it)that connection
SET int_division = integer (also SET kdb.int_division = ...)that connection, from now on

PostgreSQL-compatible results (kdb.pg_compat)

Waiting for the owner's decision. This setting is off everywhere by default, and KDB's own answers do not change. It exists so an app moving from PostgreSQL can be tried with PostgreSQL's answers; whether it becomes the default is not decided.

Syntax:

SET kdb.pg_compat = on | off | default            this session (also SET pg_compat = ...)
SHOW kdb.pg_compat
RESET kdb.pg_compat
CREATE DATABASE <name> WITH (pg_compat = on)
ALTER DATABASE <name> SET pg_compat = on | off | default
ALTER DATABASE <name> WITH (pg_compat = on | off)
kdb serve --pg-compat on | off                    databases without their own setting (default off)
postgres://app:secret@127.0.0.1:8832/market?sslmode=disable&options=-c%20kdb.pg_compat%3Don

With pg_compat on, a statement gives PostgreSQL 14's answer where KDB's own differs:

WhatKDB (off)PostgreSQL (on)
7 / 2, -7 / 23.5, -3.53, -3 (the same as int_division = integer)
0.1 + 0.2 = 0.3false: 0.1 is a DOUBLEtrue: every decimal literal is an exact NUMERIC
1.50::text1.51.50
x::numeric, CAST(x AS NUMERIC) (no precision)a DOUBLEthe exact NUMERIC of x at its own scale (text ' 12.50 ' is 12.50)
count(*) OVER (ORDER BY x) with equal xROWS frame: 1, 2, 3, 4RANGE UNBOUNDED PRECEDING: equal values count together, 1, 3, 3, 4
row_number() OVER (ORDER BY x DESC) with a NULL xthe NULL row is lastthe NULL row is first; NULLS FIRST / NULLS LAST in a window are followed
ORDER BY namebyte order: Banana, Zebra, applelocale order: apple, Banana, Zebra
'apple' < 'Banana', name BETWEEN 'a' AND 'c'byte orderlocale order
a plain TIMESTAMP on this portUTCthe session's wall clock (see below)

Big answers stream

A SELECT with a big answer sends its rows while it reads them, so a whole table can be read through the Postgres port: SELECT * FROM trades over 34 million rows used to stop at --max-result-memory (54000, about 6609 MB; the limit is 1024 MB).

COPY

Syntax:

COPY <table> [(<column>, ...)] FROM STDIN [WITH (FORMAT text|csv|binary, HEADER, DELIMITER 'x', NULL 'x', QUOTE 'x', ESCAPE 'x')]
COPY <table> | (<SELECT ...>) TO STDOUT [WITH (...)]

COPY moves many rows in one go: pgx CopyFrom, psycopg cursor.copy(), psql \copy.

The catalog

Tools and ORMs read the schema from PostgreSQL's catalog. KDB works these out from its own tables when they are read (nothing is stored), all in the schema public:

SchemaViews
information_schematables, columns, views, schemata, sequences, table_constraints, key_column_usage, constraint_column_usage, referential_constraints, check_constraints
pg_catalogpg_namespace, pg_class, pg_attribute, pg_type, pg_index, pg_indexes, pg_tables, pg_views, pg_constraint, pg_sequences, pg_database, pg_attrdef, pg_roles, pg_settings, pg_am, pg_collation, pg_policy and pg_policies (row rights), and empty ones such as pg_description, pg_inherits, pg_trigger

They only read. pg_attribute also lists the columns of every view and index.

On the PostgreSQL port, the catalog functions tools call work too: pg_get_indexdef, pg_get_constraintdef, pg_get_viewdef, pg_get_expr, format_type, pg_table_is_visible, and casts such as 'orders'::regclass. On every port: to_regclass, pg_relation_size, pg_total_relation_size, pg_database_size and pg_size_pretty (sizes), and QuestDB's tables() and table_columns() (SHOW TABLES).

What does not work