18. Content data: companies, reports, news and a forum
- Category: Tutorial
What you will learn
- How to store company data, quarterly numbers, financial reports, news and forum posts in KDB.
- The
JSONtype: store flexible data, read one value, change one value. - The
BYTEStype: store hashes and small files. - Text functions that work with text in any language (the examples use Nepali).
- How triggers keep forum counters (posts, votes) correct.
- Limits, and why big files should stay outside the database.
All examples are in examples/18-content-data/. The output under each example is real. The numbers are made up.
The plan
A trading app needs more than prices. It also needs:
| Data | Table | Special types |
|---|---|---|
| Company details | companies | JSON for details that differ between companies |
| Numbers per quarter | fundamentals | DECIMAL for exact money, JSON for extra numbers |
| Financial reports | reports | JSON for the statements, BYTES for a file hash |
| News | news | text in any language, JSON tags |
| Forum | forum_categories, forum_threads, forum_posts, forum_votes | replies with REFERENCES, counters with triggers |
These are relational tables (chapter 3): each row has an id, and rows are changed often. Prices and candles stay in time-series tables.
Step 1: the company tables
01-schema.sql:
CREATE TABLE companies (
symbol STRING PRIMARY KEY,
name STRING NOT NULL,
name_ne STRING,
sector STRING NOT NULL,
listed_shares LONG,
paid_up_capital DECIMAL(2),
listed_on DATE,
profile JSON DEFAULT '{}'
);
CREATE TABLE fundamentals (
id LONG PRIMARY KEY AUTO,
symbol STRING NOT NULL REFERENCES companies(symbol),
fiscal_year STRING NOT NULL,
quarter INT NOT NULL CHECK (quarter BETWEEN 1 AND 4),
period_end DATE NOT NULL,
eps DECIMAL(2),
book_value DECIMAL(2),
net_profit DECIMAL(2),
extra JSON DEFAULT '{}'
);
CREATE UNIQUE INDEX fundamentals_one_per_quarter ON fundamentals (symbol, fiscal_year, quarter);
CREATE TABLE reports (
id LONG PRIMARY KEY AUTO,
symbol STRING NOT NULL REFERENCES companies(symbol),
fiscal_year STRING NOT NULL,
kind STRING NOT NULL,
published_at TIMESTAMP NOT NULL,
statements JSON NOT NULL,
pdf_url STRING,
pdf_sha256 BYTES
);
CREATE TABLE news (
id LONG PRIMARY KEY AUTO,
symbol STRING REFERENCES companies(symbol),
published_at TIMESTAMP NOT NULL,
lang STRING NOT NULL DEFAULT 'en',
title STRING NOT NULL,
body STRING NOT NULL,
source_url STRING,
tags JSON DEFAULT '[]'
);
table 'companies' created
table 'fundamentals' created
index 'fundamentals_one_per_quarter' created on table 'fundamentals' (0 rows)
table 'reports' created
table 'news' created
Why these types:
- Money is
DECIMAL(2), neverDOUBLE.DECIMALis exact (chapter 3). - The fiscal year is text like
'2082/83', because it is a year of a local calendar (here the Bikram Sambat year used in Nepal).period_endis the normal (AD) date, so you can sort and filter by time. CREATE UNIQUE INDEXmakes sure there is only one row per company, year and quarter. Chapter 17 explains indexes.CHECK (quarter BETWEEN 1 AND 4)refuses a wrong quarter (chapter 15).
Step 2: JSON
Some data is different for every company. A bank has branches and NPL (bad loans). A hydropower company has megawatts and rivers. Do not make 50 columns that are mostly empty. Put such data in a JSON column.
02-companies.sql:
INSERT INTO companies VALUES
('CITY', 'City Bank Limited', 'सिटी बैंक लिमिटेड', 'Banking', 270578640, 27057864000.00, '1985-07-12',
'{"website": "https://www.citybank.com", "head_office": "Kathmandu", "ceo": "A. Sharma", "branches": 270}'),
...
('VOLT', 'Volt River Hydropower Limited', 'भोल्ट रिभर जलविद्युत लिमिटेड', 'Energy', 105900000, 10590000000.00, '2019-01-21',
'{"website": "https://www.voltriverhydro.com", "capacity_mw": 456, "rivers": ["Volt River"]}');
SELECT symbol, name_ne, length(name_ne) AS letters, octet_length(name_ne) AS bytes, sector
FROM companies ORDER BY symbol;
SELECT symbol,
json_value(profile, '$.head_office') AS office,
profile ->> 'website' AS website,
json_value(profile, '$.capacity_mw') AS mw,
profile -> 'rivers' ->> 0 AS river
FROM companies ORDER BY symbol;
3 row(s) inserted
symbol | name_ne | letters | bytes | sector
-------+------------------------------+---------+-------+--------
CITY | सिटी बैंक लिमिटेड | 17 | 47 | Banking
COAST | कोस्टल बैंक लिमिटेड | 19 | 53 | Banking
VOLT | भोल्ट रिभर जलविद्युत लिमिटेड | 28 | 78 | Energy
(3 rows, 0.1 ms)
symbol | office | website | mw | river
-------+-----------+--------------------------------+------+-----------
CITY | Kathmandu | https://www.citybank.com | NULL | NULL
COAST | Kathmandu | https://www.coastalbank.com | NULL | NULL
VOLT | NULL | https://www.voltriverhydro.com | 456 | Volt River
(3 rows, 0.1 ms)
What you see:
length()counts letters (17).octet_length()counts bytes (47). A Nepali letter takes 3 bytes.- A path starts with
$.$.head_officeis the keyhead_office.$.rivers[0]is the first element of the arrayrivers. - A key that is not there gives
NULL. It is not an error.
How to read JSON
| You write | You get | Type of the result |
|---|---|---|
json_extract(doc, '$.a.b') | the part, as JSON | JSON |
doc -> 'a' -> 'b' | the same, shorter | JSON |
doc -> 0 | array element 0 (-> -1 is the last one) | JSON |
doc ->> 'a' | the part as text | STRING |
json_value(doc, '$.a') | text, a whole number (LONG), a decimal number (DOUBLE) or true/false | depends on the value |
json_value_text / json_value_double / json_value_long / json_value_bool | always this type (NULL if it does not fit) | STRING / DOUBLE / LONG / BOOL |
json_type(doc, '$.a') | object, array, string, number, boolean or null | STRING |
json_array_length(doc, '$.a') | number of elements | LONG |
json_keys(doc) | the keys, as a JSON array | JSON |
Paths: $.a.b, $.list[0], $.list[-1] (last), $.list[last], $."key with spaces", $['key'].
Step 3: numbers per quarter
03-fundamentals.sql:
INSERT INTO fundamentals (symbol, fiscal_year, quarter, period_end, eps, book_value, net_profit, extra) VALUES
('CITY', '2082/83', 2, '2026-01-13', 10.85, 181.20, 1467500000.00, '{"npl_pct": 2.91, "car_pct": 12.8, "deposits": 431200000000}'),
('CITY', '2082/83', 3, '2026-04-13', 11.40, 184.05, 2310200000.00, '{"npl_pct": 2.75, "car_pct": 13.1, "deposits": 442900000000}'),
('CITY', '2082/83', 4, '2026-07-16', 12.10, 187.60, 3270100000.00, '{"npl_pct": 2.60, "car_pct": 13.4, "deposits": 455000000000, "audited": false}'),
('COAST', '2082/83', 4, '2026-07-16', 6.35, 158.90, 1011000000.00, '{"npl_pct": 4.10, "car_pct": 12.2}'),
('VOLT', '2082/83', 4, '2026-07-16', 3.20, 102.40, 338900000.00, '{"energy_gwh": 2105.4, "plant_factor_pct": 52.7}');
SELECT fiscal_year, quarter, period_end, eps, book_value FROM fundamentals
WHERE symbol = 'CITY' ORDER BY period_end;
SELECT symbol, quarter,
json_value(extra, '$.npl_pct') AS npl,
CAST(json_value_text(extra, '$.car_pct') AS DECIMAL(2)) AS car,
json_type(extra, '$.audited') AS audited_type
FROM fundamentals WHERE fiscal_year = '2082/83' AND quarter = 4 ORDER BY symbol;
SELECT symbol, extra ->> 'npl_pct' AS npl FROM fundamentals
WHERE quarter = 4 AND json_value_double(extra, '$.npl_pct') > 3;
UPDATE fundamentals SET extra = json_set(extra, '$.audited', TRUE, '$.auditor', 'CA firm A')
WHERE symbol = 'CITY' AND quarter = 4;
SELECT json_value(extra, '$.audited') AS audited, extra ->> 'auditor' AS auditor, extra ->> 'npl_pct' AS npl
FROM fundamentals WHERE symbol = 'CITY' AND quarter = 4;
5 row(s) inserted
fiscal_year | quarter | period_end | eps | book_value
------------+---------+------------+-------+-----------
2082/83 | 2 | 2026-01-13 | 10.85 | 181.20
2082/83 | 3 | 2026-04-13 | 11.40 | 184.05
2082/83 | 4 | 2026-07-16 | 12.10 | 187.60
(3 rows, 0.1 ms)
symbol | quarter | npl | car | audited_type
-------+---------+------+-------+-------------
CITY | 4 | 2.6 | 13.40 | boolean
COAST | 4 | 4.1 | 12.20 | NULL
VOLT | 4 | NULL | NULL | NULL
(3 rows, 0.1 ms)
symbol | npl
-------+-----
COAST | 4.10
(1 row, 0.1 ms)
1 row(s) updated
audited | auditor | npl
--------+-----------+-----
true | CA firm A | 2.60
(1 row, 0.1 ms)
Notes:
json_valuegives2.6as aDOUBLE. For exact money or percent, take the text and cast it:CAST(json_value_text(extra, '$.car_pct') AS DECIMAL(2)).->>keeps the number as it was written:4.10.json_set(doc, path, value, path, value, ...)changes or adds keys. The other keys stay. Use it inUPDATE.
How to change JSON
| Function | What it does |
|---|---|
json_set(doc, path, value, ...) | adds the key, or replaces its value |
json_insert(doc, path, value, ...) | adds only; a key that is already there stays |
json_replace(doc, path, value, ...) | replaces only; a missing key is not added |
json_remove(doc, path, ...) | removes keys or array elements |
json_array_append(doc, path, value) | adds a value at the end of an array |
json_object('k', v, ...), json_array(v, ...) | builds new JSON |
The value can be text ('yes' becomes "yes"), a number, TRUE/FALSE, NULL (becomes null), or JSON (json('{"a": 1}'), or another JSON column). If the parent of the path does not exist, nothing changes.
Bad JSON is refused
04-bad-json.sql:
INSERT INTO fundamentals (symbol, fiscal_year, quarter, period_end, extra) VALUES
('COAST', '2082/83', 3, '2026-04-13', '{"npl_pct": 4.3}'),
('VOLT', '2082/83', 3, '2026-04-13', '{"energy_gwh": 1850.2,}');
error: row 2: column 'extra' (JSON): not valid JSON: a JSON object cannot end with ',' at character 23 (near '}')
The message says the row, the column and the character. Nothing was saved (also not row 1).
How KDB keeps JSON:
- It checks the text is valid JSON (RFC 8259). Keys need double quotes.
true,falseandnullare lower case. - It removes spaces between the parts.
{ "a" : 1 }is saved as{"a":1}. - It keeps the order of the keys and the digits of numbers (
2.60stays2.60). - A key written twice: a lookup finds the last one.
- JSON
nullis a value. It is not SQLNULL:profile IS NULLis false for'null'.
Step 4: reports, and BYTES
A report has statements (balance sheet, income statement). Put them in a JSON column. The PDF file stays outside the database (see Big files). KDB keeps its address and its SHA-256 hash.
05-reports.sql:
INSERT INTO reports (symbol, fiscal_year, kind, published_at, statements, pdf_url, pdf_sha256) VALUES
('CITY', '2082/83', 'quarterly', '2026-08-01T10:15:00+05:45',
'{"income": {"interest_income": 9820.5, "operating_profit": 4410.2, "net_profit": 3270.1},
"balance_sheet": {"total_assets": 612300.8, "deposits": 455000.0, "loans": 402100.3},
"unit": "USD million"}',
'https://files.example.com/city/2082-83-q4.pdf',
X'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08'),
('VOLT', '2082/83', 'quarterly', '2026-08-03T16:00:00+05:45',
'{"income": {"sales": 1520.4, "net_profit": 338.9}, "unit": "USD million"}',
'https://files.example.com/volt/2082-83-q4.pdf', NULL);
SELECT symbol,
json_value(statements, '$.income.net_profit') AS net_profit,
statements -> 'balance_sheet' ->> 'total_assets' AS assets,
json_keys(statements) AS parts,
length(pdf_sha256) AS hash_bytes,
left(hex(pdf_sha256), 16) AS hash_start
FROM reports ORDER BY symbol;
SELECT to_base64(X'00FF10') AS b64, from_base64('AP8Q') AS back, X'0102' || X'00' AS joined,
'\x6869'::BYTES AS from_text, octet_length('नेपाल'::BYTES) AS utf8_bytes;
2 row(s) inserted
symbol | net_profit | assets | parts | hash_bytes | hash_start
-------+------------+----------+-----------------------------------+------------+-----------------
CITY | 3270.1 | 612300.8 | ["income","balance_sheet","unit"] | 32 | 9f86d081884c7d65
VOLT | 338.9 | NULL | ["income","unit"] | NULL | NULL
(2 rows, 0.2 ms)
b64 | back | joined | from_text | utf8_bytes
-----+----------+----------+-----------+-----------
AP8Q | \x00ff10 | \x010200 | \x6869 | 15
(1 row, 0.1 ms)
About BYTES:
BYTESholds any bytes, also zero bytes. Good for hashes, keys, small images and signatures.- Write bytes as
X'0A0B'or as text'\x0a0b'. Other text is saved as its UTF-8 bytes. - KDB shows bytes as
\xand hex (\x00ff10), in the shell, in CSV and in the HTTP API. This is the same as PostgreSQL.'\x...'reads back as the same bytes. - On
BYTES,length()andsubstring()count bytes. - Functions:
hex(),unhex(),to_base64(),from_base64(),encode(b, 'hex' | 'base64'),decode(t, 'hex' | 'base64'), and||to join.
Step 5: news and text search
06-news.sql:
INSERT INTO news (symbol, published_at, lang, title, body, source_url, tags) VALUES
('CITY', '2026-08-01T11:00:00+05:45', 'en', 'City Bank profit rises 18%',
'City Bank reported a net profit of 3.27 billion dollars for Q4 of 2082/83.', 'https://news.example.com/1', '["results", "banking"]'),
('CITY', '2026-08-02T09:30:00+05:45', 'ne', 'सिटी बैंकको नाफा १८ प्रतिशतले बढ्यो',
'सिटी बैंकले आर्थिक वर्ष २०८२/८३ को चौथो त्रैमासिकमा ३ अर्ब २७ करोड नाफा कमाएको छ।', 'https://news.example.com/2', '["results"]'),
('VOLT', '2026-08-20T14:00:00+05:45', 'en', 'Volt River sells record energy',
'The plant produced 2105 GWh this year. Contact: info@voltriverhydro.com', NULL, '["hydro", "energy"]'),
(NULL, '2026-09-01T08:00:00+05:45', 'en', 'Market index closes at 2,840',
'The market gained 1.2% today. Banking stocks led the gains.', NULL, '[]');
SELECT id, lang, left(title, 30) AS preview, length(body) AS letters FROM news ORDER BY published_at;
SELECT id, title FROM news WHERE title ILIKE '%profit%' OR body ILIKE '%profit%' ORDER BY id;
SELECT id, title FROM news WHERE body LIKE '%नाफा%';
SELECT id, regexp_substr(body, '[\w.]+@[\w.]+\w') AS email FROM news WHERE regexp_like(body, '\w+@\w+');
SELECT to_char(published_at, 'YYYY-MM', '+05:45') AS month, lang, count(*) AS items
FROM news ORDER BY month, lang;
SELECT id, json_array_length(tags) AS tag_count, tags ->> 0 AS first_tag FROM news ORDER BY id;
4 row(s) inserted
id | lang | preview | letters
---+------+--------------------------------+--------
1 | en | City Bank profit rises 18% | 74
2 | ne | सिटी बैंकको नाफा १८ प्रतिशतले | 81
3 | en | Volt River sells record energy | 71
4 | en | Market index closes at 2,840 | 59
(4 rows, 0.1 ms)
id | title
---+---------------------------
1 | City Bank profit rises 18%
(1 row, 0.0 ms)
id | title
---+------------------------------------
2 | सिटी बैंकको नाफा १८ प्रतिशतले बढ्यो
(1 row, 0.0 ms)
id | email
---+------------------------
3 | info@voltriverhydro.com
(1 row, 0.1 ms)
month | lang | items
--------+------+------
2026-08 | en | 2
2026-08 | ne | 1
2026-09 | en | 1
(3 rows, 0.1 ms)
id | tag_count | first_tag
---+-----------+----------
1 | 2 | results
2 | 1 | results
3 | 2 | hydro
4 | 0 | NULL
(4 rows, 0.0 ms)
Notes:
left(title, 30)cuts after 30 letters, never in the middle of a Nepali letter.ILIKEisLIKEthat ignores upper and lower case of English letters. InLIKE,%is any text and_is one letter (also one Nepali letter). Write\%to find a real%.LIKE '%word%',ILIKEandregexp_likeread every row. That is fine for thousands of rows. For big tables, use full-text search with an index: chapter 17.to_char(time, format, zone)makes text from a time. Use it to group reports by month, quarter (Q) or year in a local time, such as Nepal ('+05:45'or'Asia/Kathmandu') or New York in summer ('-04:00').- Regular expressions support
\d,\w(also Nepali letters),\s,[...],(...),|,*,+,?,{n,m}. KDB never backtracks, so a bad pattern cannot hang the server.
Text functions
| Function | Example | Result |
|---|---|---|
length(s), octet_length(s) | length('नेपाल'), octet_length('नेपाल') | 5, 15 |
substring(s, from, len) | substring('नेपाल', 2, 2) | ेप |
left(s, n), right(s, n) | right('CITY', 3) | ITY |
upper(s), lower(s) | upper('city नेपाल') | CITY नेपाल |
trim(s [, chars]), ltrim, rtrim | rtrim('नेपाल।।', '।') | नेपाल |
replace(s, from, to) | replace('a-b', '-', '/') | a/b |
split_part(s, sep, n) | split_part('2082/83', '/', 1) | 2082 |
strpos(s, part), position(part IN s) | position('बैंक' IN 'नेपाल बैंक') | 7 |
starts_with, ends_with | ends_with('CITY', 'TY') | true |
lpad(s, n, fill), rpad | lpad('7', 3, '0') | 007 |
concat(a, b), a || b, concat_ws(sep, ...) | 'CITY' || '-' || 2082 | CITY-2082 |
regexp_like, regexp_substr, regexp_count, regexp_replace | regexp_replace('a1b2', '\d', '#', 'g') | a#b# |
coalesce, nullif, greatest, least | coalesce(name_ne, name) | the first that is not NULL |
a || b gives NULL when a or b is NULL. concat(a, b) skips NULL. The SQL reference (chapter 11) lists every function.
Step 6: the forum
07-forum-schema.sql (the triggers are shortened here; the file has all of them):
CREATE TABLE users (
id INT PRIMARY KEY AUTO,
name STRING NOT NULL UNIQUE
);
CREATE TABLE forum_categories (
id INT PRIMARY KEY AUTO,
slug STRING NOT NULL UNIQUE,
title STRING NOT NULL,
thread_count INT NOT NULL DEFAULT 0
);
CREATE TABLE forum_threads (
id LONG PRIMARY KEY AUTO,
category_id INT NOT NULL REFERENCES forum_categories(id),
symbol STRING REFERENCES companies(symbol),
user_id INT NOT NULL REFERENCES users(id),
title STRING NOT NULL,
created TIMESTAMP NOT NULL,
post_count INT NOT NULL DEFAULT 0,
last_post_at TIMESTAMP
);
CREATE TABLE forum_posts (
id LONG PRIMARY KEY AUTO,
thread_id LONG NOT NULL REFERENCES forum_threads(id),
parent_id LONG REFERENCES forum_posts(id),
user_id INT NOT NULL REFERENCES users(id),
body STRING NOT NULL,
created TIMESTAMP NOT NULL,
score INT NOT NULL DEFAULT 0,
meta JSON DEFAULT '{}'
);
CREATE TABLE forum_votes (
id LONG PRIMARY KEY AUTO,
post_id LONG NOT NULL REFERENCES forum_posts(id),
user_id INT NOT NULL REFERENCES users(id),
value INT NOT NULL CHECK (value IN (1, -1))
);
CREATE UNIQUE INDEX forum_votes_one_per_user ON forum_votes (user_id, post_id);
CREATE TRIGGER count_posts AFTER INSERT ON forum_posts EXECUTE BEGIN
FOR r IN (SELECT thread_id, count(*) AS n, max(created) AS last FROM NEW GROUP BY thread_id) DO
UPDATE forum_threads SET post_count = post_count + r.n, last_post_at = greatest(last_post_at, r.last)
WHERE id = r.thread_id;
END FOR;
END;
CREATE TRIGGER score_new_votes AFTER INSERT ON forum_votes EXECUTE BEGIN
FOR r IN (SELECT post_id, sum(value) AS v FROM NEW GROUP BY post_id) DO
UPDATE forum_posts SET score = score + r.v WHERE id = r.post_id;
END FOR;
END;
CREATE TRIGGER score_changed_votes AFTER UPDATE ON forum_votes EXECUTE BEGIN
FOR r IN (SELECT post_id, sum(value) AS v FROM OLD GROUP BY post_id) DO
UPDATE forum_posts SET score = score - r.v WHERE id = r.post_id;
END FOR;
FOR r IN (SELECT post_id, sum(value) AS v FROM NEW GROUP BY post_id) DO
UPDATE forum_posts SET score = score + r.v WHERE id = r.post_id;
END FOR;
END;
The ideas:
- Replies:
parent_id REFERENCES forum_posts(id). A reply points to the post it answers. The first post of a thread hasparent_idNULL. - One vote per user: the unique index on
(user_id, post_id). - Counters:
thread_count,post_countandscoreare kept by triggers. A trigger runs in the same transaction as the change. So the counter is never wrong, also after a crash. Counting on every page view (count(*)) would be slower. - Triggers run once per statement. They read all new rows from
NEWand old rows fromOLD(chapter 13). This is why they useGROUP BY.
08-forum-posts.sql:
INSERT INTO users (name) VALUES ('ram'), ('sita'), ('hari');
INSERT INTO forum_categories (slug, title) VALUES ('banking', 'Banking stocks'), ('hydro', 'Hydropower');
INSERT INTO forum_threads (category_id, symbol, user_id, title, created) VALUES
(1, 'CITY', 1, 'CITY Q4 results: good or bad?', '2026-08-01T12:00:00+05:45'),
(2, 'VOLT', 2, 'VOLT dividend this year?', '2026-08-21T09:00:00+05:45');
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created, meta) VALUES
(1, NULL, 1, 'Profit is up 18%. I think the price will go up.', '2026-08-01T12:00:00+05:45', '{"device": "android"}'),
(2, NULL, 2, 'Energy sales were a record. Dividend soon?', '2026-08-21T09:00:00+05:45', '{}');
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created) VALUES
(1, 1, 2, 'But NPL is still 2.6%. Wait for the next quarter.', '2026-08-01T12:30:00+05:45');
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created, meta) VALUES
(1, 3, 1, 'NPL went down from 2.91%. That is good news.', '2026-08-01T13:05:00+05:45', '{"edited": true}'),
(1, 1, 3, 'नाफा राम्रो छ, तर मूल्य पहिले नै बढिसकेको छ।', '2026-08-01T15:40:00+05:45', '{"lang": "ne"}');
INSERT INTO forum_votes (post_id, user_id, value) VALUES (1, 2, 1), (1, 3, 1), (3, 1, -1), (3, 3, 1), (5, 1, 1);
SELECT slug, thread_count FROM forum_categories ORDER BY id;
SELECT id, title, post_count, last_post_at FROM forum_threads ORDER BY id;
SELECT p.id, p.parent_id, u.name, p.score, left(p.body, 30) AS text
FROM forum_posts p JOIN users u ON u.id = p.user_id
WHERE p.thread_id = 1 ORDER BY p.created;
SELECT parent_id, count(*) AS answers FROM forum_posts WHERE parent_id IS NOT NULL ORDER BY parent_id;
SELECT id, score FROM forum_posts ORDER BY score DESC, id LIMIT 1;
3 row(s) inserted
2 row(s) inserted
2 row(s) inserted
2 row(s) inserted
1 row(s) inserted
2 row(s) inserted
5 row(s) inserted
slug | thread_count
--------+-------------
banking | 1
hydro | 1
(2 rows, 0.0 ms)
id | title | post_count | last_post_at
---+-------------------------------+------------+-------------------------
1 | CITY Q4 results: good or bad? | 4 | 2026-08-01T09:55:00.000Z
2 | VOLT dividend this year? | 1 | 2026-08-21T03:15:00.000Z
(2 rows, 0.0 ms)
id | parent_id | name | score | text
---+-----------+------+-------+-------------------------------
1 | NULL | ram | 2 | Profit is up 18%. I think the
3 | 1 | sita | 0 | But NPL is still 2.6%. Wait fo
4 | 3 | ram | 0 | NPL went down from 2.91%. That
5 | 1 | hari | 1 | नाफा राम्रो छ, तर मूल्य पहिले
(4 rows, 0.1 ms)
parent_id | answers
----------+--------
1 | 2
3 | 1
(2 rows, 0.1 ms)
id | score
---+------
1 | 2
(1 row, 0.0 ms)
A reply must be its own INSERT: the post it answers must already be saved. In an app this is always true, because people answer posts they can see.
A second vote is refused
09-vote-twice.sql:
INSERT INTO forum_votes (post_id, user_id, value) VALUES (1, 2, 1);
error: duplicate value violates unique index 'forum_votes_one_per_user' on table 'forum_votes': a row with user_id='2', post_id='1' already exists
In the app, show "you already voted", or change the vote with UPDATE:
10-change-vote.sql:
UPDATE forum_votes SET value = -1 WHERE user_id = 3 AND post_id = 3;
DELETE FROM forum_votes WHERE user_id = 1 AND post_id = 5;
SELECT id, score FROM forum_posts ORDER BY id;
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created)
VALUES (2, 2, 3, 'Last year they paid 10% bonus shares.', '2026-08-22T10:00:00+05:45')
RETURNING id, thread_id;
SELECT id, post_count, last_post_at FROM forum_threads ORDER BY id;
SELECT id, regexp_substr(body, '\d+(\.\d+)?%') AS percent FROM forum_posts
WHERE regexp_like(body, '\d%') ORDER BY id;
1 row(s) updated
1 row(s) deleted
id | score
---+------
1 | 2
2 | 0
3 | -2
4 | 0
5 | 0
(5 rows, 0.0 ms)
id | thread_id
---+----------
6 | 2
(1 row, 0.7 ms)
1 row(s) inserted
id | post_count | last_post_at
---+------------+-------------------------
1 | 4 | 2026-08-01T09:55:00.000Z
2 | 2 | 2026-08-22T04:15:00.000Z
(2 rows, 0.0 ms)
id | percent
---+--------
1 | 18%
3 | 2.6%
4 | 2.91%
6 | 10%
(4 rows, 0.1 ms)
- Post 3 had +1 and −1. Hari changed +1 to −1, so the score went from 0 to −2.
RETURNING idgives the new post's id at once (chapter 15).
Step 7: JSON over HTTP
The HTTP API (chapter 8) sends a JSON column as a real JSON object, not as a string. BYTES are sent as a string with \x and hex. CSV export (/exp) writes JSON as text.
11-http.ps1 (it starts a server on port 9948):
curl.exe -s "$url/exec?compat=0" --data-binary "SELECT symbol, statements -> 'income' AS income, pdf_sha256 FROM reports WHERE symbol = 'CITY'"
$r = Invoke-RestMethod -Method Post "$url/exec" -Body "SELECT symbol, statements FROM reports ORDER BY symbol"
foreach ($row in $r.dataset) { "{0}: net profit {1} {2}" -f $row[0], $row[1].income.net_profit, $row[1].unit }
curl.exe -s -G "$url/exp" --data-urlencode "query=SELECT id, tags FROM news ORDER BY id"
{"query":"SELECT symbol, statements -> 'income' AS income, pdf_sha256 FROM reports WHERE symbol = 'CITY'","results":[{"statement":"SELECT symbol, statements -> 'income' AS income, pdf_sha256 FROM reports WHERE symbol = 'CITY'","columns":[{"name":"symbol","type":"STRING"},{"name":"income","type":"JSON"},{"name":"pdf_sha256","type":"BYTES"}],"dataset":[["CITY",{"interest_income":9820.5,"operating_profit":4410.2,"net_profit":3270.1},"\\x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":32,"execute_us":289}}],"timings":{"parse_us":32,"execute_us":289}}
CITY: net profit 3270.1 USD million
VOLT: net profit 338.9 USD million
id,tags
1,"[""results"",""banking""]"
2,"[""results""]"
3,"[""hydro"",""energy""]"
4,[]
In JSON, a backslash is written twice, so "\\x9f86..." is the text \x9f86....
Other ways in and out:
kdb sql market "..." --format jsonalso writes JSON columns as objects.- The PostgreSQL protocol (chapter 14) sends
JSONas typejsonandBYTESas typebytea. - The line protocol (
POST /write) can fillJSONandBYTEScolumns from text fields:news_feed,symbol=CITY payload="{\"price\": 512.5}". Bad JSON is refused with the line number.
Big files: keep them outside
A STRING, JSON or BYTES value can be at most 16 MB. A bigger value gets this error:
column 'body' (STRING): the value is 17000000 bytes; the limit is 16 MB (16777216 bytes). Keep big files outside the database and store their path or URL
Values of 1 MB work well: through INSERT, UPDATE, SELECT, the log, checkpoints, restarts, /exec and the line protocol (tested).
For PDF files, images and videos, the best plan is:
- Save the file in a folder or an object store (for example S3-compatible storage).
- In KDB, store the path or URL (
pdf_url STRING) and the hash (pdf_sha256 BYTES). - When you read the file, check the hash.
Why: every change in KDB is written to the log and later to table files, and backups copy everything. Big files make the log, backups and restarts slow. Small things (hashes, thumbnails of a few KB, signatures) are fine in BYTES.
Other limits:
| Limit | Value |
|---|---|
| One text, JSON or BYTES value | 16 MB |
| JSON nesting | 256 levels |
| HTTP request body | 64 MB |
| Regular expression | 4096 bytes; {n,m} up to 1000; very big jobs stop with an error |
Summary
| Need | Use |
|---|---|
| Data that differs between rows | a JSON column; read with ->, ->>, json_value |
| Exact numbers from JSON | CAST(json_value_text(doc, '$.x') AS DECIMAL(2)) |
| Change one key | UPDATE ... SET doc = json_set(doc, '$.key', value) |
| Hashes, small binary data | BYTES, written X'0A0B', shown \x0a0b |
| Text in any language | normal STRING; length, left, substring count letters |
| Search in a few thousand rows | ILIKE, LIKE, regexp_like |
| Search in big tables | full-text index, chapter 17 |
| Counters (posts, votes) | triggers |
| One vote per user | CREATE UNIQUE INDEX ... (user_id, post_id) |
| Big files | outside KDB; store the URL and a hash |
Try it yourself
- Add
"dividend_pct": 10to theprofileof VOLT. - List the companies whose profile has a key
branches, with the number of branches. - Find forum posts written in Nepali (hint:
meta ->> 'lang', orregexp_like(body, '[ऀ-ॿ]')).
Answers
UPDATE companies SET profile = json_set(profile, '$.dividend_pct', 10) WHERE symbol = 'VOLT';SELECT symbol, json_value(profile, '$.branches') AS branches FROM companies WHERE json_type(profile, '$.branches') IS NOT NULL;SELECT id, body FROM forum_posts WHERE regexp_like(body, '[ऀ-ॿ]');
Next: 19. Analysis functions