KDB knowledge

22. PostgreSQL schema compatibility

What you will learn

The NChartPro app creates its PostgreSQL tables itself when it starts (pgdb.CreateAllTables()). This chapter shows what KDB does with those statements:

Chapter 14 explains how to connect with the PostgreSQL protocol. This chapter is about the schema (tables, keys, indexes); chapter 23 is about queries and data changes (ON CONFLICT, WITH, subqueries, arrays). The output under each example is real.

The short version

PostgreSQLKDB
SERIAL, BIGSERIAL, GENERATED ... AS IDENTITYWorks for a single-column PRIMARY KEY (AUTO id).
VARCHAR(n), CHAR(n), NUMERIC(p,s)Works. Lengths, precision and scale are checked.
UUID, TIME, INTERVAL, TEXT[], JSONB, INETWorks, with a KDB type underneath (Part A).
PRIMARY KEY (a, b), UNIQUE (a, b), FOREIGN KEY (a, b)Works.
ON DELETE CASCADE / SET NULL / SET DEFAULT / RESTRICT, ON UPDATE CASCADEWorks, also inside BEGIN ... COMMIT.
ALTER TABLE ... ALTER COLUMN ... TYPE / SET DEFAULT / SET NOT NULL, ADD CONSTRAINTWorks. Rows are checked, or written again.
CREATE UNIQUE INDEX ... (lower(email)), ... WHERE condWorks.
CREATE SEQUENCE, nextval('s')Works. A number is never given twice, also after a crash.
information_schema.tables / columns / table_constraints, pg_indexes, pg_constraint, ...Read-only views.
DO $$ BEGIN IF ... THEN ...; END IF; END $$Works for IF, RAISE and SQL statements.
CREATE EXTENSION, CREATE SCHEMA, COMMENT ONAccepted. Nothing changes.
CREATE FUNCTION ... RETURNS trigger LANGUAGE plpgsql with a BEFORE UPDATE ... FOR EACH ROW triggerWorks for IF, RAISE EXCEPTION, NEW.col := ... and RETURN NEW (Part I). Other plpgsql is refused with a clear message.

When the app's PostgreSQL startup DDL runs on KDB, 802 of 809 statements work (99%). Before this work it was 198 of 809. The 7 others are listed at the end of the chapter.


Part A: Types

Write the PostgreSQL name. KDB keeps the name (so SHOW CREATE TABLE and information_schema.columns show it) and stores the value in a KDB type.

PostgreSQLKDB stores asRule
SMALLINT, INT2SHORT
INTEGER, INT, INT4INT
BIGINT, INT8LONG
SMALLSERIAL, SERIAL, BIGSERIALSHORT / INT / LONG PRIMARY KEY AUTOOnly as the single-column PRIMARY KEY. See below.
GENERATED {ALWAYS | BY DEFAULT} AS IDENTITYsame as SERIAL
REAL, FLOAT4, FLOAT(1..24)FLOAT
DOUBLE PRECISION, FLOAT8, FLOAT(25..53)DOUBLEFLOAT alone is KDB's 4-byte FLOAT (in PostgreSQL it is 8 bytes).
NUMERIC(p,s), DECIMAL(p,s), NUMERIC(p)DECIMAL(s)Rounded to s places (half away from zero). More than p digits → error numeric field overflow. s at most 18.
NUMERIC (no precision)DOUBLEAbout 15 exact digits.
DECIMAL(s) (one number)DECIMAL(s)KDB meaning: s is the scale (chapter 11).
MONEYDECIMAL(2)Rounded to 2 places.
VARCHAR(n), CHARACTER VARYING(n)STRINGAt most n characters (letters of any language, such as Nepali, count as characters).
CHAR(n), BPCHARSTRINGAt most n characters. Not padded with spaces.
TEXT, VARCHAR, CITEXT, NAMESTRINGCITEXT is not case-insensitive: use lower(col) (and a lower(col) index).
BOOLEANBOOL
DATEDATE
TIMESTAMP, TIMESTAMPTZ, TIMESTAMP WITH TIME ZONETIMESTAMPMilliseconds, UTC. Text with an offset (+05:45) is converted. See Part J.
TIME, TIMETZSTRINGChecked and written as HH:MM:SS[.fff][zone]: '9:30' becomes '09:30:00'.
INTERVALLONGMilliseconds. '7 days' is 604800000. A month counts as 30 days.
UUIDSTRINGChecked. Written in lower case with dashes.
JSON, JSONBJSONChapter 18. jsonb operators like @> are not there.
BYTEABYTES
INET, CIDR, MACADDR, XMLSTRINGNot checked.
type[] (TEXT[], INT[], ...)JSON array'{a,"b c",NULL}' becomes ["a","b c",null]. Numbers and booleans are checked.
CREATE TABLE ev (id SERIAL PRIMARY KEY, at_time TIME, took INTERVAL, at TIMESTAMPTZ);
INSERT INTO ev (at_time, took, at) VALUES ('9:30', '1 day 02:00:00', '2026-09-11 10:00:00+05:45')
  RETURNING at_time, took, at;
