KDB knowledge

14. PostgreSQL protocol

What you will learn

KDB is not PostgreSQL. But it speaks the same "wire protocol" (the messages between a program and the database). So a normal Postgres driver can send SQL to KDB and read the rows. You write KDB SQL (chapter 11). Much everyday PostgreSQL SQL works too: SERIAL, ON CONFLICT, RETURNING, WITH, arrays, information_schema (chapters 22 and 23).

Start the server

kdb serve starts the Postgres port together with the HTTP port:

kdb serve --token my-long-secret
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)
  ...

Options:

OptionMeaningDefault
--pg-port NThe Postgres port. --pg-port 0 turns it off.8832
--bind ADDRESSThe address for both ports (HTTP and Postgres).127.0.0.1
--token TThe password for every Postgres user.none
--pg-timestamptzSend KDB TIMESTAMP as timestamptz (see Times).off

Security: read this first

Connection settings

SettingValue
Host127.0.0.1
Port8832
Userany name (for example app)
Passwordthe --token value
Databasea KDB database name, like db= in the HTTP API. Empty, postgres or the user name means the default database.

A connection string:

postgres://app:my-long-secret@127.0.0.1:8832/market?sslmode=disable

Go (pgx)

This is how the NChartPro backend connects. Nothing special is needed.

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()

	// Parameters $1, $2, ... work. 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)
	}
}

Everything pgx does works: the pool, QueryRow, pgx.CollectRows with RowToStructByName, Begin/Commit/Rollback, batches (SendBatch), and all query modes (the default cached statements, QueryExecModeSimpleProtocol, Exec, DescribeExec, CacheDescribe).

The test program tests/pg_go/main.go checks all of this against a real server. Its real output:

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

Run it yourself:

kdb serve --data-dir D:\kdb-pg-test --port 9950 --pg-port 8850 --token secret-token
# in another window:
cd tests\pg_go
go run . "postgres://app:secret-token@127.0.0.1:8850/default"

Python

psycopg (version 3) and psycopg2 use the normal Postgres protocol, so they work the same way. The pure-Python driver pg8000 was tested (tests/pg_python/check_pg8000.py):

import pg8000.native

con = pg8000.native.Connection("app", host="127.0.0.1", port=8832,
                               database="market", password="my-long-secret")
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()

With psycopg:

import psycopg

with psycopg.connect("host=127.0.0.1 port=8832 dbname=market user=app password=my-long-secret sslmode=disable") as con:
    for row in con.execute("SELECT symbol, close FROM ochl_day WHERE close > %s", (500,)):
        print(row)

(psycopg was not run on the test PC: it has no build for its Python version yet.)

Node (pg)

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', ['CITY']);
console.log(res.rows);
await client.end();

This was tested. Note about times: node-pg reads a timestamp (without time zone) as your computer's local time. On a PC set to Nepal time (+05:45), 05:15 UTC comes back as 23:30 of the day before. Start the server with --pg-timestamptz and times come back right.

Numbers: node-pg gives LONG and DECIMAL values as strings ('182390', '512.40'), as with PostgreSQL.

psql

$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;

Plain SQL works. KDB also answers the Postgres catalog (pg_catalog, information_schema), so \l (databases), \dt (tables), \di (indexes) and \du work. A full \d ochl_day still fails on one of the queries psql sends; use SHOW COLUMNS FROM ochl_day; and SHOW CREATE TABLE ochl_day; instead. \du shows one user, kdb: use SHOW USERS; for the real list.

\copy works too, because psql sends it as COPY ... FROM STDIN (see Loading many rows: COPY).

(psql is not installed on the test PC, so this example was not run.)

DBeaver

  1. New Database Connection → PostgreSQL.
  2. Host 127.0.0.1, port 8832, database market, user app, password = the token.
  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, which KDB answers: it lists the tables, their columns with types and defaults, indexes and constraints. Sizes stay empty, and row counts are not known.

(DBeaver was not run on the test PC.)

Types

KDB typePostgres type (OID)Go (pgx)
BOOLbool (16)bool
BYTE, SHORTint2 (21)int16
INTint4 (23)int32
LONGint8 (20)int64
FLOATfloat4 (700)float32
DOUBLEfloat8 (701)float64
DECIMAL(s)numeric (1700)pgtype.Numeric, float64, string
DATEdate (1082)time.Time
TIMESTAMPtimestamp (1114), or timestamptz (1184) with --pg-timestamptztime.Time
SYMBOL, STRINGtext (25)string
JSONjson (114)string, []byte, a struct
BYTESbytea (17)[]byte