table 'ev' created

at_time  | took     | at
---------+----------+-------------------------
09:30:00 | 93600000 | 2026-09-11T04:15:00.000Z
(1 row, 8.6 ms)
1 row(s) inserted

Errors use the PostgreSQL words, so the app's error checks still work. Over the PostgreSQL protocol they have the PostgreSQL error codes:

MessageCode
value too long for type VARCHAR(40) (48 characters, at most 40)22001
numeric field overflow: ...22003
invalid input syntax for type uuid: 'not-a-uuid'22P02

SERIAL must be the primary key

KDB gives AUTO ids only to the PRIMARY KEY. A SERIAL column that is not the primary key is refused, with the fix in the message:

error: line 1, column 24: column 'code' is SERIAL (or IDENTITY) but not the PRIMARY KEY; in KDB such a column must be the table's single-column PRIMARY KEY (id SERIAL PRIMARY KEY). For other columns use a sequence: code INT NOT NULL DEFAULT nextval('t_serial_code_seq')

AUTO ids work like a sequence: a failed or rolled back insert also uses up an id, so there can be gaps.


Part B: CREATE TABLE

A small forum, written as PostgreSQL:

CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS public.forum_users (
  user_id    SERIAL PRIMARY KEY,
  email      VARCHAR(120) NOT NULL UNIQUE,
  name       VARCHAR(40) NOT NULL,
  balance    NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS forum_posts (
  post_id   BIGSERIAL PRIMARY KEY,
  user_id   INTEGER REFERENCES forum_users(user_id) ON DELETE SET NULL,
  parent_id BIGINT REFERENCES forum_posts(post_id) ON DELETE CASCADE,
  title     TEXT NOT NULL,
  tags      TEXT[] NOT NULL DEFAULT '{}',
  public_id UUID NOT NULL DEFAULT gen_random_uuid()
);
CREATE TABLE IF NOT EXISTS post_likes (
  post_id BIGINT NOT NULL,
  user_id INT NOT NULL,
  CONSTRAINT post_likes_pkey PRIMARY KEY (post_id, user_id),
  CONSTRAINT post_likes_post_fk FOREIGN KEY (post_id) REFERENCES forum_posts(post_id) ON DELETE CASCADE,
  CONSTRAINT post_likes_user_fk FOREIGN KEY (user_id) REFERENCES forum_users(user_id) ON DELETE CASCADE
);
COMMENT ON TABLE post_likes IS 'one row per like';
extension 'pgcrypto' accepted: its common functions are built in or not needed

table 'forum_users' created

table 'forum_posts' created

table 'post_likes' created

comment accepted (KDB does not keep comments)
INSERT INTO forum_users (email, name) VALUES ('ram@example.com', 'Ram'), ('sita@example.com', 'Sita')
  RETURNING user_id, balance, created_at;
INSERT INTO forum_posts (user_id, title, tags) VALUES (1, 'CITY results', '{bank,"q1 2083"}') RETURNING post_id, tags;
user_id | balance | created_at
--------+---------+-------------------------
      1 |    0.00 | 2026-09-11T19:01:54.203Z
      2 |    0.00 | 2026-09-11T19:01:54.203Z
(2 rows, 2.1 ms)
2 row(s) inserted

post_id | tags
--------+-------------------
      1 | ["bank","q1 2083"]
(1 row, 2.1 ms)
1 row(s) inserted

The rules are checked for every written row:

INSERT INTO forum_users (email, name, balance) VALUES ('hari@example.com', 'Hari', 12.345) RETURNING user_id, balance;
user_id | balance
--------+--------
      3 |   12.35

INSERT INTO forum_users (email, name) VALUES ('ram@example.com', 'Ram again');
error: column 'email' must be unique: 'ram@example.com' is already used

INSERT INTO forum_posts (user_id, title) VALUES (99, 'Nobody');
error: column 'user_id' = '99' has no matching row in forum_users(user_id)

INSERT INTO forum_users (email, name, balance) VALUES ('gita@example.com', 'Gita', -5);
error: CHECK constraint 'forum_users_balance_check' of table 'forum_users' failed: balance >= 0 is false for the row user_id='5', balance='-5.00'

What CREATE TABLE accepts

FormWhat KDB does
IF NOT EXISTS, public.nameAs in PostgreSQL. public. and pg_catalog. are removed from names.
NOT NULL, NULL, DEFAULT expr, CHECK (...), UNIQUE, PRIMARY KEYAs in PostgreSQL.
REFERENCES t (no column)Refers to the primary key of t.
CONSTRAINT name ...The name is kept. It is used in errors, DROP CONSTRAINT and the catalog views.
PRIMARY KEY (a, b)Rows are stored by the key. INSERT OR REPLACE uses it.
UNIQUE (a, b)A unique index named like PostgreSQL (table_a_b_key).
FOREIGN KEY (a, b) REFERENCES t (x, y)Checked when any part is not NULL (MATCH SIMPLE).
GENERATED ALWAYS AS (expr) STOREDA KDB computed column (worked out when read, not stored).
CREATE TABLE t AS SELECT ... [WITH NO DATA]Types come from the query. Table and rows are written together.
CREATE TEMP / TEMPORARY / UNLOGGED TABLEA normal table (it stays after the session).
WITH (fillfactor = 70), WITHOUT OIDS, TABLESPACE xAccepted and ignored.
INHERITS (...), PARTITION BY RANGE (...)Refused. Use KDB TIMESERIES tables (chapter 3).
DEFERRABLERefused. KDB checks keys at every statement.

Table constraints (PRIMARY KEY (...), CONSTRAINT ... CHECK) must come after the columns.


Part C: Foreign key actions

forum_posts.parent_id has ON DELETE CASCADE, and post_likes has ON DELETE CASCADE to both tables. Add replies and likes, then delete post 1:

INSERT INTO forum_posts (user_id, parent_id, title) VALUES (2, 1, 'Good numbers'), (1, 2, 'Agree');
INSERT INTO post_likes VALUES (1, 2), (2, 1), (3, 2);
DELETE FROM forum_posts WHERE post_id = 1;
SELECT post_id, user_id, parent_id, title FROM forum_posts ORDER BY post_id;
SELECT * FROM post_likes ORDER BY post_id;
2 row(s) inserted

3 row(s) inserted

1 row(s) deleted

post_id | user_id | parent_id | title
--------+---------+-----------+------
(0 rows, 0.1 ms)

post_id | user_id
--------+--------
(0 rows, 0.1 ms)

Post 1 took its reply (2), the reply's reply (3) and all three likes with it. forum_posts.user_id has ON DELETE SET NULL:

INSERT INTO forum_posts (user_id, title) VALUES (2, 'Sita post');
DELETE FROM forum_users WHERE user_id = 2;
SELECT post_id, user_id, title FROM forum_posts ORDER BY post_id;
1 row(s) inserted

1 row(s) deleted

post_id | user_id | title
--------+---------+----------
      5 |    NULL | Sita post
(1 row, 0.3 ms)
ActionWhat happens to the rows that refer to the deleted (or changed) row
NO ACTION (default), RESTRICTThe statement is refused: cannot remove forum_users(user_id) = '1': table 'forum_posts' column 'user_id' still refers to it (foreign key 'forum_posts_user_id_fkey').
CASCADEON DELETE: they are deleted too (and their children, at most 32 levels). ON UPDATE: they get the new key.
SET NULLTheir column becomes NULL. Refused if the column is NOT NULL.
SET DEFAULTTheir column gets its DEFAULT. The default must exist in the parent.

The rules:


Part D: ALTER TABLE

ALTER TABLE forum_users ADD COLUMN IF NOT EXISTS phone VARCHAR(15), ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE forum_users ALTER COLUMN name TYPE VARCHAR(80), ALTER COLUMN balance SET DEFAULT 100;
ALTER TABLE forum_users ALTER COLUMN phone SET NOT NULL;
table 'forum_users' altered
table 'forum_users' altered
error: column 'phone' of table 'forum_users' contains NULL values; set them first (UPDATE ... SET phone = ... WHERE phone IS NULL)
FormWhat KDB does
ADD [COLUMN] [IF NOT EXISTS] col type [rules]Old rows get the DEFAULT.
DROP [COLUMN] [IF EXISTS] col [CASCADE]Refused while an index, CHECK or multi-column key uses the column; CASCADE drops them.
ALTER [COLUMN] col SET DEFAULT expr / DROP DEFAULTOnly new rows.
ALTER [COLUMN] col SET NOT NULL / DROP NOT NULLSET NOT NULL checks all rows first.
ALTER [COLUMN] col [SET DATA] TYPE type [USING expr]Every row is converted and written again, in the same transaction as the change. A value that does not fit stops it and nothing changes.
RENAME [COLUMN] a TO b, RENAME TO new_nameAs in PostgreSQL.
ADD [CONSTRAINT name] UNIQUE (...) / PRIMARY KEY (...) / FOREIGN KEY (...) REFERENCES ... / CHECK (...)Existing rows are checked. ADD PRIMARY KEY writes the rows again, stored by the new key.
... NOT VALIDFor CHECK and FOREIGN KEY: old rows are not checked, new rows are.
DROP CONSTRAINT [IF EXISTS] nameAny named constraint, except the PRIMARY KEY (rows are stored by it).
Several actions with commasAll of them or none.
ALTER TABLE IF EXISTS, ONLY, OWNER TO, VALIDATE CONSTRAINTAccepted.
ENABLE / DISABLE TRIGGER nameKDB triggers (chapter 13).

ALTER TABLE and CREATE INDEX cannot be used between BEGIN and COMMIT (chapter 13). A DO block can use them, outside a transaction (Part G).


Part E: Indexes

CREATE UNIQUE INDEX IF NOT EXISTS uq_forum_users_email_lower ON forum_users (lower(email));
INSERT INTO forum_users (email, name) VALUES ('RAM@example.com', 'Ram 2');
CREATE UNIQUE INDEX IF NOT EXISTS uq_one_admin ON forum_users (is_admin) WHERE is_admin;
CREATE INDEX IF NOT EXISTS idx_posts_tags ON forum_posts USING gin (tags);
SELECT indexname FROM pg_indexes WHERE tablename = 'forum_users' ORDER BY indexname;
index 'uq_forum_users_email_lower' created on table 'forum_users' (2 rows)
error: duplicate value violates unique index 'uq_forum_users_email_lower' on table 'forum_users': a row with the same (lower(email)) already exists
index 'uq_one_admin' created on table 'forum_users' (0 rows)
index 'idx_posts_tags' created on table 'forum_posts' (1 rows)

indexname
--------------------------
forum_users_email_key
forum_users_pkey
uq_forum_users_email_lower
uq_one_admin
(4 rows, 0.1 ms)
FormWhat KDB does
CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] [name] ON [ONLY] t [USING btree | hash | gin | gist] (...)Accepted. Without a name, KDB names it like PostgreSQL (t_col_idx). USING is only remembered.
(col DESC NULLS LAST, col text_pattern_ops)Accepted. The order words are remembered; KDB indexes work both ways.
(lower(email)), (COALESCE(a, ''))An expression index.
... WHERE conditionA partial index: only rows where the condition is true.
INCLUDE (...), WITH (...), TABLESPACEAccepted and ignored.
An index on a GENERATED columnIndexes the column's expression.
DROP INDEX [CONCURRENTLY] [IF EXISTS] a, b [CASCADE]Drops them. The index of a UNIQUE constraint needs ALTER TABLE ... DROP CONSTRAINT.

Unique expression and partial indexes are checked on every write (also inside transactions and after ROLLBACK). Non-unique ones are built and used too: WHERE lower(email) = 'a@x.np' uses (lower(email)), and a query whose WHERE has every part of a partial index's own WHERE uses that index. EXPLAIN shows it as use index <name> (...). An expression key part works for expr = constant only (no ranges, no IN (...), no ORDER BY on it); those queries scan.


Part F: Sequences

CREATE SEQUENCE IF NOT EXISTS invoice_no START WITH 1000;
SELECT nextval('invoice_no') AS a, nextval('invoice_no') AS b;
SELECT setval('invoice_no', 5000);
CREATE TABLE invoices (invoice_no BIGINT PRIMARY KEY DEFAULT nextval('invoice_no'), amount NUMERIC(12,2));
INSERT INTO invoices (amount) VALUES (10), (20) RETURNING invoice_no;
sequence 'invoice_no' created

a    | b
-----+-----
1000 | 1001
(1 row, 2.8 ms)

setval('invoice_no', 5000)
--------------------------
                      5000
(1 row, 2.0 ms)

table 'invoices' created

invoice_no
----------
      5001
      5002
(2 rows, 3.9 ms)
2 row(s) inserted
FormMeaning
CREATE SEQUENCE [IF NOT EXISTS] s [AS type] [INCREMENT [BY] n] [MINVALUE n] [MAXVALUE n] [START [WITH] n] [CACHE n] [[NO] CYCLE] [OWNED BY t.c]A new sequence.
ALTER SEQUENCE s RESTART [WITH n] / INCREMENT BY n / ...Changes it.
DROP SEQUENCE [IF EXISTS] s, ...Removes it.
nextval('s')The next number.
currval('s')The last nextval of this session. Before that: an error, as in PostgreSQL.
setval('s', n [, is_called])The next nextval gives n + 1 (or n with false).
lastval()The last nextval of this session, of any sequence.