Values go both ways in text and binary form. A driver picks the form; you do not have to.

Parameters get their type from where you use them. In WHERE volume > $1, $1 is LONG because volume is LONG. In INSERT INTO t (a, b) VALUES ($1, $2), each parameter gets its column's type. A parameter KDB cannot place (SELECT $1) has no type; the driver then sends it as text.

Computed columns. Before a query runs, KDB works out the type of each result column. For columns, count(*), sum, avg, arithmetic and most functions it knows the type. For a few (a saved function, say) it runs the query once with empty values to learn it. If a type is still unknown, the column is text. When Go then says cannot scan text into *float64, add a cast: SELECT my_func(close)::DOUBLE AS x ....

Times

KDB keeps a TIMESTAMP as one exact moment in UTC (chapter 4).

By default it is sent as Postgres timestamp (without time 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. pgx then gives time.Time values in the local time zone of the Go program (the same moment). Choose one setting and keep it.

Times have milliseconds. Microseconds from a driver are cut.

Transactions

BEGIN, COMMIT and ROLLBACK work across messages, one transaction per connection. Postgres forms are accepted too: BEGIN ISOLATION LEVEL ..., START TRANSACTION, END, ABORT. (Every KDB transaction is serializable; the level is ignored.)

When a statement fails inside a transaction, the transaction is aborted, like in PostgreSQL. Every next statement gets error 25P02 until you send ROLLBACK (a COMMIT then also rolls back, and its tag is ROLLBACK). pgx does this for you: tx.Rollback(ctx).

INSERT, UPDATE and DELETE with RETURNING give rows, like a SELECT (in Go: pool.QueryRow(ctx, "INSERT ... RETURNING id", ...).Scan(&id)).

Errors

Errors carry a SQLSTATE code, so errors.As(err, &pgErr) and pgErr.Code work in Go:

CodeMeaning
42601Syntax error (the message has line X, column Y; the error also has the position)
42P01Unknown table or view
42703Unknown column
23505Unique key used twice (PRIMARY KEY, UNIQUE)
23503REFERENCES: the row it points to is missing, or is still used
23502NOT NULL column without a value
23514CHECK constraint failed
25P02The transaction failed before (or was idle too long); send ROLLBACK
55P03Busy: another transaction held the table for 5 seconds; try again
40P01Deadlock: two transactions waited for each other; this one was rolled back. Run it again
42501The user's role or table rights do not allow this statement
57014The statement was stopped: statement timeout, a CancelRequest, server shutdown
54000The query keeps too much in memory (--max-result-memory, --max-groups)
25006The database or the server is read-only
28P01Wrong password
3D000Unknown database
53300Too many connections (at most 100)
XX000Anything else (read the message)

What drivers send, and KDB answers

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

CommandAnswer
SET name = value / SET name TO valueaccepted and remembered (client_encoding must be UTF8)
SET statement_timeout = 5000 (or '5s'), SET safe_updates = onKDB uses them (chapter 21); a user who is not an admin can only make the timeout shorter than the server's
CancelRequest (Ctrl+C in psql, a cancelled context in pgx)stops the running statement of that connection: 57014
SHOW server_version, SHOW TimeZone, ...the setting; server_version is 14.0 (KDB ...)
SELECT version()PostgreSQL 14.0 (KDB 0.2.0-dev), compatible server
SELECT current_database()the KDB database name
SELECT current_user, current_schema()the user name, public
RESET ..., DISCARD ALL, DEALLOCATE ...accepted

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

Loading many rows: COPY

COPY is the fast way to load many rows over this port. pgx CopyFrom sends it, and so does psql's \copy. It loads hundreds of thousands of rows a second, about 1,000 times faster than one INSERT per round trip.

Written asWhat it does
COPY t (a, b) FROM STDIN (FORMAT binary)what pgx CopyFrom sends; the fastest form
COPY t FROM STDINtext: tab between values, \N for NULL
COPY t FROM STDIN WITH (FORMAT csv, HEADER)CSV, with quotes and an optional header line
COPY t TO STDOUT, COPY (SELECT ...) TO STDOUTreads rows out, in text, CSV or binary
rows := [][]any{{int64(101), "Everest Capital"}, {int64(102), "Summit Traders"}}
n, err := pool.CopyFrom(ctx, pgx.Identifier{"brokers"}, []string{"id", "name"}, pgx.CopyFromRows(rows))

Not supported yet