A number is never given twice. Not after a rollback, not after a restart, not after a crash. KDB writes a mark to the log before it gives out the next 32 numbers. After a crash, the sequence starts after the mark, so a few numbers can be skipped (as in PostgreSQL).

Over HTTP every request is a new session, so currval works only inside one request (SELECT nextval('s'); SELECT currval('s')). Over the PostgreSQL protocol, the session is the connection.


Part G: Other statements

StatementWhat KDB does
CREATE SCHEMA [IF NOT EXISTS] nameAccepted: schema 'reporting' accepted: KDB has no schemas; use a separate database for its tables.
CREATE EXTENSION [IF NOT EXISTS] pgcryptoAccepted for pgcrypto, uuid-ossp, citext, plpgsql, pg_trgm, btree_gin, btree_gist, unaccent. Other names are refused.
COMMENT ON TABLE / COLUMN / INDEX ... IS '...'Accepted. The comment is not kept.
CREATE TEMP TABLE t AS SELECT ...A normal table with the rows (table 'tmp_ids' created with 1 row(s)).

DO blocks

The app uses DO $$ ... $$ blocks to change the schema only once:

DO $$ BEGIN
  IF to_regclass('public.forum_posts') IS NOT NULL
     AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'forum_posts' AND column_name = 'views') THEN
    ALTER TABLE forum_posts ADD COLUMN views INT NOT NULL DEFAULT 0;
    RAISE NOTICE 'column views added';
  END IF;
END $$;
table 'forum_posts' altered; NOTICE: column views added

A DO block may use:

DECLARE, PERFORM, EXECUTE, loops, RETURN and EXCEPTION handlers are refused with a message. Write a KDB procedure (chapter 13), or do the work in the Go code.

A DO block is not one transaction. Each statement is saved when it runs. If a later statement fails, the earlier ones stay. The app's blocks are written to run again safely (they check first), so this is fine for them.

Of the app's 23 DO blocks, 19 work. Two fail only because the test tool joins the lines of the Go source and a -- comment then hides the rest of the block (in the app they work). One is built by Go code at run time. One (actionsimport) compares count(*) with a value the tool cannot fill in.


Part H: Catalog views

These read-only views describe the current database. They are made when a query reads them and kept until the schema changes.

information_schemapg_catalog
tables, columns, views, schemata, sequencespg_namespace, pg_class, pg_attribute, pg_type, pg_database
table_constraints, key_column_usage, constraint_column_usagepg_index, pg_indexes, pg_constraint
referential_constraints, check_constraintspg_tables, pg_views, pg_sequences

Every table is in the schema public.

SELECT column_name, data_type, character_maximum_length AS len, numeric_precision AS p, numeric_scale AS s, is_nullable, column_default
FROM information_schema.columns WHERE table_name = 'forum_users' ORDER BY ordinal_position;
column_name | data_type                | len  | p    | s    | is_nullable | column_default
------------+--------------------------+------+------+------+-------------+---------------------------------------------
user_id     | integer                  | NULL |   32 |    0 | NO          | nextval('forum_users_user_id_seq'::regclass)
email       | character varying        |  120 | NULL | NULL | NO          | NULL
name        | character varying        |   80 | NULL | NULL | NO          | NULL
balance     | numeric                  | NULL |   12 |    2 | NO          | 100
created_at  | timestamp with time zone | NULL | NULL | NULL | NO          | now()
mobile      | character varying        |   15 | NULL | NULL | YES         | NULL
is_admin    | boolean                  | NULL | NULL | NULL | NO          | FALSE
(7 rows, 0.3 ms)
SELECT constraint_name, constraint_type FROM information_schema.table_constraints WHERE table_name = 'post_likes' ORDER BY 1;
constraint_name    | constraint_type
-------------------+----------------
post_likes_pkey    | PRIMARY KEY
post_likes_post_fk | FOREIGN KEY
post_likes_user_fk | FOREIGN KEY
(3 rows, 0.1 ms)

The views have the columns apps and tools use most. They are not complete copies of PostgreSQL's views: psql's \d and some ORMs ask for more and may still fail.


Part I: plpgsql functions and triggers

KDB runs a small part of plpgsql: a trigger function (RETURNS trigger ... LANGUAGE plpgsql) with a BEFORE UPDATE ... FOR EACH ROW trigger. This is the form of the app's two trigger functions. KDB runs the body for every changed row.

The body may use:

1. Refuse a change (user_register_block_identity_update):

CREATE TABLE user_register (user_id SERIAL PRIMARY KEY, email VARCHAR(200), referral_code VARCHAR(20));
INSERT INTO user_register (email, referral_code) VALUES ('ram@example.com', 'R1'), ('sita@example.com', 'S1');

CREATE OR REPLACE FUNCTION user_register_block_identity_update() RETURNS trigger AS $fn$
BEGIN
  IF OLD.email IS NOT NULL AND OLD.email <> '' AND NEW.email IS DISTINCT FROM OLD.email THEN
    RAISE EXCEPTION 'user_register.email is write-once (user %)', OLD.user_id;
  END IF;
  RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_register_immutable BEFORE UPDATE ON user_register
  FOR EACH ROW EXECUTE FUNCTION user_register_block_identity_update();

UPDATE user_register SET referral_code = 'R2' WHERE user_id = 1;
UPDATE user_register SET email = 'new@example.com' WHERE user_id = 2;
trigger function 'user_register_block_identity_update' created (plpgsql, run by KDB for every row a BEFORE UPDATE trigger sees)

row trigger 'trg_user_register_immutable' created on table 'user_register': it runs 'user_register_block_identity_update' for every row of a BEFORE UPDATE

1 row(s) updated
error: trigger 'trg_user_register_immutable' on table 'user_register' stopped the change: user_register.email is write-once (user 2)

The % is filled in from the row's own OLD.user_id. Nothing of the refused UPDATE is written.

2. Change the new row (ticker_alias_reset_confirmation):

CREATE TABLE ticker_alias (alias_id SERIAL PRIMARY KEY, swap_ratio NUMERIC(10,4), confirmed BOOLEAN NOT NULL DEFAULT FALSE, notes TEXT);
INSERT INTO ticker_alias (swap_ratio, confirmed) VALUES (1.5, TRUE);

CREATE FUNCTION ticker_alias_reset_confirmation() RETURNS trigger AS $$
BEGIN
  IF OLD.confirmed AND NEW.swap_ratio IS DISTINCT FROM OLD.swap_ratio THEN
    NEW.confirmed := FALSE;
    NEW.notes := COALESCE(NEW.notes || ' | ', '') || 'confirmation reset';
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_ticker_alias_reset_confirmation BEFORE UPDATE ON ticker_alias
  FOR EACH ROW EXECUTE FUNCTION ticker_alias_reset_confirmation();

UPDATE ticker_alias SET swap_ratio = 1.6 WHERE alias_id = 1;
SELECT alias_id, swap_ratio, confirmed, notes FROM ticker_alias;
alias_id | swap_ratio | confirmed | notes
---------+------------+-----------+-------------------
       1 |     1.6000 | false     | confirmation reset
(1 row, 0.2 ms)

What KDB refuses, with a clear message when you create it (nothing is accepted and then quietly not run):

You writeKDB says
AFTER UPDATE ... FOR EACH ROWAFTER ... FOR EACH ROW triggers are not supported by KDB; a plpgsql row trigger must be BEFORE (...)
BEFORE INSERT or BEFORE DELETE ... FOR EACH ROWonly BEFORE UPDATE ... FOR EACH ROW plpgsql triggers are supported by KDB; INSERT row triggers are not (...)
DECLARE, PERFORM, loops or SQL statements in the bodythe name of the part that is not supported, and what the body may use
A function that does not RETURNS trigger (LANGUAGE sql, for example)a WARNING: the function is recorded but not run. Write a KDB function or procedure for it (chapter 13).

For other work, write a KDB trigger (chapter 13). It runs once per statement and sees all changed rows in the tables OLD and NEW. A KDB trigger and a plpgsql row trigger can be on the same table.

The PostgreSQL function of the app lets a person skip the check with SET LOCAL nchartpro.allow_identity_update = 'on'. In KDB, an admin runs ALTER TABLE user_register DISABLE TRIGGER trg_user_register_immutable, makes the fix, and runs ENABLE TRIGGER again.


Part J: Times (local time and UTC)

KDB stores every TIMESTAMP in UTC (chapter 4). The NChartPro PostgreSQL server runs with TZ=Asia/Kathmandu (Nepal time, +05:45). That matters when you move data:

PostgreSQL columnWhat it holdsWhen you move it to KDB
TIMESTAMPTZAn exact moment.Export it with its offset (2026-08-25 10:08:38+05:45). KDB converts it to UTC. Nothing else to do.
TIMESTAMP (no time zone)Local wall-clock time (for this app, Nepal time). DEFAULT CURRENT_TIMESTAMP and now() wrote local time.Subtract the zone offset (5 hours 45 minutes here), or add +05:45 to the text before you insert it.
DATEA calendar day.Keep it as it is.
TIMEA clock time ("show at 10:00").Keep it as it is. It stays local clock time.
SELECT ('2026-08-25 10:08:38' || '+05:45')::TIMESTAMP AS utc;
SELECT '2026-08-25 10:08:38'::timestamp - INTERVAL '5 hours 45 minutes' AS utc;
utc
------------------------
2026-08-25T04:23:38.000Z
(1 row, 0.1 ms)

utc
------------------------
2026-08-25T04:23:38.000Z
(1 row, 0.1 ms)

After the move:

Never mix local times and UTC times in one column. Move all rows of a table the same way.


What is different from PostgreSQL

The app's startup DDL on KDB

node tests/app_compat/run_compat.mjs replays the app's startup statements: 809 for PostgreSQL and 39 for QuestDB. 802 PostgreSQL statements work (checked on 2026-09-23). The 7 that do not are faults of the test tool, not of KDB:

CountWhatWhy
32 DO blocks and 1 plpgsql function with a -- comment insideThe test tool joins the lines of the Go source, so the comment hides the rest (the function then has no END). With the app's real text, with its line breaks, they work.
1The trigger of that functionIt fails because its function failed.
1The other plpgsql triggerThe test schema still has an old hand-written KDB trigger with the same name.
2DO blocks built by Go code at run time ({{...}} parts)The test tool cannot fill in the parts.

Over the whole app (2,143 statements: reads, writes and startup DDL, PostgreSQL and QuestDB), 1,971 work (92%) on the same day.

The QuestDB statements (SYMBOL INDEX, TIMESTAMP(ts), DEDUP UPSERT KEYS, DROP PARTITION LIST) are not PostgreSQL. KDB has its own TIMESERIES form for them (chapter 10).

Older KDB programs

A database is written in a way older KDB programs understand, until you use a new feature:

New featureStored asAn older KDB program
PostgreSQL types, named UNIQUE, PRIMARY KEY (a, b), FOREIGN KEY (a, b), partial or expression indexesNew parts of the table description (T, K, F, X)Refuses to open the database: needs a newer KDB. Nothing is damaged.
SequencesA new file sequences and log recordsRefuses to open the database: needs a newer KDB.
plpgsql trigger functions and row triggersNew kinds in the file routines (feature plpgsql_triggers)Refuses to open the database: needs a newer KDB.
ALTER COLUMN TYPE, ADD PRIMARY KEYRows written again, in one transaction with the changeNormal rows.

Tables that use none of this stay in the old format. Update every kdb program before you run the app's PostgreSQL schema on a database.

Speed