SQL
Chronos runs SQL itself (its own parser and executor) and speaks the Postgres protocol, so psql, GUI tools and Postgres drivers connect as they would to Postgres.
chronos serve mydb # HTTP on 7070, Postgres protocol on 5433
psql postgres://127.0.0.1:5433/main # the database name is the branch
SQL also works in the shell (chronos mydb, end statements with ; to spread them over lines), over HTTP (POST /v1/sql with {"sql": "...", "params": [...]}), through the clients (db.sql(...)), and as the MCP tool sql.
create table users (id bigint primary key, name text not null, age integer, admin boolean default false);
insert into users (id, name, age) values (1, 'Ada', 36), (2, 'Alan', 41) returning id;
select name, age from users where age > 30 and name like 'A%' order by age desc limit 10;
update users set admin = true where id = 1;
delete from users where age is null;
Tables are the same data
A table made with CREATE TABLE stores each row as a JSON object keyed by its primary key, so get users 1, find, diff and merges see it too. A table made by writing JSON rows (put notes a {...}) has no schema: SQL sees its key as the text column id, plus every field any row has.
What works now
- Statements:
CREATE TABLE [IF NOT EXISTS],DROP TABLE [IF EXISTS] a, b [CASCADE],CREATE [UNIQUE] INDEX,DROP INDEX,INSERT(several rows, orINSERT ... SELECT),SELECT(joins and grouping below),UPDATE [AS alias],DELETE [AS alias],TRUNCATE,RETURNING,EXPLAIN [ANALYZE],COPY(see Loading and unloading),PREPARE/EXECUTE/DEALLOCATE(see Prepared statements),CREATE TYPE ... AS ENUM/DROP TYPE(see Enum types),CREATE TEMP TABLE(see Temporary tables),CREATE SCHEMA/DROP SCHEMAandSET search_path(see Schemas), and views, materialized views, sequences, LISTEN / NOTIFY, andCREATE FUNCTION/CREATE TRIGGER(see Functions and triggers). - Clauses:
WHERE,ORDER BY(by expression, name or position,ASC/DESC,NULLS FIRST/LAST; nulls sort last going up by default, as in Postgres),LIMIT,OFFSET. - Expressions:
= <> < <= > >=,AND OR NOT,IS [NOT] NULL,[NOT] IN,[NOT] LIKE / ILIKE,BETWEEN [SYMMETRIC],+ - * / %,||,CASTand::.IS [NOT] NULLof a row ((a, b) IS NULL) is true when every field is null (IS NOT NULL: none is), so a row can be neither. - Types:
integer,bigint,double precision(alsoreal),numeric(alsodecimal),text(alsovarchar),uuid(see uuid),boolean,bytea(see Binary strings),jsonb,date,time,timestamp,timestamptz,interval, pgvector'svector(see Vectors), and enums (see Enum types). Any other type name is an error (42704) naming these. numericis exact: up to 38 digits, withnumeric(p, s)rounding to its scale on every write (22003 past its precision). As in Postgres, a literal like1.5is numeric, so0.1 + 0.2 = 0.3; numeric with a float gives a float. Division keeps at least 16 significant digits (1/3.0is0.33333333333333333333), andsum/avgof numeric stay exact. Over the Postgres port it travels exactly, in text or binary; in JSON (the HTTP API,get) it's a number, so past about 15 digits the JSON view rounds.- Constraints:
PRIMARY KEY(one column or several, see Keys of several columns: integers, numeric, double precision, text, bytea, uuid, boolean, date, time, timestamps, the address types and enums; not an interval, an array or JSON),NOT NULL,DEFAULT,CHECK,UNIQUE(one or more columns;DEFERRABLEis taken,INITIALLY DEFERREDisn't),EXCLUDE(exclude using gist (room with =, during with &&), withWHERE; a statement that writes such a table reads it whole, as no index answers&&), and foreign keys (REFERENCES,FOREIGN KEY) withON DELETE CASCADE,SET NULLorRESTRICT/NO ACTION. LIKE:CREATE TABLE t (LIKE other [INCLUDING | EXCLUDING option ...], ...)copies a table's (or view's) columns, types andNOT NULL, wherever it stands among the columns;INCLUDING DEFAULTS,CONSTRAINTS(checks, with their names),INDEXES(primary key, unique constraints and other indexes, named for the new table),IDENTITY(a counter of its own),GENERATED(a generated column keeps its expression; else it is a plain column) andALLcopy more;COMMENTS,STORAGE,COMPRESSIONandSTATISTICSare read and change nothing. Foreign keys aren't copied, as in Postgres. Not forCREATE TEMP TABLE(0A000).CREATE TABLE ASandSELECT INTO:CREATE [TEMP] TABLE [IF NOT EXISTS] t [(a, b)] AS query [WITH [NO] DATA](the query aSELECT,WITHorVALUES) andSELECT ... INTO [TEMP] [TABLE] t FROM ...make a table with the query's columns (named by the list if there is one, the rest by the query) and its rows. The table is a plain one after that: no primary key, defaults or constraints (Postgres makes none either), and a column has the query's type as Chronos types it (avarchar(20)istext,numeric(10,2)anumeric). The tag isSELECT n, orCREATE TABLE ASwithWITH NO DATAor whenIF NOT EXISTSfound the table. It is all or nothing: a query that fails leaves no table. More names than columns is 42601, a repeated column name 42701, a table that is there 42P07.SELECT INTOin PL/pgSQL is the assignment to variables, as before.- Generated values:
serial,bigserialandGENERATED ... AS IDENTITYcolumns, andDEFAULT gen_random_uuid(),DEFAULT now(),DEFAULT current_dateorDEFAULT nextval('sequence')(also set later, as pg_dump does, withALTER TABLE ... ALTER COLUMN ... SET DEFAULT). A default Postgres works out afresh for each row, such asrandom(),now() + interval '7 days',nextval('s') + 1,upper(current_user)or a function of your own, is kept as its SQL and run for each row that takes it. Any other default is kept as one value, worked out when it's set (DEFAULT 60 * 60).ALTER TABLE ... ADD COLUMNwith a default that gives each row a value of its own (random(), a sequence, a function) is refused (0A000) on a table with rows; one from the clock or the date gives the rows there what it gives then, as in Postgres.DEFAULTcan also go in aVALUESlist, and inUPDATE ... SET col = DEFAULTandON CONFLICT DO UPDATE SET col = DEFAULT(the counter's next number, the clock, a new uuid, the default the column was made with, or null). - Changing tables:
ALTER TABLEcan add, drop and rename columns, rename the table, change a column's type, and set or drop its default orNOT NULL, several changes in one statement.ALTER TABLE t RENAME CONSTRAINT a TO brenames a CHECK, primary key, unique or foreign key constraint (the violation message,pg_constraint, its comment and an inheriting child's copy of a CHECK follow; a key's or unique constraint's index is renamed with it). A missing name is 42704; a taken one is 42710, or 42P07 when an index or other relation has the name a key or unique constraint is given, as in Postgres. A renamed table keeps its primary key's name, and a new key is<table>_pkey1,<table>_pkey2, ... when a relation or constraint of the schema has<table>_pkey, as in Postgres. - Parameters:
$1, $2, ..., with types inferred from use as Postgres does. - Errors: Postgres SQLSTATE codes (23505 duplicate key, 42P01 no such table, ...). A constraint violation has Postgres's message and fields (
constraint,table,column,schema,detail), soerr.constraint === 'bookings_unit_uq'works; Chronos adds the row it is about in thehint(Row bookings/7 already has this key.), which the HTTP API and MCP show too.
Outside a transaction each statement commits on its own, durably on main (see concepts).
Joins, grouping and functions
select c.name, count(o.id) as orders, sum(o.total)
from customers c left join orders o on o.customer = c.id and o.status = 'paid'
where c.city = 'London'
group by c.name
having count(o.id) > 0
order by orders desc;
- Joins:
[INNER] JOIN,LEFT,RIGHTandFULL [OUTER] JOIN, eachON ...,USING (cols)orNATURAL(joined on every column both sides have, shown once, asUSINGdoes; none in common is a cross join),CROSS JOIN, andFROM a, b. Tables can have aliases (orders o), columns can be qualified (o.total), andt.*selects one table's columns.USING: as in Postgres,*shows each USING column once and first, and a bare name means the joined value (forFULL, the first non-null of the two sides).a.xandb.xstill name each side.RIGHTandFULL: every row of the right table appears, matched or padded with nulls (and forFULL, every left row too). Chronos reads the right table whole and hashes it on an equality inON; without one, it compares every pair.- Join order: with only inner joins, Chronos starts from the table its conditions narrow most (a key lookup, or the fewest index matches), then joins linked tables, key links first. Outer joins keep the written order.
- Few rows so far (up to 1,000): a join on the next table's primary key reads just the matching rows. A join on another integer, text or boolean column looks each value up in that table's index.
- Otherwise, equality joins use a hash join, and joins on anything else compare every pair.
- Conditions on one table are applied, and use its index, as it's read.
- Set-returning functions in the select list:
SELECT unnest(tags), generate_subscripts(a, 1) ...and, inside an expression, any ofgenerate_series,unnest,jsonb_array_elements(_text),jsonb_object_keys,regexp_split_to_tableandgenerate_subscripts(generate_series(1, 3) * 2,upper(unnest(names)),jsonb_array_elements(doc -> 'items') ->> 'sku'): a row per element, several side by side (the shorter padded with nulls). Not inWHERE,GROUP BY,HAVINGorON(0A000), and not yet in a grouped query, where Postgres runs them after the grouping. A lone jsonb value is an array of one, so'1'::jsonb -> 0is1. WITH ORDINALITY: a set-returning function inFROM(unnest,generate_series,jsonb_array_elements,jsonb_each,regexp_split_to_table, ...) may sayf(...) WITH ORDINALITY [AS] alias(a, n): its rows numbered from 1 in a bigint column after its own (ordinality, unless the alias's column list names it). After a table it is read for each of the table's rows.||by type: text with anything concatenates ('a' || 1,1 || '2'), arrays append, and beside a jsonb an unknown literal reads as jsonb (payload || '{"k":1}'merges,'[3]'appends). Two typed numbers or booleans are 42883 as in Postgres.jsonb_array_elements(x) AS inames its columni.- Row values:
(a, b) IN (SELECT x, y ...),(a, b) = ANY | ALL (SELECT ...)and(a, b) = (SELECT x, y ...)(and the other comparisons) compare row by row with SQL's rules for nulls;UPDATE t SET (a, b) = (1, 'x')andSET (a, b) = (SELECT ...)assign several columns (a subquery is asked once per column). A row is also a value, held as the text Postgres writes a composite as:select t from t,select row(1, 'a b')((1,"a b")),count(t),t IS NULL,t = t,(t).col,row(t.*). Not yet:pg_typeofof one (text, not record), a column of composite type,(select t)of an outer table,(row_col).fieldof a subquery's row. - Choosing rows:
SELECT DISTINCT ON (a, b) ... ORDER BY a, b, ckeeps the first row (byORDER BY) of each set with the same a and b; theONexpressions thatORDER BYnames have to lead it (42P10 otherwise), as in Postgres. Not yet withGROUP BY, aggregates or window functions.FETCH FIRST | NEXT [n] ROW | ROWS ONLYisLIMIT n, withOFFSET n ROWSbefore or after;FETCH FIRST n ROWS WITH TIES(which needs anORDER BY, 42601 otherwise) also gives the rows that tie with the last on its keys; it reads the whole ordered result, so it does not get aLIMIT's top-n shortcut.TABLE tisSELECT * FROM t(also in a subquery, a UNION,CREATE TABLE ASandINSERT). AWITHquery may sayMATERIALIZEDorNOT MATERIALIZED; it is read and ignored, as its rows are the same either way. - Grouping sets:
GROUP BY ROLLUP (a, b),CUBE (a, b),GROUPING SETS ((a), (b), ())(nested, and multiplied by otherGROUP BYitems:GROUP BY a, ROLLUP (b)),GROUP BY DISTINCTto drop repeated sets, andGROUPING(a, b)for which columns a row's set left out (the first argument the highest bit). It runs as the same query once per set withUNION ALL, a column outside a set as NULL:ORDER BYandLIMITapply to all of it, and can name output columns, positions, or an expression the select list has (order by sum(qty)). Not yet withDISTINCT(0A000) or a window function. - Ordered-set aggregates:
percentile_cont(f) WITHIN GROUP (ORDER BY x)(interpolated, as double precision;fmay be an array of fractions),percentile_disc(f)andmode(), withFILTER. Nulls aren't in the distribution;foutside 0 to 1 is 22003. Not yet: intervals inpercentile_cont, and the hypothetical-set aggregates (rank(x) WITHIN GROUP ...). - Row values:
(a, b) < (c, d), and=,<>,<=,>,>=,ROW(a, b)and(a, b) IN ((1, 2), (3, 4)), compared as Postgres compares them (by the first pair that differs, nulls as in Postgres): keyset pagination,where (created_at, id) < ($1, $2) order by created_at desc, id desc limit 20. They work asa < c OR (a = c AND b < d), so an index on the first column serves them and the rest is checked. A row is only ever compared, not selected or stored. - Operators:
^(a power, as double precision:2 ^ 3 ^ 2is 64, and0 ^ -1is 2201F), the bitwise&,|,#(exclusive or),~(not; its operand is all the arithmetic after it, so~5 + 1is~(5 + 1)),<<and>>, andx IS [NOT] TRUE | FALSE | UNKNOWN, which bind as in Postgres (ISlooser than the comparisons:x = y IS TRUE). Integers are 64-bit here, so a shift past 32 bits doesn't wrap asint4does. pg_trgm,fuzzystrmatch,unaccent:similarity(a, b),word_similarity,strict_word_similarity,show_trgm(text)and the operators%(similar),<%,%>,<<%,%>>(word similarity, either way round) and the distances<->,<<->,<->>,<<<->,<->>>between two texts (pg_trgm, at the default thresholds 0.3, 0.6 and 0.5);levenshtein(a, b[, ins, del, sub]),levenshtein_less_equal,soundex,difference,metaphone(s, n),dmetaphone,dmetaphone_altanddaitch_mokotoff(fuzzystrmatch);unaccent([dictionary,] text)with the default rules. They are built in, soCREATE EXTENSION pg_trgmis accepted and changes nothing. The results are pg_trgm's own algorithms (checked against Postgres 16 on 138,000 generated cases);similarityand the distances arerealin Postgres and print with a float4's digits here, though they are held as double precision.SET pg_trgm.similarity_thresholdisn't read by%yet. Aginorgistindex withgin_trgm_opsorgist_trgm_opsserves%,LIKEandILIKE(see Indexes).pgcrypto:digest(data, 'sha256')andhmac(data, key, 'sha256')(md5, sha1, sha224, sha256, sha384, sha512, ripemd160; of text or bytea), password hashes withcrypt(password, gen_salt('bf'))(and'md5','xdes','des';gen_salt('bf', 10)for more rounds; check one withcrypt(password, hash) = hash),gen_random_bytes(n)(1 to 1024, from the system's generator),encrypt(data, key, 'aes')anddecrypt(aes,bf,cast5,des,3des;-cbcor-ecb;/pad:pkcsor/pad:none),encrypt_ivanddecrypt_iv, OpenPGP messages withpgp_sym_encrypt(text, password [, 'cipher-algo=aes256, compress-algo=1'])andpgp_sym_decrypt(and_byteaforms for bytes),armoranddearmor, andpgp_key_id. Each gives what Postgres's pgcrypto 1.3 gives, so hashes and messages move between the two both ways. They are built in;CREATE EXTENSION pgcryptolists it inpg_extension. The public-key functions (pgp_pub_encrypt,pgp_pub_decrypt) andpgp_armor_headersaren't here yet; the rest of what differs is in Postgres compatibility.jsonb_to_record(j)/jsonb_to_recordset(j)(andjson_to_record,json_to_recordset): a JSON object, or array of objects, as rows by the column definition list that follows it inFROM:jsonb_to_recordset(o.items) AS i(sku text, qty int). A key not in the list is ignored, a missing one is NULL, and each value is cast to the column's type (withnumeric(p,s)andvarchar(n)limits). Without a list it is 42601;jsonb_populate_record(composite types) is 0A000.to_number(text, template)with every numeric template pattern (9 0 . , D G S MI PL SG PR L V RN TH FM;EEEEis refused as in Postgres), numbers beyond 38 digits aside.- Domains:
CREATE DOMAIN name [AS] type [DEFAULT e] [NOT NULL] [CHECK (... VALUE ...)]andDROP DOMAIN [CASCADE]: a column of the domain is its base type with the domain's NOT NULL, DEFAULT and CHECKs (the column's own DEFAULT wins). A cast to a domain (x::posint) is to its base type without the checks,pg_typeofsays the base type, and there is noALTER DOMAIN. ORDER BY x USING < | >(ascending, descending: the btree operators), andunnest(a, b, ...)in FROM: a column for each array, side by side, the shorter padded with nulls.- ALTER options accepted as no-ops (with a notice):
REPLICA IDENTITY,CLUSTER ON,SET WITHOUT CLUSTER,ALTER COLUMN ... SET STATISTICS | STORAGE | COMPRESSION | (options)andRESET (...), and a function's attributes (ALTER FUNCTION f(int) IMMUTABLE | SECURITY DEFINER | COST n | SET ...). - Constraint options:
UNIQUE NULLS NOT DISTINCT (a)(also onCREATE UNIQUE INDEXand inpg_get_indexdef),FOREIGN KEY ... MATCH FULL(all columns null or none;MATCH PARTIALis 0A000),CREATE TEMP TABLE ... ON COMMIT DELETE ROWS | DROP | PRESERVE ROWS. Not here:DEFERRABLEunique constraints (a unique key is claimed as the row is written). TABLESAMPLE:FROM t [alias] TABLESAMPLE BERNOULLI|SYSTEM (percent) [REPEATABLE (seed)]: each row is kept with that chance (both methods a row at a time; Postgres's SYSTEM takes whole pages), the same seed keeps the same rows.generate_series(0, 1, 0.25)takes a numeric step.- Array assignment:
UPDATE t SET a[2] = v,SET a[1] = x, a[2] = yandSET a[lo:hi] = '{..}'(a place past the end grows the array with nulls; one before the start moves its lower bound). One subscript only. - Small syntax: array slices
a[2:3],a[:2],a[2:](as Postgres clamps them: past the ends is fewer elements, none is{}; a subscript after a slice is 0A000, where Postgres would read a second dimension); the prefix operators@ x(absolute value),|/ x(square root) and||/ x(cube root, alsocbrt(x)), taking all the arithmetic after them as~does; a type's name as a function,int4('1'),int8,int2,float4,float8,text(5),bool('t')anddate('2024-01-02'), which cast;make_interval(years, months, weeks, days, hours, mins, secs);x COLLATE "de-x-icu"(see Collations); and named arguments,make_interval(days => 1, hours => 2)or:=, and for functions made with CREATE FUNCTION,f(b => 2, a => 1)(by position first, then names; each parameter once; a parameter left out takes its default). Other built-ins don't take names (42883). - A series in the select list:
select generate_series(1, 3)andgenerate_series(a, b)::date(one per query; several set-returning functions at once are onlyunnestandgenerate_subscripts). - Aggregates (each can take
FILTER (WHERE ...)):count(*),count(x),count(distinct x),sum,avg,min,max,bool_and/every,bool_or, andstring_agg(x, sep),jsonb_agg,jsonb_object_agg(k, v),array_agg(as Postgres's array text,{1,2}), each with its ownORDER BY(string_agg(name, ', ' order by name)) andDISTINCT, withGROUP BY(expressions, output names or positions),HAVINGandSELECT DISTINCT. A column that's neither grouped nor aggregated is an error (42803), as in Postgres, unless its table's whole primary key is grouped (select id, name, count(*) from users group by id: the other columns follow from the key; a derived table or view has no key). OnINSERT ... ON CONFLICT DO UPDATE,RETURNING (xmax = 0) AS insertedis true for a row the statement made and false for one the conflict changed;xmin,xmax,cminandcmaxread 1, 0, 0, 0 elsewhere. - Statistical aggregates:
stddev,stddev_pop,stddev_samp,variance,var_pop,var_samp(integers and numerics give a numeric at the scale Postgres picks, floats a double precision), andcorr,covar_pop,covar_samp,regr_count,regr_avgx,regr_avgy,regr_sxx,regr_syy,regr_sxy,regr_slope,regr_interceptandregr_r2(each(dependent, independent), double precision). They work as Postgres's do, so the digits agree, including the last ones: floats accumulate asfloat8_accumdoes, integers and numerics through numeric arithmetic (a sum of squares past 38 digits is 22003). A row with a null argument is left out; no rows, and one row for the sample forms, is null (regr_countof no rows is 0).FILTER,DISTINCT(one argument) and anORDER BYinside work; anything that is not a number is 42883. Not for arealcolumn: it is held as a double here, so its values are not rounded to four bytes first, and astddevover one can differ from Postgres's in the last digits. - Aggregates as window functions:
string_agg,array_agg,jsonb_agg,jsonb_object_aggand the statistical aggregates takeOVER (...)with any frame, assumandavgdo (string_agg(name, ',') over (partition by team order by id),stddev(x) over (order by ts rows between 6 preceding and current row)).DISTINCTand an aggregateORDER BYinside one are 0A000, as is an ordered-set aggregate (percentile_cont ... over) in Postgres. - Functions, with Postgres's names, NULL rules and edge cases:
- Text:
lower,upper(one character for one, as Postgres's locale functions do:upper('ß')isß),length/char_length/character_length,octet_length,concat,concat_ws,substring(alsosubstring(s from a for b)) andsubstr,position(a in s)andstrpos,replace,trim(alsotrim(leading 'x' from s)),btrim,ltrim,rtrim,left,right,lpad,rpad,repeat,reverse,translate,split_part,initcap,ascii,chr,unistr(\0041,\+01F600,\u0041,\U00000041), the literalU&'d\0061t'(withUESCAPE '!'),starts_with;bpcharis read aschar, without the padding. - Math:
abs,round(x[, places]),ceil,floor,trunc(x[, places]),sign,mod,power,sqrt,exp,ln,log(x)/log(b, x),pi,random,setseed(see Simulations),greatest,least, the trigonometricsin,cos,tan,cot,asin,acos,atan,atan2,sinh,cosh,tanh,degreesandradians(doubles),div(a, b)(numeric, cut toward zero),gcd,lcm,factorial(up to what 38 digits hold),width_bucket(x, low, high, count)andto_hex.ceil,floor,truncandsignof an integer are double precision, as in Postgres;roundof a double rounds halves to the even number (round(2.5::float8)is2,round(2.5)numeric is3). Integer literals may be written0x1F,0o17and0b101(underscores between digits allowed), and1::boolean/true::intcast between the two (int4 only).bit_and,bit_orandbit_xorof integers andany_value(x)are aggregates. Not yet:sind/cosd/tand,asinh/acosh/atanh, and numeric (exact)sqrt,exp,lnandpower(they give doubles). - NULLs:
coalesce,nullif,num_nulls(...)andnum_nonnulls(...). - Sizes:
pg_size_pretty(bigint | numeric)andpg_size_bytes('1.5 MB')as Postgres writes and reads them, andpg_relation_size(rel [, fork]),pg_table_size,pg_indexes_size,pg_total_relation_sizeandpg_database_size(name). The byte counts are estimates in Postgres's units (whole 8 kB pages of the rows and index entries Chronos stores, so a table of 1,000 rows of 100 bytes comes out near what Postgres says), not what Chronos's files take, and reading them reads the table. An index takes the name of the index (pg_relation_size('t_pkey')), a view has no storage (0), a sequence is one page.pg_database_sizeis the bytes of the database's files, shared by every world. psql's\dt+,\di+,\ds+,\dv+and\l+show them. - The session and the server:
version()(PostgreSQL 16.15 (Chronos 0.1.3) on ...: clients read the leadingPostgreSQL 16.15),current_user,session_userandcurrent_role(the name a Postgres client logged in with, or an agent's name, untilSET ROLEorSET SESSION AUTHORIZATIONchanges them; aSECURITY DEFINERfunction'scurrent_useris its owner: see Roles and privileges),current_catalog,current_schema,pg_backend_pid()(the connection's id, also whatpg_cancel_backend(pid)takes to stop that connection's statement, and what a NOTIFY carries),pg_is_in_recovery()(false),pg_postmaster_start_time(),txid_current()(one number for a transaction),inet_server_addr()and the otherinet_*functions (null, as over a Unix socket),pg_sleep(seconds)(stops when the statement is canceled or times out),pg_encoding_to_char(n), andhas_table_privilegeand its kin (Postgres compatibility). - Dates: see below, plus
make_date(y, m, d),to_timestamp(epoch seconds),age(a[, b])(years, months and days by the calendar, as Postgres counts them) andto_char(ts, 'YYYY-MM-DD HH24:MI')(Postgres's date and time patterns:YYYY,MM,Mon,Month,DD,Day,Dy,HH12,AM,MI,SS,MS,US,Q,DDD,FM,"literal"). - Numbers as text:
to_char(n, 'FM9,999.00')with Postgres's number patterns:9,0,./D,,/G,S,MI,PL,SG,PR,FMand"literal". - Regular expressions:
s ~ 'pattern'(~*ignoring case,!~and!~*negated),regexp_replace(s, pattern, replacement[, 'gi'])(with\1and\&),regexp_match,regexp_like,regexp_count,regexp_substr,regexp_instr(all with Postgres's start, N, flags, end-option and sub-expression arguments;regexp_replace(s, p, r, start, N, flags)too),string_to_table,regexp_split_to_array, and in FROMregexp_split_to_tableandregexp_matches.s LIKE 'pattern' ESCAPE 'c'(andNOT LIKE,ILIKE;ESCAPE ''turns escaping off; the operators~~,!~~,~~*,!~~*, and^@for a prefix;LIKE,ILIKE,~and~*withANYorALLover an array or subquery),substring(s from 'regex')(its first parenthesized part, or the whole match),substring(s from 'pattern' for 'esc')andsubstring(s similar 'pattern' escape 'esc')(the part between the escape-double-quotes),s SIMILAR TO 'pattern' [ESCAPE 'c'](andNOT SIMILAR TO), with%,_,|,*,+,?,{n,m},()and[...]. Postgres's advanced syntax (src/sql/regex.rs, checked against Postgres 16 on thousands of random patterns): classes ([a-z],[[:alpha:]],[[.a.]],[[=a=]],\d \w \sand\D \W \S, also inside brackets), anchors and word edges (\A \Z \y \Y \m \M,[[:<:]],[[:>:]]), groups,(?:...), alternation, greedy and lazy quantifiers ({n,m}up to 255), lookaround ((?=...),(?!...),(?<=...),(?<!...); their parentheses capture nothing), back references (\1, a lone digit is always one,\10only with ten groups open, else octal; refused inside lookaround and before the group closes), the escapes\t \n \e \cX \xhhh \uhhhh \Uhhhhhhhh \ooo, and the options: embedded at the start ((?i),(?n),(?p),(?w),(?s),(?c),(?x),(?q),***=,***:) or as flag letters (b c e g i m n p q s t w x). Of the matches at the leftmost place it takes the longest (the shortest when the first quantifier is lazy), as Postgres's engine does. A quantifier after an anchor or lookaround, or a back reference to a group that does not exist, is 2201B as in Postgres. Known differences: a lazy quantifier inside an alternation under a counted repeat,(x)+wherexcan match nothing, andregexp_matchesbeside other set-returning functions in the select list. A pattern that would backtrack for too long stops with 54001. - Arrays:
text[],integer[]and other array columns (stored as Postgres's array text),ARRAY[...],ARRAY(SELECT x ...)(the rows of one column,{}for none; a plain query may read the query around it),a @> b,a <@ banda && b(contains, is contained by, overlaps: by element, a null equal to none;@>and<@are jsonb's when neither side is an array),a[i](from 1; outside the array: null),a[lo:hi]slices,||(array with array, or with an element on either side),array_append,array_prepend,array_cat,array_remove,array_replace,array_position,array_positions,array_dims,array_ndims,array_fillandtrim_array(one dimension: a multi-dimensionalarray_fillis 0A000),string_to_array,array_to_string(a, sep[, null_string]),unnest,= ANY(...). (array_to_stringof a boolean array writestandf, nottrueandfalse: an array is text here, so its elements' types aren't known.)jsonbtakes subscripts too:doc['key'],doc['list'][0]. - Ranked search:
search('table', 'words' [, k])in FROM: the built-in text index's best k rows (BM25, typo-tolerant), asid,score,row(jsonb). See search. - Full-text search, as Postgres does it:
to_tsvector([config,] text),to_tsquery,plainto_tsquery,phraseto_tsquery,websearch_to_tsquery,@@,ts_rank([weights,] vector, query [, normalization]),setweight,strip,tsvector || tsvector, andtsvector/tsquerycolumns and casts. Theenglish(the default) andsimpleconfigurations match Postgres's: its parser, stop words and Snowball stemmer, checked word for word on 20,000 dictionary words.text @@ textandtext @@ tsquerywork as in Postgres.CREATE INDEX ... USING gin (to_tsvector('english', body))(or on atsvectorcolumn) keeps an entry per lexeme:@@with the same expression reads the rows its query's lexemes allow (&intersects,|unites,:*reads a range) and checks just those, 12 times faster than a scan at 100,000 rows. As in Postgres, the index's expression must match the query's (config included). Without one,@@computes each row's vector as it scans. Not yet:ts_headline,ts_rank_cd, and the parser's file-path and version tokens. - Points, as PostGIS does them:
geometryandgeographycolumns and casts (hex EWKB, WKT orSRID=4326;POINT(lon lat)),ST_MakePoint,ST_Point,ST_SetSRID,ST_SRID,ST_GeomFromText,ST_GeogFromText,ST_GeomFromGeoJSON,ST_AsText,ST_AsEWKT,ST_AsGeoJSON,ST_X,ST_Y,ST_Distance(meters on the WGS84 ellipsoid for geography, the exact geodesic, or on PostGIS's sphere withfalse),ST_DWithinandST_DistanceSphere, checked against PostGIS 3.6. Values print as PostGIS prints them, and clients receive them as text.CREATE INDEX ON places USING gist (location)makesST_DWithin(location, point, distance)read only the band of latitudes (or y) the distance can reach, then check those rows: a 1 km search among 200,000 points worldwide reads about 20 rows.a <-> bis PostGIS's distance operator (planar for geometry, on the sphere for geography), andORDER BY location <-> point LIMIT k(orORDER BY ST_Distance(location, point)) reads nearest first from the index, widening its search until the LIMIT fills: the nearest 10 of 200,000 points take about 1 ms.CREATE EXTENSION postgisis accepted. Not yet: lines and polygons, andST_Transform. - Other:
md5(text),format(fmt, ...)(%s,%Ifor identifiers,%Lfor literals,%%, positions%2$s, widths%-10sand%*s),quote_ident,quote_literalandquote_nullable, as Postgres writes them. - jsonb:
->and->>(a field or array element, as jsonb or text),#>and#>>(a path,'{a,b,0}'),@>and<@(containment),?,?|,?&(keys),||(merge),-(a key, an index, or atext[]of keys),#-(delete at a path),jsonb_insert,jsonb_pretty,jsonb_set_lax, andjsonb_build_object,jsonb_build_array,to_jsonb,row_to_jsonandarray_to_json(a table's name, alias ort.*into_jsonb(t),json_agg(t)orjsonb_build_object('k', t)is its row as an object; arrays are JSON arrays),jsonb_typeof,jsonb_array_length,jsonb_extract_path(_text),jsonb_set,jsonb_strip_nulls(json_names too). jsonb prints as Postgres prints it:{"a": 1, "bb": [1, 2]}, shorter keys first. - Functions made with
CREATE FUNCTIONtoo: see Functions and triggers. - An unknown function is error 42883, as in Postgres, and the message lists the functions there are.
- Text:
Unique constraints and foreign keys
create table users (id serial primary key, email text unique, org int references orgs on delete cascade);
alter table posts add constraint posts_author_fkey foreign key (author) references users on delete set null;
- When they're checked: at every statement, and again at every merge, including a transaction's
COMMIT. Two branches can each be valid and still clash together: the same email on both, or one side deleting a row the other side's new row references. The merge then fails with 23505 or 23503, merges nothing, and says which rows clash. That holds even when you force a side withUSING OURS/THEIRS. - How they're stored: each unique value and each reference is a small index row, so a check is one lookup (about 1 µs), not a scan. Those rows don't show in
DIFF. - Changing them:
ALTER TABLE ... ADD CONSTRAINTchecks existing rows first.DROP CONSTRAINTremoves one, andDROP COLUMNandDROP CONSTRAINTtakeCASCADE(the foreign keys that reference what goes are dropped too, with a NOTICE; without it they are refused with 2BP01). Constraints follow renamed tables and columns, and a table others reference can't be dropped. - Several columns:
FOREIGN KEY (a, b) REFERENCES treferences a primary key of several columns (naming its columns in any order), withMATCH SIMPLE's rule: a row with a null among them references nothing. - Deferrable foreign keys:
DEFERRABLE,NOT DEFERRABLE,INITIALLY DEFERRED(which impliesDEFERRABLE) andINITIALLY IMMEDIATEonREFERENCESandFOREIGN KEY, as Django writes on every foreign key. In a transaction, a deferred key isn't checked when its row is written (or its parent deleted) but when the transaction ends, so children can come before parents; a row that fails then fails theCOMMIT(23503) and the transaction is rolled back.SET CONSTRAINTS ALL | name, ... DEFERRED | IMMEDIATEchanges that for the rest of the transaction (IMMEDIATEchecks what was put off at once), andALTER TABLE ... ALTER CONSTRAINT name [NOT] DEFERRABLE [INITIALLY DEFERRED | IMMEDIATE]changes it for good. Outside a transaction a statement is its own commit, so nothing is put off.RESTRICTis checked at once, andCASCADE,SET NULLandSET DEFAULTact at once, as in Postgres. Unique and primary keys can't be deferred (0A000). - Actions:
ON DELETEandON UPDATEeach takeNO ACTION(the default),RESTRICT,CASCADE,SET NULLandSET DEFAULT. Changing the primary key of a row others reference (UPDATE p SET id = 5) is refused (23503) unlessON UPDATEsays otherwise:CASCADEgives the referencing rows the new key (a reference that is part of their own key moves them, and their children in turn),SET NULLandSET DEFAULTset the reference to null or to its column's default (which must then be a row of the parent, or the statement fails with 23503). An update that leaves the key as it was never touches the referencing rows.SET NULLcan't apply to a primary key column (42601), and a table with row triggers can't be the target of an actingON UPDATEorON DELETE(0A000). - To a UNIQUE column:
REFERENCES t (col)andFOREIGN KEY (a, b) REFERENCES t (x, y)may name aUNIQUEconstraint's columns (or a plain unique index's, in any order) as well as the primary key's; a partial or expression unique index can't be referenced (42830, as in Postgres). The referencing row's values are looked up by the unique entry, a change of the referenced value acts asON UPDATEsays, and a merge that leaves a row pointing at a value the other world deleted fails with 23503. The unique constraint or index, and its columns, can't be dropped while a foreign key uses it (2BP01), and its type can't change except betweenintegerandbigint(0A000). - Not yet:
MATCH PARTIAL(0A000).MATCH FULLworks. - JSON writes too: agents'
put,deleteandbatch(shell, HTTP, MCP, Rust) on a SQL table are checked like anINSERT. See JSON writes into SQL tables.
JSON writes into SQL tables
Agents writing JSON rows (put, delete, batch, over the shell, HTTP, MCP or Rust) into a table made with CREATE TABLE get the same rules as SQL, in the same transaction-like batch:
- Columns: the row must be a JSON object of the table's columns (an unknown column is 42703) with values that fit their types (22P02). A missing column gets its default, a
serialnumber or a UUID.NOT NULLholds (23502). - Keys: the key names the row.
put users/5setsidto 5, and anidthat disagrees with the key is refused (23514). - Constraints:
UNIQUEand foreign keys hold (23505, 23503), and a delete followsON DELETE. - Storage: the row is stored packed, and read back as JSON.
- HTTP status: a row that doesn't fit is a 400; a clash is a 409.
- Reserved names: keys and tables starting with
_sonosare the database's own bookkeeping (schemas, constraint indexes, agents). Nobody can read or write them with SQL or JSON writes, or create or rename a table to such a name.
Tables made by writing JSON (no CREATE TABLE) stay free-form. Until a SQL table exists, JSON writes skip these checks entirely.
- Triggers: JSON writes to a table with triggers turned on are refused (0A000): triggers fire for SQL's
INSERT,UPDATEandDELETEonly, and a write that skipped them would leave their work undone.ALTER TABLE ... DISABLE TRIGGERlets JSON writes in again.
Upserts and CASE
insert into stock (sku, qty) values ('a', 3)
on conflict (sku) do update set qty = stock.qty + excluded.qty
returning *;
select name, case when total >= 100 then 'big' when total >= 10 then 'mid' else 'small' end
from orders;
ON CONFLICT:DO NOTHING, orDO UPDATE SET ... [WHERE ...], on the primary key or anyUNIQUEconstraint: named by its columns in any order,ON CONFLICT (room, num), or by its name,ON CONFLICT ON CONSTRAINT seat_room_num_key. A partial unique index is named by its columns and aWHEREthat implies the index's own,ON CONFLICT (n) WHERE status = 'live'(each of the index's ANDed conditions written in theWHERE; more may be added); one it doesn't imply is 42P10, as in Postgres.DO NOTHINGwithout a target skips any clash.excluded.colis the row that was to be inserted, and bare column names mean the existing row.INSERT INTO t AS aliasworks.- Columns
SETdoesn't name keep their values.
CASE: both theCASE WHEN ...andCASE value WHEN ...forms, anywhere an expression goes, including inside and around aggregates.
Numbering rows
A serial column (and GENERATED ... AS IDENTITY) takes the next number from a counter that belongs to the database, not to any branch, so rows inserted on two branches at once never collide when they merge. Taking a number writes nothing to any branch: inserts in a fork leave main's rows and version as they were. As with Postgres sequences:
- Not taken back: a rollback or a failed insert still uses its number.
- Gaps: a restart can skip numbers, because numbers are reserved 64 at a time. A branch sees gaps where other branches took numbers too: after a fork inserts 3 rows, main's next row may be 5, not 2.
- Never twice: the counters are logged and kept in every checkpoint, so a restart or a crash never hands a number out again. Time travel doesn't rewind them: restoring a world to the past leaves the counters where they are.
CREATE SEQUENCE makes a named one, and DEFAULT nextval('name') numbers a column from it (see Sequences).
A serial or identity column's counter has the name Postgres gives its sequence, table_column_seq (app.t_id_seq in a schema), which pg_get_serial_sequence('t', 'id') returns, and nextval, currval, setval and lastval take: select setval(pg_get_serial_sequence('t', 'id'), coalesce(max(id), 1), max(id) is not null) from t after loading rows with their own ids, as Django and Rails do, moves the counter, and the next insert takes the number after it. The counter is a sequence, listed as one: \ds, pg_class (relkind S), pg_sequence, pg_sequences and information_schema.sequences show t_id_seq, select last_value, is_called from t_id_seq reads where it is (what pg_dump reads to write its setval), and a serial column's default is nextval('t_id_seq'::regclass). ALTER SEQUENCE doesn't reach it. INSERT INTO t DEFAULT VALUES makes a row of defaults.
Identity columns keep which kind they are. GENERATED ALWAYS AS IDENTITY refuses a value written to the column (INSERT and UPDATE fail with 428C9) unless the INSERT says OVERRIDING SYSTEM VALUE; OVERRIDING USER VALUE takes the counter's number whatever was given; BY DEFAULT takes either. COPY fills either kind, as Postgres's does. ALTER TABLE t ALTER COLUMN c ADD GENERATED ALWAYS | BY DEFAULT AS IDENTITY (the column must be NOT NULL, an integer, with no default), SET GENERATED ... and DROP IDENTITY [IF EXISTS] work as in Postgres, and pg_attribute.attidentity and information_schema.columns.is_identity / identity_generation say what each column is. The options in (...) (START WITH, INCREMENT BY, MINVALUE, MAXVALUE, CYCLE, SEQUENCE NAME) work as a sequence's do: an identity column made with any that aren't the defaults gets a sequence of its own, t_c_seq, owned by the column (pg_sequence, select last_value from t_c_seq, and ALTER SEQUENCE reach it; DROP IDENTITY, dropping the column or the table drops it), and with the defaults it uses the column's own counter, which counts from 1 by 1. ALTER COLUMN ... SET INCREMENT / RESTART (changing the sequence through the column) isn't supported: use ALTER SEQUENCE.
Generated columns: col type GENERATED ALWAYS AS (expr) STORED, in CREATE TABLE and ALTER TABLE ... ADD COLUMN (which fills every existing row), works out expr from the row's other columns each time the row is inserted or updated, and keeps the result. A value written to it is refused (428C9; DEFAULT is fine), and the expression may read only the row's own columns, not another generated column (42P17), a subquery (0A000), or anything that isn't immutable, such as random() or now() (42P17); VIRTUAL (Postgres 18's) and GENERATED BY DEFAULT AS (...) are syntax errors, as in Postgres 16. A generated column can be indexed, NOT NULL, and read in a WHERE; a column it reads can't be dropped (2BP01) or have its type changed (0A000), but can be renamed. ALTER COLUMN c DROP EXPRESSION [IF EXISTS] keeps the values and makes it a plain column; SET DEFAULT on one is 42601. COPY with no column list leaves generated columns out (both ways), and naming one is 42P10. attgenerated, information_schema.columns.is_generated / generation_expression, pg_attrdef (so pg_dump writes GENERATED ALWAYS AS (...) STORED) and INCLUDING GENERATED in LIKE know them.
gen_random_uuid() gives version-4 UUIDs, a good fit for rows made on branches.
Dates and times
create table events (id serial primary key, at timestamptz default now(), day date, lasts interval);
select * from events where at > now() - interval '7 days' order by at desc;
select date_trunc('month', at) as m, count(*) from events group by m order by m;
select day + 7, extract(dow from day), at - '2024-01-01'::timestamptz from events;
- Time zone:
timestamptzis stored as a moment (UTC). A session shows it in its time zone, UTC until it sets another:SET TIME ZONE 'Asia/Kolkata'(alsoSET timezone = ...,SET TIME ZONE INTERVAL '+05:30' HOUR TO MINUTE,SET TIME ZONE 5.5,LOCAL/DEFAULT/RESET timezonefor UTC;SHOW timezone).SET LOCAL TIME ZONElasts until the transaction ends. A Postgres client can set it at connect, as theTimeZoneparameter or inoptions=-c TimeZone=....- In that zone:
timestamptzprints with its offset (2026-09-29 10:30:00+05:30), text without a zone is read as its time,current_date,localtimestampandcurrent_timeare its date and time, casts betweentimestamptzanddate,timestamportimeconvert through it, adatecompared with atimestamptzis its midnight, anddate_trunc,extract(notepoch) andto_charwork in its clock. - Named zones come from the system's tz database (
/usr/share/zoneinfo, orTZDIR), with their daylight saving time and their history, as in Postgres built with system tzdata:SET TIME ZONE 'America/New_York','Europe/London','Asia/Kolkata', in any case (shown as the database spells it). A timestamptz is shown with the offset the zone had at that moment (2024-07-01 08:00:00-04), a wall-clock time that a change skips is read in the zone's time before the change and one it repeats in its time after, and+ interval '1 day'counts days on the zone's calendar (across a change it is 23 or 25 hours). Without a tz database (a minimal container: installtzdata) only UTC, fixed offsets,Etc/GMT±nand a short list of zones with one offset all year (Asia/Kolkata,Asia/Tokyo, ...) are known, and any other name is 22023. A string offset is POSIX, as in Postgres:'UTC+5'and'+05'are five hours west of UTC; a number orINTERVALis east.pg_timezone_namesandpg_timezone_abbrevslist them. AS OF '2026-09-20 10:00'and world names likemain@2026-09-20 10:00read the time as UTC unless it says otherwise.- JSON (
to_json,row_to_json, the HTTP and MCP APIs, diffs) shows atimestamptzin UTC with its offset (2026-09-29T05:00:00+00:00), whatever the session's zone: the same moment, where Postgres'sto_jsonwould show the session's time.
- Values:
date '2024-03-10',timestamp '...'andinterval '1 day 02:00'literals, or plain text where a date is expected, andnow(),current_dateandcurrent_timestamp. Years before the common era are written and shown withBC(0044-03-15 BC; there is no year 0, and a year of one or two digits is of this century or the last,24being 2024 and701970).infinityand-infinityare dates and timestamps that sort past every other (valid_to date default 'infinity');infinity ± an interval or a number of daysis itself,date - datewith one is 22008,isfinite(x)tells them,date_truncleaves them andto_chargives null.extractof one is 0A000 (Postgres givesInfinity).epoch,now,today,tomorrowandyesterdayare read as the moments they name.generate_series(date, date, interval)is overtimestamptz, as in Postgres, and refuses an infinite bound (22023). - The clock, as in Postgres:
now(),current_timestamp,transaction_timestamp(),localtimestamp,current_timeandcurrent_dateare fixed when the transaction begins (outsideBEGIN, when the statement does), socreated_at = updated_at = now()in two statements of one transaction agree, and so does a column'sDEFAULT now().statement_timestamp()is fixed when the statement begins, andclock_timestamp()reads the clock at each call (aDEFAULT clock_timestamp()too). Postgres'scurrent_timecarries a time zone; ours is a plaintime. - Arithmetic: timestamp ± interval (months by the calendar: Jan 31 + 1 month is the end of February), timestamp − timestamp, date ± days, and date − date. An interval times or divided by a number (
interval '1 hour' * 2.5,/ 4) keeps whole months and days and cascades the fractions down (a fraction of a month into days, of a day into time), as Postgres'sinterval_mulandinterval_divdo;sum,avg,minandmaxof intervals work (avgis the sum over the count);justify_hours,justify_daysandjustify_intervalturn 24 hours into a day and 30 days into a month; and(start, end) OVERLAPS (start, end)(an end may be an interval: the period's length) follows Postgres's null rules. A bare number in an interval is seconds (interval '5','5 days 3'). AT TIME ZONE:timestamp AT TIME ZONE zonereads a wall clock in the zone and gives the timestamptz (a date is its midnight),timestamptz AT TIME ZONE zonegives the wall clock there as a timestamp, andtimezone(zone, value)is the same with the operands the other way round. The zone is a name from the tz database (America/New_York,UTC,Etc/GMT+5), an abbreviation (PST,CEST: its offset all year, as Postgres's default abbreviation set has it), a POSIX offset string ('+05:30','UTC+3': west of UTC for+, as Postgres reads it), or an interval (interval '05:30', east of UTC; not with months or days). It binds tighter than*and+and looser than::, as in Postgres, soa + b AT TIME ZONE 'UTC'isa + (b AT TIME ZONE 'UTC').date_trunc(unit, timestamptz, zone)andgenerate_series(from, to, step, zone)take a zone too, and a timestamptz can be written with one:'2024-03-15 12:00:00 America/New_York','... EST'.time AT TIME ZONE(a timetz) is not supported.- Building and reading:
make_date,make_time,make_timestampandmake_timestamptz(y, m, d, h, mi, s [, zone])(a negative year is BC;24:00:00is a valid time), andto_date(text, template)/to_timestamp(text, template), which read a date and time out of text by Postgres's patterns:YYYY YYY YY Y,MM,DD,DDD,HH HH12 HH24,MI,SS,MS,US,FF1-FF6,SSSS,AM/PM(alsoA.M.),Month/Mon(in any case),Day/Dy,BC/AD,Q,W,WW,IYYY/IW/ID(ISO weeks),CC,J,TZH/TZM, quoted text,FMand aTHsuffix. A space or punctuation mark in the template takes the text's whitespace and one mark; digits are read as wide as the pattern unless another number follows (then exactly that wide); the text may end before the template does, and what is left over is ignored; what is not given is year 0 (1 BC), January, the 1st.TZ,OF,RMandSPare only forto_char(0A000). Not the same as Postgres: a few odd combinations of ISO-week patterns. - Functions:
date_trunc(unit, …),extract(field from …)anddate_part(field, …).extractgives a numeric, as Postgres 14 and later does (2024, not2024.0), at the scale Postgres writes each field with: whole numbers foryear,month,day,hour,minute,quarter,dow,isodow,doy,week,isoyear,century,decade,millennium,microsecondsandtimezone*;secondandepochwith six decimals (45.678901,1710513045.678901; a date's epoch is a whole number),millisecondswith three,juliana whole number for a date and 20 decimals for a timestamp.date_partgives the same value as a double precision. A field that a type has no such part of (hourof a date,dayof a time,dowof an interval) is 0A000, one Postgres does not have 22023. An interval'sepochcounts a year as 365.25 days and a month as 30, as Postgres does;interval '100:00:00'reads as 100 hours. - Times of day:
time(time without time zone), e.g.'09:30',time '17:00:00.5',localtime.- time ± interval moves the clock and wraps past midnight, time − time is an interval, date + time is a timestamp,
ts::timetakes a timestamp's time of day, andextract(hour | minute | second from t)works. current_timeis the time now in the session's zone, with no zone of its own. There's notime with time zone: usetimestamptz.
- time ± interval moves the clock and wraps past midnight, time − time is an interval, date + time is a timestamp,
- Outside SQL: JSON shows ISO 8601.
Changing tables
Rows aren't rewritten when a table changes. Each packed row records the column layout it was written with, so:
-
Old rows keep working: after columns are added, dropped, renamed or retyped, old rows read through their own layout.
-
Across branches: a branch that changed a table merges cleanly with rows another branch wrote under the old layout.
-
Type changes: changing a type checks every value converts first, and refuses if one doesn't.
-
Renaming a table moves its rows.
-
Limit: rows stored as JSON (through the JSON commands) are matched by name, so a renamed column doesn't find their old field.
-
Merging by columns:
alter table leads set (merge_by_columns = true)makes merges into this table combine a row both sides changed when they changed different columns, asMERGE ... BY COLUMNSdoes;alter table leads reset (merge_by_columns)goes back to whole rows (the default). Set it on tables whose columns don't depend on each other: two agents changingstatusandbalanceof one account would otherwise combine into a row neither checked. It's a change to the table:DIFFshows it (merge: by columns), and under a merge policy it's a schema change. -
What migration tools write:
ALTER TABLE ONLY t ...(aspg_dumpand Rails'sstructure.sqldo),ADD CONSTRAINT ... NOT VALIDandVALIDATE CONSTRAINT name,ALTER COLUMN c TYPE t USING candUSING c::t(Django's and Alembic's; an expression that computes something else is 0A000),SET SCHEMA s(moves the table), andALTER INDEX a RENAME TO b(a unique constraint's index and the constraint with it).NOT VALIDchecks the rows there are when the constraint is added, where Postgres adds it unchecked: a table that already breaks the rule fails here (23514, 23503, 23505).OWNER TO rolegives the table to the role (Roles and privileges).SET TABLESPACEandSET LOGGEDsucceed and change nothing (a notice says so): there are no pages or tablespaces here. Storage parameters (WITH (fillfactor = 70, autovacuum_enabled = false, ...)onCREATE TABLEandCREATE INDEX,SET (...)andRESET (...)onALTER TABLEandALTER INDEX) change nothing either, but are checked as Postgres checks them (an unknown one, or a value out of its bounds, is 22023) and kept, aspg_class.reloptionsshows them. -
A primary key later:
alter table t add primary key (col), or(a, b)(or a new column declaredprimary keyon an empty table). Every row moves to its key in one step: the key must be unique and never null (23505, 23502), and its columns becomeNOT NULL.
Keys of several columns
create table order_lines (ord int, line int, sku text, qty int, primary key (ord, line));
select * from order_lines where ord = 7 and line = 2; -- one row, read by its key
select * from order_lines where ord = 7; -- the rows whose keys start with 7
create table shipments (id int primary key, ord int, line int,
foreign key (ord, line) references order_lines on delete cascade);
insert into order_lines values (7, 2, 'a', 5) on conflict (line, ord) do update set qty = excluded.qty;
- Types: each column an integer, numeric, double precision, text, bytea, uuid, boolean, date, time, timestamp, timestamptz or an address type. The columns are
NOT NULL. A one-column key of a type other than integer, bigint, text, bytea, uuid or an address can't have its type changed (ALTER COLUMN ... TYPEis 0A000). - Stored in order: a row's key holds its key's values in a form that sorts as they do (numbers as numbers, text by its bytes, one column after another) and can't be mistaken for another row's. So a table's rows come out in key order, equality on all the key's columns reads one row, and equality on its first columns reads just the rows that start with them (
EXPLAINshowsIndex Scan using order_lines_pkey). Other conditions scan or use indexes as usual; joins on the key's columns hash-join. - Shown and typed as rows:
DIFF,MERGE ... DRY RUN, merge conflicts, error messages, the shell and the HTTP API show such a key as Postgres writes a row,order_lines/(7,2), and take it that way:get order_lines (7,2)in the shell,{"table": "order_lines", "id": "(7,2)"}over HTTP, andRESOLVE ('order_lines/(7,2)' = ours). JSON writes (put order_lines (7,2) {...}) fill the key's columns from the key. ON CONFLICTnames the key's columns in any order, orON CONFLICT ON CONSTRAINT order_lines_pkey.- Keys of one column are stored as before (
users/42), so databases made before read the same. - Not allowed: dropping a key column (2BP01), or changing its type (0A000) other than between integer and bigint.
Loading and unloading: COPY
copy orders from stdin (format csv, header); -- then the rows, as Postgres clients send them
copy orders (id, total) from stdin; -- text format: tab-separated, \N for NULL
copy orders to stdout (format csv, header);
copy (select id, total from orders where total > 100) to stdout with csv;
copy (delete from orders where total = 0 returning id) to stdout; -- the rows a statement returns
psql postgres://127.0.0.1:5433/main -c "\copy orders from 'orders.csv' csv header" # a file on your side
psql postgres://127.0.0.1:5433/main -c "\copy orders to 'orders.csv' csv header"
- Over the Postgres protocol:
COPY ... FROM STDINandCOPY ... TO STDOUTuse Postgres's copy messages, so psql's\copy,pg_dump-style scripts with the data inline, and drivers' copy support (psycopg'scursor.copy, thepostgrescrate'scopy_in/copy_out, ...) work. Through the simple or the extended protocol. - Formats:
text(the default),csv, and (FROM only)binary, Postgres's binary format as bulk loaders send it, with every type binary parameters take, pgvector's vectors included (psycopg:cur.copy("copy items from stdin (format binary)")). Text and CSV take Postgres's options:HEADER,DELIMITER,NULL,QUOTE,ESCAPE,FORCE_QUOTE (cols)or*,FORCE_NOT_NULL,FORCE_NULLandENCODING 'UTF8', in theWITH (...)form or the older one psql writes (csv header delimiter ';'). Values are written as Postgres writes them (t/f,\N, quoting only where needed), so a table copied out and in again comes back byte for byte. - All or nothing: a bad value, a missing or extra column, a duplicate key, or a client that cancels (CopyFail) or disconnects leaves nothing behind. Inside
BEGIN, a failed COPY fails the transaction, as in Postgres. Errors name the line and column:invalid input syntax for type integer: "x" (COPY t, line 45000, column v). - Fast, and one statement however big: rows are parsed to their columns' types as they arrive and go in as one insert of values (no SQL to parse), a part at a time (16,384 rows or 32 MB) while the client is still sending. A COPY too big to hold goes on in parts, as any big
INSERTdoes, so memory doesn't grow with it (see operations). In process, 100,000 three-column rows load at about 280,000-390,000 rows a second, against about 170,000-200,000 forINSERTs of 1,000 rows each. Over the network with psql, psql itself sets the pace. - Checked on insert:
NOT NULL,CHECK,UNIQUE, foreign keys and indexes, and defaults for columns the COPY leaves out, exactly asINSERTdoes. - Triggers: a COPY FROM fires
INSERTtriggers as anINSERTdoes: row triggers for each row, and statement triggers once for the whole COPY. A table with triggers takes the COPY in memory. - Refused: files and programs on the server (
COPY t FROM '/path',PROGRAM): 42501. The client reads and writes its own files (\copy). AlsoCOPY ... TOin binary andCOPY ... WHERE(0A000). Over HTTP and in the shell, COPY needs a Postgres connection. - Loading a lot: use COPY, or one
INSERT ... SELECTor big multi-rowINSERT. A statement bigger than half ofCHRONOS_WORK_MEMskips the log: its rows are written once, into the table's tree, and parts in key order (ids as they come) go straight there. Many small transactions (INSERTs of 100 rows, 1,000 perCOMMIT) write each row twice, to the log at commit and into the tree at the next checkpoint, as Postgres writes its WAL and then its tables. On 76,424 OpenAI embeddings: a binary COPY wrote 526 MB, the small-transaction load 1.27 GB.
uuid
create table accounts (id uuid primary key default gen_random_uuid(), owner text);
insert into accounts (id, owner) values ('A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11', 'ada');
select id from accounts where id = 'a0eebc999c0b4ef8bb6d6bb9bd380a11'; -- found: the same uuid
- As in Postgres:
uuidis its own type (id 2950): drivers get a UUID, not text (sqlx'sUuid, pgx'spgtype.UUID, npgsql'sGuid, JDBC'sgetObject(UUID)), in binary as 16 bytes and in text as lowercase8-4-4-4-12. Input takes the spellings Postgres takes (upper or lower case, without hyphens, in braces, hyphens after any group of four digits) and anything else is 22P02. A literal compared with a uuid is read as one, soid = 'A0EE...'findsa0ee...; uuids order and compare as their 16 bytes do;id::textand'...'::uuidcast both ways;gen_random_uuid()(anduuid_generate_v4()) give auuid. It can be a primary key, aUNIQUEcolumn, an indexed column, and the target of a foreign key. - Before this:
uuidwastextunderneath, so'A0EE...'and'a0ee...'were two different values and'not-a-uuid'was accepted. Tables made then keeptextcolumns (their schema says so); make the columnuuidwithALTER TABLE t ALTER COLUMN id TYPE uuidto have the new behaviour.
Binary strings (bytea)
bytea holds bytes. It is written as '\xdeadbeef' (hex, whitespace between bytes allowed) or in the older escape form ('abc\000def', a backslash as \\), read back as \x and hex (Postgres's bytea_output = hex), and travels in binary as the bytes themselves, so a driver's Vec<u8>, Buffer, bytes or LargeBinary round-trips byte for byte. Values are compared bytewise, so they sort, group, join and index (CREATE INDEX, UNIQUE, PRIMARY KEY of one column or several) as Postgres's do. Casting text to bytea gives the string's UTF-8 bytes ('héllo'::text::bytea), the other way the \x text; a number is not a bytea (42846).
- Functions:
lengthandoctet_length(bytes),bit_length,substring(b FROM n FOR m)/substr,position(x IN b)/strpos,overlay(b PLACING x FROM n [FOR m]),||(alsostring_agg),get_byte,set_byte,encode(b, 'hex' | 'base64' | 'escape'),decode(s, ...),md5(b),sha224(b),sha256(b),sha384(b),sha512(b)(and pgcrypto'sdigestandhmac, below), andconvert_to(s, 'UTF8' | 'LATIN1' | 'SQL_ASCII')/convert_from.overlay(s PLACING ...)works on text too. - Errors are Postgres's:
'\xzz'and an odd number of digits are 22023, a bad escape is 22P02,get_bytepast the end is 2202E, a negativesubstringlength is 22011. - Stored as the bytes' hex text, so a value takes twice its size on disk and in memory; a multi-megabyte blob is better kept as a file with its path in the row.
- Not here:
bytea_output = 'escape'(results are always hex),byteain aLIKE, arrays ofbyteaas more than text,min/max(Postgres has none either), andCOPY ... (FORMAT binary)out.
Enum types
create type mood as enum ('sad', 'ok', 'happy');
create table people (id int primary key, name text, feeling mood default 'ok');
select name from people where feeling > 'sad' order by feeling desc;
alter type mood add value 'meh' before 'ok';
alter type mood rename value 'sad' to 'down';
select enum_range(null::mood); -- {down,meh,ok,happy}
drop type mood; -- once nothing uses it (or CASCADE)
- As in Postgres: a value must be one of the labels (22P02 otherwise), and enums compare, sort (
ORDER BY),minandmaxin the order the labels were given, not as text.'happy'::moodcasts and checks;feeling::textis the label. - Types are rows of the world: a type made on a branch arrives with its merge, and forks share it. A table's schema keeps its enum's labels too.
- Everywhere a column goes: defaults,
ALTER TABLE ... ADD COLUMNandTYPE, indexes (for=),UNIQUE, COPY and JSON writes (checked the same). Clients receive labels as text. - Changing a type:
ALTER TYPE t ADD VALUE [IF NOT EXISTS] 'v' [BEFORE | AFTER 'w'],ALTER TYPE t RENAME VALUE 'a' TO 'b'andALTER TYPE t RENAME TO u, as in Postgres: existing rows keep their labels, in the new order, and a renamed label is renamed in every row, index and column default that holds it (Chronos keeps labels as text, so the rows holding it are rewritten, in the same write). A change on a branch arrives with its merge, andAS OFsees the type as it was. Renaming a type that a view's column has is refused (0A000): drop the view, rename, make it again. DROP TYPE ... CASCADEdrops the columns of the type (asALTER TABLE ... DROP COLUMN) and the functions taking or returning it, with Postgres's notices; withoutCASCADE, any of those stops it (2BP01).- Functions:
enum_first(x),enum_last(x)andenum_range(x [, y])(a text array,{sad,ok}), taking the type from their argument (null::mood, a column). Enums can be a function's arguments and result,RETURNS SETOF/TABLEcolumns, and PL/pgSQL variables, checked (22P02) and compared by their labels' order. - Errors: a type that exists (42710), a label given twice (23505) or added twice (42710), a label that isn't one (22023 in
ALTER TYPE, 22P02 elsewhere),DROP TYPEof a type something uses (2BP01), an unknown type (42704). - Not yet: enums in primary keys, and
CHECKconditions or partial indexes that name a renamed label (they keep the old one).DIFFdoesn't list types (the tables using one show its labels).
Schemas
create schema app;
create table app.orders (id int primary key, customer int references public.customers);
set search_path to app, public; -- now `orders` is app.orders, `customers` public.customers
select * from orders join customers c on c.id = orders.customer;
drop schema app cascade; -- and everything in it
- Names:
schema.nameworks for tables, views, materialized views, sequences, enum types, functions, triggers' tables and indexes, anywhere they're named (DDL,INSERT/UPDATE/DELETE,FROM, joins,REFERENCES,COPY,nextval('app.s'),'app.t'::regclass). A table in another schema is called by its own name in the query (select t.id from app.t), as in Postgres. Default constraint and index names don't carry the schema (t_pkey); an index is in its table's schema (drop index app.t_v_idx). search_path:SET search_path TO a, public(or= 'a, public',SET SCHEMA 'a'),RESET search_pathandSHOW search_path, per session. A name without a schema is the first of its kind along the path (a temporary table first);CREATEwithout a schema makes it in the path's first schema that exists (3F000 if none).current_schema()andcurrent_schemas(bool)follow it. The default is"$user", public;$userschemas aren't looked for. In a script of several statements, each sees the path and objects the ones before it left, as in Postgres.CREATE SCHEMA [IF NOT EXISTS] name [AUTHORIZATION role](42P06 if it exists; names startingpg_are 42939), andDROP SCHEMA [IF EXISTS] a, b [CASCADE | RESTRICT]: withoutCASCADE, a schema holding anything is 2BP01; with it, its views, tables (and foreign keys and views elsewhere that depend on them), sequences, functions and types go too, in one step.publiccan't be dropped.- World data: a schema is a row of the world, like a table's schema, and objects in it are stored under their qualified name (
app.orders/7); objects inpublickeep their plain names, so databases made before schemas read the same. Forks,DIFF(andDIFF ... AS SQL, ascreate schema app;), merges (the same schema made on both sides merges as one),AS OFand restarts all carry them. - psql:
\dnlists schemas,\dt app.*a schema's tables,\dtthe ones the search_path shows, and\d app.ta table in one. - Differences from Postgres: a view's names are looked up along the search_path of the session reading it (Postgres fixes them when the view is made); a prepared statement keeps the tables its names meant when it was prepared;
CREATE SCHEMA ... CREATE TABLE ...(objects inside the statement) is 0A000.
Temporary tables
create temp table picks (id int primary key, score float8);
insert into picks select id, random() from products where stock > 0;
select p.name from products p join picks using (id) order by score limit 10;
- The session's own:
CREATE TEMP(orTEMPORARY)TABLEmakes a table only this connection sees, in memory: it's never written to the log, never in aDIFF, never merged, and it's gone when the connection ends (orDROP TABLE). A temporary table hides a world's table of the same name, as in Postgres. - With everything else: it can be read alongside the world's tables (joins, subqueries,
INSERT INTO world_table SELECT ... FROM temp_table), and filled from them (INSERT INTO temp_table SELECT ... FROM world_table), with the usual constraints, defaults and indexes.BEGIN ... ROLLBACKtakes back its changes too. - Not yet: savepoints (
ROLLBACK TOleaves a temporary table's changes), subqueries on the world's tables inside anUPDATEorDELETEof a temporary table, foreign keys between temporary and other tables, and psql's\dfor them.
Prepared statements
prepare by_customer (int) as select * from orders where customer = $1 order by id;
execute by_customer(42);
deallocate by_customer; -- or: deallocate all
- As in Postgres: parameter types given in parentheses or inferred from use,
EXECUTE name(values)with the values converted to those types, and Postgres's errors (42P05 a name already used, 26000 no such statement, 42601 the wrong number of values).SELECT,INSERT,UPDATEandDELETEcan be prepared. - One namespace with the protocol's: a statement a driver prepares (Parse) and one
PREPAREmakes share names on a connection, soEXECUTEruns either andDEALLOCATEfrees either, as in Postgres. - Per session: they last until the connection ends (or, over HTTP, until the request ends).
Comments
comment on table orders is 'One row per checkout';
comment on column orders.total is 'In cents, tax included';
select obj_description('orders'::regclass, 'pg_class'); -- One row per checkout
select col_description('orders'::regclass, 3); -- In cents, tax included
- As in Postgres:
COMMENT ON TABLE | VIEW | MATERIALIZED VIEW | SEQUENCE | INDEX | COLUMN | CONSTRAINT ... ON table | SCHEMA name IS 'text', withNULL(or an empty string) taking it away,'it''s',E'...'and$$...$$text, and Postgres's errors (42P01 no such relation, 42703 no such column, 42809 not that kind of object, 42704 no such constraint, 3F000 no such schema).obj_description(oid [, catalog]),col_description(oid, n)andpg_descriptionread them (pg_dumpand psql's\d+and\dt+do too), andpg_dumpwritesCOMMENT ONstatements. - A comment goes with its object: dropping the table, column, index or constraint (or the schema, with its tables) drops it, and renaming a table, a column or an index moves it, as in Postgres. A table dropped and made again has none.
- In the world: a comment is a row of the world (
_sonos_comments/...), so a fork has its own,DIFFshows a change to one (DIFF ... AS SQLasCOMMENT ON, after what it's on), a merge brings it (both sides commenting one object differently is a conflict, as for any row), and it survives restarts. Writing one is a write: an agent that can only read can't. - Not kept: comments on functions, procedures, types, triggers, extensions, databases, roles and the rest say so in a notice and are dropped, and so does one on a temporary table.
Cursors
begin;
declare orders_cur scroll cursor for select * from orders order by id;
fetch 100 from orders_cur; -- the next 100 rows
fetch backward 10 from orders_cur; -- and back over 10
move absolute 0 in orders_cur; -- to before the first row
close orders_cur;
commit;
- As in Postgres:
DECLARE name [INSENSITIVE] [[NO] SCROLL] CURSOR [WITH | WITHOUT HOLD] FOR query;FETCHandMOVEwithNEXT,PRIOR,FIRST,LAST,ABSOLUTE n,RELATIVE n, a count (FETCH 5,FETCH -2),ALL,FORWARD [n | ALL]andBACKWARD [n | ALL],FROMorIN(or neither:FETCH cur);CLOSE nameandCLOSE ALL. Tags count the rows:FETCH 3,MOVE 2. A cursor withoutWITH HOLDneeds a transaction block (25P01) and ends with it (aROLLBACK TOcloses the ones declared after its savepoint);WITH HOLDones last untilCLOSEorDISCARD ALL. Errors as Postgres's: 42P03 a name already used, 34000 no such cursor, 55000 a backward fetch on aNO SCROLLcursor, 25P01 outside a transaction. This is what psycopg's named cursors, JDBC'ssetFetchSize(through the protocol's row limit, below) and psql's\set FETCH_COUNTuse. - A snapshot: the query runs when the cursor is declared and its rows are kept for the cursor, so rows other sessions (or this one) change or add afterwards don't show in it, as in Postgres, where a cursor reads the snapshot of its
DECLARE. It holds all those rows in memory until it's closed, so a cursor over a table of millions of rows costs that much: aLIMITin the query, or keyset pagination, keeps it small. - Not here:
BINARYcursors (0A000), a query other than aSELECTorVALUES(42601), andWHERE CURRENT OF cursorin anUPDATEorDELETE. A cursor a PL/pgSQL function opened (arefcursorit returned) is fetched, moved and closed like one declared here, until its transaction ends; aROLLBACKcloses aWITH HOLDcursor declared in its transaction, as in Postgres. Postgres refuses a backward fetch on a cursor whose plan can't scan backward (an aggregate, say); here a cursor withoutNO SCROLLscans backward whatever it reads. - Over the protocol: an Execute with a row limit sends that many rows, then
PortalSuspended, and the next Execute on the portal goes on from there (as JDBC'ssetFetchSizeandpostgres'squery_portaldo); a portal that has run to its end is 55000 if run again, and one left part-read is dropped when a Sync ends a statement outside a transaction. The rows of the statement are found when it's first executed, all at once, and sent a limit at a time. - Agents:
DECLAREis checked as its query is (a read for aSELECT);FETCH,MOVEandCLOSEare reads, since what they show was checked when the cursor was declared.
psql's commands
\dt, \dv, \dm, \ds, \d, \d name (tables with their triggers, views, materialized views and sequences), \d+ view (with its definition) and \l (which lists branches as databases) work in psql. Chronos recognises the catalog queries psql 16 sends and answers them from its own tables. Other queries on the catalog go to the SQL engine, which knows these catalog tables.
Views
create view paid (id, who, total) as select id, customer, total from orders where status = 'paid';
create or replace view paid as select id, customer, total, placed from orders where status = 'paid';
select who, sum(total) from paid group by who;
update paid set total = total * 0.9 where who = 'ada'; -- writes orders, only its paid rows
drop view paid; -- or: drop table orders cascade
- Anywhere a table is read:
FROM, joins, subqueries,INSERT ... SELECT, other views. A view's query runs as part of the query that reads it. - In the world: a view's definition is a row of the world, like a table's schema. A fork has its own views,
DIFFshows them made, changed and dropped (DIFF ... AS SQLasCREATE OR REPLACE VIEW), a merge brings them, and they survive restarts. - AS OF:
select * from v as of '-1 hour'reads the view's definition as it was then, over its tables as they were then. CREATE OR REPLACE: as in Postgres, the new query keeps the old columns (names and types) first and may add more (42P16 otherwise).- Dependencies: dropping a table or view another view reads fails with 2BP01 unless it's
DROP ... CASCADE, which drops the views too (and, for a table, the foreign keys referencing it). - Writing through a view:
INSERT,UPDATEandDELETEwork on views that select columns of one table (or of such a view), withoutDISTINCT,GROUP BY, aggregates, window functions,LIMITorUNION, as Postgres's automatically updatable views: they write the table,UPDATEandDELETEonly the rows the view shows, andRETURNINGgives the view's columns. Other views are refused (55000), and so is writing a view column that's an expression (0A000).WITH [LOCAL | CASCADED] CHECK OPTIONworks as in Postgres: anINSERTorUPDATEthrough the view that writes a row the view wouldn't show is 44000, LOCAL checking the view's own condition (and those of views below it that have an option), CASCADED every view below; only on views that can be written through (0A000 otherwise).information_schema.views.check_optionsays which. - Tables, views, materialized views and sequences share names (42P07).
- Renaming:
ALTER VIEW [IF EXISTS] v RENAME TO w,ALTER MATERIALIZED VIEW ... RENAME TOandALTER TABLE v RENAME TO won a view, with Postgres's errors (42P01, 42809 for the wrong kind, 42P07 a name taken); the view's comment goes with it.RENAME COLUMNon a view is 0A000. - Differences from Postgres: a view is kept as its SQL and read by name each time, so
select *in a view picks up columns added to its table later, renaming a table or view another view reads is refused (drop that view, rename, make it again), and a view reading a dropped or renamed column fails when it's read.
Materialized views
create materialized view daily as select date_trunc('day', at) as day, sum(total) from orders group by 1;
refresh materialized view daily;
create materialized view later as select ... with no data; -- reading it fails until a REFRESH
drop materialized view daily;
- A table holding its query's rows, made by
CREATE MATERIALIZED VIEW [IF NOT EXISTS] name [(columns)] AS query [WITH [NO] DATA]. It reads like a table, takes indexes, and is written only byREFRESH MATERIALIZED VIEW name [WITH [NO] DATA](42809 forINSERT,UPDATE,DELETE,TRUNCATE,ALTER TABLE). - Per world: a REFRESH in a fork changes only the fork until it merges. Rows are kept by their place in the answer, so a REFRESH that finds the same rows changes nothing, and
DIFFshows only the rows that did change (DIFF ... AS SQLas aREFRESH). CONCURRENTLYis accepted: a REFRESH never blocks readers anyway.
Sequences
create sequence order_no start 1000 increment by 10;
select nextval('order_no'), currval('order_no'), lastval();
select setval('order_no', 5000); -- or setval('order_no', 5000, false): 5000 comes next
alter sequence order_no restart with 1; -- also INCREMENT, MINVALUE, MAXVALUE, CYCLE, ...
create table orders (id bigint primary key default nextval('order_no'), total numeric);
drop sequence order_no;
- Options:
AS smallint | integer | bigint,INCREMENT [BY](negative counts down),MINVALUE/NO MINVALUE,MAXVALUE/NO MAXVALUE,START [WITH],[NO] CYCLE, andALTER SEQUENCE ... RESTART [WITH n], checked as Postgres checks them.CACHEis accepted and changes nothing.OWNED BY table.column(alsoCREATE SEQUENCE ... OWNED BY) is kept:pg_dependshows it,pg_dumpwrites it, and dropping the table drops the sequence;OWNED BY NONElets go. Past the end withoutCYCLE,nextvalfails with 2200H. - Read as a table:
select last_value, log_cnt, is_called from order_nogives one row, as in Postgres: the last value handed out andtrue, or (none yet) the value it counts from andfalse.pg_sequences.last_valueis null until one was handed out. - Across worlds: the definition is a row of the world, like a table's schema (a fork,
DIFF, a merge and AS OF see it), but its counter belongs to the database, shared by every world holding that sequence, asserialcolumns' are, and isn't rolled back: rows numbered in two forks never collide when they merge.nextvalin a world writes no world's rows (main's version doesn't move). A sequence created separately in two worlds under one name has two counters, and merging both definitions is a conflict.setvalandRESTARTmove the shared counter for every world (from any world, without writing main), andTRUNCATE ... RESTART IDENTITYrestarts its tables'serialcounters for every world too. Restoring or reading the past doesn't move a counter back. - In a session:
currvalandlastvalare this session's last values (55000 before any), as in Postgres; aDEFAULT nextval(...)sets them too. - Differences from Postgres:
setvalworks inSELECT(not insideINSERT,UPDATEorDELETE); a write taking many values at once may skip some (it reserves more when it runs out).ALTER SEQUENCE ... OWNER TOis accepted with a notice: nothing has an owner here.
LISTEN and NOTIFY
listen jobs; -- this connection hears the channel
notify jobs, 'order 42 paid'; -- or: select pg_notify('jobs', 'order 42 paid')
unlisten jobs; -- or: unlisten *
- When: at
COMMIT(or at once outside a transaction); aROLLBACK, a failed statement or aROLLBACK TO SAVEPOINTdrops the notifications it made. The same channel and payload twice in one transaction arrive once. Payloads are shorter than 8000 bytes (22023). - Who hears it: connections that LISTEN on the channel in the same world, the sender included; each gets Postgres's NotificationResponse with the sender's backend id. An idle connection gets it at once, without sending a query (psql shows it at its next command); a connection inside a transaction gets it when the transaction ends.
- Worlds: a world's notifications reach its own listeners only: a NOTIFY in a fork doesn't reach main, and a merge doesn't send it (notifications are messages at commit time, not rows). For a notification that should happen when the work reaches main, use
NOTIFY ON MERGE(below). A LISTEN is for the world the connection is in when it runs, and stays with that world if the connection switches. - Differences from Postgres: a LISTEN inside a transaction takes effect at once, not at COMMIT.
Effects on merge: NOTIFY ON MERGE and the outbox
An agent working in a world shouldn't send the refund email before anyone has approved the refund. NOTIFY ON MERGE queues a notification in the world instead. Nobody hears it while the world is worked on, it's dropped with the world if the world is discarded, and it happens when a merge brings it into main:
-- in world fix (an agent's)
update orders set status = 'refunded' where id = 42;
notify on merge billing, '{"refund": 42}';
-- a person merges fix: listeners on main hear billing '{"refund": 42}' now,
-- and it waits in main's outbox until a worker says it's done
show outbox;
-- id | channel | payload | world | queued | by | state | attempts | worker | lease_until | merged_from | approved
-- -------------------------------+---------+----------------+-------+---------------------------+-----+--------+----------+--------+-------------+-------------+--------------------
-- 1790590000000-9f2c41d07e6a13b5 | billing | {"refund": 42} | fix | 2026-09-28 10:00:00.00+00 | bot | queued | 0 | | | fix | merged by a person
-- a worker: take it, do it, say so
claim outbox for billing lease '30 seconds'; -- the same columns, state claimed, attempts 1
ack outbox '1790590000000-9f2c41d07e6a13b5'; -- ACK 1: done
Each entry in main is queued, claimed or ambiguous, so a worker that dies between calling a receiver and saying so never makes an effect vanish, or go out twice without anyone seeing:
queued ──CLAIM OUTBOX──> claimed (a worker, a lease, attempt n) ──ACK OUTBOX──> gone: done
claimed ──RETRY OUTBOX 'id' ATTEMPT n──> queued (the worker knows it wasn't sent)
lease runs out, claimed IDEMPOTENT ──> queued (the receiver drops a repeat)
lease runs out, otherwise ──> ambiguous: it may have reached the receiver
ambiguous ──ACK or RETRY──> once someone checked the receiver
- An entry, not a message: it's a row of the world, so a
ROLLBACKdrops it, a transaction'sCOMMITkeeps it, and a merge into another world carries it along. Only reachingmainsends it: a merge, a transaction'sCOMMITonmain, orNOTIFY ON MERGErun onmainitself (heard as the statement or its transaction ends). A merge sends it with backend id 0. - Kept until acknowledged: after the NOTIFY, the entry waits in
main's outbox, so work a worker missed (it wasn't listening, or crashed) is still there. A worker listens (the NOTIFY is its wake-up), claims, does the work, then runsACK OUTBOX 'id', ..., which removes them (ACK n; ids not there are skipped, so acknowledging twice is fine). The database never sends anything out itself: no webhooks, no mail, no secrets. - CLAIM OUTBOX [FOR channel, ...] [LIMIT n] [LEASE 'interval'] [IDEMPOTENT]: takes up to
n(default 1) queued effects, oldest first, on those channels (default any), for the lease (default 5 minutes), and returns them asSHOW OUTBOXshows them. Two claims never get the same effect. The id is the same from queueing to acknowledgement: pass it to the receiver as an idempotency key where it takes one, and claim withIDEMPOTENTthen. - A lease that runs out puts an
IDEMPOTENTclaim back in the queue. Any other becomesambiguous: the worker may have called the receiver and died beforeACK. NoCLAIMreturns it, and it stays inSHOW OUTBOXuntil someone checks the receiver and runsACK OUTBOX(it went out) orRETRY OUTBOX 'id' ATTEMPT n, ...(it didn't: queued again,RETRY n).nis theattemptsits claim showed: only that attempt is retried, so a retry that arrives late, or twice, after the effect was claimed again never takes the new claim from its worker (RETRY 0). A worker that knows a call failed retries it at once the same way. - Who:
NOTIFY ON MERGEis a write to the session's world (an agent needs to be allowed to write it; one keeping to a merge policy can't queue onmaindirectly). Merge policies count effects apart from rows (max_effects,effects_per_hour,review_channels: see merge policies).SHOW OUTBOXlists the entries in the session's world (in a world, its own andmain's as they were at the fork).CLAIM,ACKandRETRY OUTBOXneed the database's own user, or an agent withoutboxoradmin, notwrite_main: an agent that may merge can't take back the effects its merges queued. A merge policy doesn't stop them. - Partial merges:
ONLY TABLESandONLY KEYSleave a world's effects in it until it merges whole. - In the review: what a world's merge would send is shown beside its rows:
DIFF WORLDgives a NOTICE (fix queues 2 effects, sent when it merges into main: billing (1), email (1)),SHOW REVIEWSaneffectscolumn, and the Studio's Changes view lists each effect, so approving the merge is approving what it sends.MERGE ... DRY RUNshows them as rows of_sonos_outbox. - Where an effect came from:
SHOW OUTBOX(andCLAIM OUTBOX) inmainaddmerged_from, the world whose merge brought it in, andapproved:on merge policy p's rules(an agent merging within its policy),approved by a person after review (merge policy p),merged by a person, ormerged by agent x, which keeps to no merge policy. - What a merge sent:
SHOW MERGE world EFFECTS(a name or a world id) lists what that world's merges brought intomain:id,channel,world,world_id,landed,merged_by(NULL: a person),approved, andstate:queued,claimed,ambiguous, ordoneonce acknowledged, withattemptsandworker. Whoever approved a merge looks there to see whether what it sent went out. - Ambiguous is said: when an entry turns ambiguous, a NOTIFY on
chronos_outbox_ambiguoussays so, once per attempt, with a JSON payload:id,channel,attempt,worker,world(where it came from) andapproved.LISTEN chronos_outbox_ambiguousin whatever pages a person. It's noticed by the background worker (every 30 seconds) and wheneverSHOW OUTBOX,CLAIM OUTBOXorSHOW MERGE ... EFFECTSlook at the outbox; after a restart one may be said again. - Discarded, never sent: dropping a world that holds effects (
DROP WORLD, rejecting a held merge, the idle sweeper) sends none of them, and records each:SHOW OUTBOX DISCARDED [SINCE 'time' | SINCE '-1 day']listsid,channel,payload(NULL unless kept, below),payload_hmac,recipient_domains,world,world_id,queued_by,queued,discarded,discarded_byandwhy(rejected: dropped by a person while its merge waited for review (merge policy p),dropped by agent x,swept: its time ran out, or it went unused). So "the agent went quiet" and "the agent's email was vetoed" can be told apart.SHOW MERGE OUTCOMEScounts them per agent (effects_discarded). A rolled-back transaction's effects aren't recorded: nothing proposed them for review.payload_hmacis an HMAC-SHA256 of the payload under a key the database makes for itself (the folder'soutbox.key, sealed with the folder, never shown in SQL output or dumps): two records with the same hash had the same payload, but a short, guessable payload can't be confirmed by hashing candidates without the folder.recipient_domainslists the domains of the email addresses in the payload (Ada@Example.comgivesexample.com), sorted and comma-separated, NULL if none: where a vetoed email was going, without the addresses, which stay in the payload. - Payloads: a payload can hold personal data, so a discarded effect keeps only its SHA-256 unless
ALTER SYSTEM SET outbox_keep_payloads = on(show outbox_keep_payloads); effects inmainkeep theirs until acknowledged, as before. - Kept how long: where effects came from, and those discarded, are kept beside the log (the folder's
outbox.ledger, sealed with the folder) for the history window (history_retention), at most 100,000 of each; an in-memory database keeps them in memory. - Limits: channels and payloads as for
NOTIFY(a channel under 64 bytes, a payload under 8000). An effect is delivered once per landing:UNDO MERGEdoesn't take back a notification already heard.
TRUNCATE
TRUNCATE [TABLE] a, b [RESTART IDENTITY | CONTINUE IDENTITY] [CASCADE | RESTRICT] deletes every row of the tables in one write. A table other tables' foreign keys reference must be truncated with them, or with CASCADE (which truncates them too); else 0A000. RESTART IDENTITY restarts the tables' serial counters, which every world shares.
Inheritance and partitioning
create table city (id int primary key, name text, pop int);
create table capital (country text) inherits (city);
select * from city; -- the rows of both
select * from only city; -- city's own
create table ev (id int, at date, v text) partition by range (at);
create table ev_24 partition of ev for values from ('2024-01-01') to ('2025-01-01');
create table ev_rest partition of ev default;
- Inheritance:
INHERITS (a, b)merges columns of the same name and type; checks andNOT NULLare inherited;ALTER TABLE ... INHERIT parent/NO INHERIT parentlink and unlink an existing table;ADD COLUMN,DROP COLUMNand constraints on the parent reach the children. A read,UPDATEorDELETEof the parent covers the children unless it saysONLY. Unique and primary keys are not inherited. - Partitioning:
PARTITION BY RANGE | LIST | HASH (column or expression, ...). A partitioned table holds no rows; they live in its partitions, which are tables of their own (and can be partitioned in turn). Bounds:FROM (..) TO (..)withMINVALUE/MAXVALUE,IN (..),WITH (MODULUS m, REMAINDER r),DEFAULT. - Rows:
INSERT,INSERT ... SELECT,COPY FROMandON CONFLICTroute to the partition; no match is 23514. AnUPDATEthat changes the key moves the row;RETURNING tableoid::regclassnames where it is now.COPY parent TOis 0A000 as in Postgres (useCOPY (select ...)). - Attach and detach:
ALTER TABLE p ATTACH PARTITION t FOR VALUES ...checks the existing rows (23514) and that columns, checks and keys fit;DETACH PARTITION t [CONCURRENTLY | FINALIZE]leaves a plain table. Dropping a partition leaves the others; dropping the parent needsCASCADEwhile partitions exist. - Keys and indexes: a primary key or unique constraint on the parent must include every partition-key column (0A000 otherwise). An index on the parent makes one on each partition, including later ones. Foreign keys may come from and point to a partitioned table.
- Catalog: see The catalog:
pg_partitioned_table,pg_inherits,relispartition,relpartbound,pg_get_partkeydef,pg_partition_tree. - Not done: see Known gaps.
The catalog (information_schema and pg_catalog)
Tools and ORMs introspect a database by querying its catalog. These tables answer from the world's schemas, tables, views, sequences, constraints and indexes, and join, filter and sort like any table:
information_schema.schemata,.tables,.columns,.views,.sequences,.routines(the world's functions and procedures),.table_constraints,.key_column_usage,.referential_constraints,.constraint_column_usageand.check_constraints.pg_catalog.pg_namespace,pg_class(indexes too),pg_attribute,pg_type,pg_attrdef,pg_index,pg_constraint,pg_indexes,pg_am,pg_database(a row per world),pg_inheritsandpg_partitioned_table(the inheritance and partition links),pg_opclass(empty),pg_stat_activity(the open connections) andpg_stat_database(each world's connection count),pg_stat_user_tablesandpg_stat_all_tables(each table, counters 0), the other statistics views (see Postgres compatibility),pg_prepared_statements,pg_cursorsandpg_locks(the session's prepared statements and cursors, and advisory locks),pg_description(the comments made withCOMMENT ON),pg_sequence,pg_tables,pg_views,pg_matviewsandpg_sequences(with or withoutpg_catalog.; columns named as in Postgres, the ones introspection reads).- Constraints and indexes come from the real ones: primary keys (of one or several columns),
UNIQUEconstraints, foreign keys (several columns too, withconfkey,confrelidandconfdeltype),CHECKs, and indexes (CREATE [UNIQUE] INDEX, on expressions, partial, gin, hnsw).pg_constraint.conindidis the index behind a key (a foreign key's: its parent's primary key),pg_index.indkey,indclassandindoptionare anint2vector(indclass,indcollationanoidvector), written as Postgres writes them (1 2; an array function reads that as the array it is, counting from 0 as Postgres does, andindkey::int2[]is the array,[0:1]={1,2}), and are those types to a driver (type ids 22 and 30; in binary an int2 array whose lower bound is 0), andpg_get_constraintdef(oid[, pretty])andpg_get_indexdef(oid[, column, pretty])write them as Postgres does (CHECK ((qty > 0)),FOREIGN KEY (org) REFERENCES orgs(id) ON DELETE CASCADE,CREATE UNIQUE INDEX t_pkey ON public.t USING btree (id)). regclassand the other reg types:'orders'::regclass(or'app.orders', or an oid) is the relation's oid, shown by name (schema-qualified when the search_path doesn't show it);::regclass::oidis itspg_class.oid,oid = 'orders'::regclasscompares oids, andto_regclass('x')is null for a relation that doesn't exist (the cast is 42P01).regtype,regnamespace,regrole,regproc,regprocedure,regoper,regoperator,regconfig,regdictionaryandregcollationare oids of their kind of object the same way, andpg_typeofgives a regtype (see Postgres compatibility).- Functions:
current_schema(),current_schemas(bool),current_database(),pg_table_is_visible(oid)(along the search_path),format_type(type oid, typmod),pg_get_expr(expr, relid),pg_get_userbyid,obj_descriptionandcol_description(the object's comment),pg_catalog.-qualified or not. - Array helpers the introspection queries use:
unnest(a)andgenerate_subscripts(a, 1)in the select list (a row per element, side by side, as Postgres runs them; not withGROUP BY),array_length,cardinality,array_lower,array_upper, and bitwise&and|on integers. - The world's own objects are rows too, with every column Postgres has for them (
src/sql/catalog_objects.rs):pg_proc(its functions and procedures:prokind,provolatile,proisstrict,proretset,proargtypes,proargnames,prosrc; and Postgres 16's 3,286 built-in functions, inpg_catalog),pg_class.reltype(each table's, view's and materialized view's row type, a row ofpg_typewith its array type),pg_attribute's system columns and its rows for sequences and indexes,pg_typeandpg_enum(enum types and their array types, with the oidsatttypidandregtypegive),pg_trigger(tgtypebits,tgfoid; not the internal triggers foreign keys are made of in Postgres, though a table with foreign keys hasrelhastriggers),pg_rewrite(a view's_RETURNrule),pg_collation(the built-in collations and those made with CREATE COLLATION;pg_attribute.attcollation,pg_index.indcollationandinformation_schema.columns.collation_namename them,regcollationandpg_collation_actual_version()read them),pg_attrdef, andpg_depend(a serial's or identity's sequence and its column, a default and its sequence, a view and the tables it reads, a trigger and its table and function, a column and its enum type).pg_attributeandformat_type(oid, typmod)knowcharacter varying(20),character(3),numeric(10,2)and enums. Functions:pg_get_functiondef,pg_get_function_arguments,pg_get_function_identity_arguments,pg_get_function_result(of the built-in functions too),pg_get_triggerdef,pg_get_viewdef(the query asCREATE VIEWwas given it, not Postgres's rewritten form),pg_get_function_sqlbody(null),pg_options_to_table. - Every column Postgres has: each
pg_catalogtable and view of Postgres 16 exists with its columns in its order (src/sql/catalog_columns.txt, made from a real Postgres 16): the columns Chronos works out are real, the others hold what a plain object has, and a table Chronos has nothing for is empty (pg_operator,pg_ts_*,pg_foreign_*,pg_event_trigger,pg_publication, ...: built-in operators and text-search objects aren't listed). Atableoidcolumn comes last, as pg_dump reads it.pg_roles,pg_authid,pg_user,pg_shadowandpg_auth_memberslist the roles (Roles and privileges);pg_settingsandSHOW ALLall 363 of Postgres 16's settings.regnamespace,regrole,regtype,regprocand the other reg types read a string as its oid and show an oid as its name (23::regtypeisinteger,'public'::regnamespace::intis 2200). - Oids: a relation's, an index's, a constraint's, a schema's (
publicis 2200), an enum type's or domain's, a collation's and a row type's is worked out from its kind and name (FNV-1a of the name, in a range of 2^26 per kind from 2^26 up, the next free one where two names meet), so it is the same in every world and after other objects are made or dropped, and changes with a rename; functions made here have oids of their own from 2^30 up; built-in types, functions and catalog tables have Postgres's oids. - An enum column's
data_typeisUSER-DEFINEDand itsudt_schemaandudt_namethe enum's, as in Postgres. A primary key of several columns makes each of its columnsNOT NULLhere. Temporary tables aren't listed. - Checked against Postgres 17 (tests/pgdiff.rs, tests/schemas.rs): listing schemas, tables, views and sequences; columns with their types, nullability and defaults; constraints and indexes through each table and view above; and the reflection queries SQLAlchemy 2.0 sends (tables, primary keys, unique constraints, foreign keys, checks, indexes with expressions and
WHERE, columns) and Prisma's schema describer (namespaces, tables, constraints, columns, foreign keys, indexes, sequences, views), written into the tests. The ORMs themselves weren't run against it. - Not yet:
NOT NULLconstraints as rows oftable_constraints(Postgres 17 lists them), operator classes inpg_opclass, the built-in operators inpg_operator, the catalog's own tables inpg_classandpg_attribute, andpg_relation_size.
Storage
Tables made with CREATE TABLE store rows packed: each column's value in schema order, length-prefixed, with no names to match and no numbers to parse, about half the size of JSON. Everything outside SQL still sees JSON objects: get, scan, find, diff, merge conflicts, the HTTP API, the shell and the search index. Rows written as JSON through those (put) are read by SQL too. Tables made by writing JSON rows stay JSON.
Subqueries, UNION and WITH
select name from customers c
where exists (select 1 from orders o where o.customer = c.id and o.total > 50);
select c.name, (select count(*) from orders o where o.customer = c.id) from customers c;
with paid as (select * from orders where status = 'paid')
select customer, sum(total) from paid group by customer
union all
select null, sum(total) from paid;
- Subqueries: as a value (
(select max(x) from t)), inIN (select ...), and inEXISTS, anywhere an expression goes, includingUPDATEandDELETE.- Correlated subqueries (ones that read the outer row) run once per distinct set of outer values they read. A correlated
EXISTS,NOT EXISTSorINwhose conditions compare its own columns with the outer row by=runs once, not once per outer row (see Speed). - Parameters inside them get their types from the subquery's own tables.
- Correlated subqueries (ones that read the outer row) run once per distinct set of outer values they read. A correlated
- Set operations:
UNION,INTERSECTandEXCEPT, each with or withoutALL, parts in parentheses allowed.ORDER BY/LIMITapply to the whole and name output columns. Result types widen as in Postgres (1and2.5give double precision). - Subqueries in FROM:
(select ...) as toras t(a, b), joined, filtered and grouped like tables. WITH:WITH name [(columns)] AS (select ...)names queries for the main query, laterWITHqueries, and subqueries. A statement runs each once, however many times it's used, and every use reads the same rows (as Postgres does for a query used more than once:nextval()orrandom()in it gives one value to all its uses). A subquery run for each outer row, correlated orLATERAL, runs its ownWITHqueries each time.WITH RECURSIVE:name AS (base UNION [ALL] step), for trees and graphs. The step reads the rows the last round added, and rounds stop when a round adds nothing new.UNIONdrops rows already seen, which ends cycles. A query that never stops is refused after 10,000 rounds or 1,000,000 rows. Each round joins from the rows the last round added, looking the next ones up by index (with an index on the edge column, a small traversal of a 200,000-edge graph takes about 1 ms).ANY,SOMEandALL:x op ANY (select ...),ALL (...)with any comparison, over a subquery,ARRAY[1, 2], a Postgres array literal'{1,2}', or a JSON array.= ANYand<> ALLareINandNOT INand share their index lookups. NULLs make the answer unknown, as in Postgres, and over nothingANYis false andALLtrue.- From drivers:
where id = any($1)takes a list however the driver sends it: a typed array, text or binary (int8[],text[]: psycopg 3, asyncpg, pgx, Rust'spostgres), array text ('{1,2,3}', node-postgres), or JSON (over HTTP). Chronos tells drivers that$1is an array of the column's type.
- From drivers:
WITHbeforeINSERT,UPDATEandDELETE: the names reach the statement's query and its subqueries.- Data-changing
WITHqueries:WITH x AS (INSERT | UPDATE | DELETE ... RETURNING ...) SELECT | INSERT | UPDATE | DELETE ...(what PostgREST and Hasura write for every mutation, and the archive-a-row patternWITH moved AS (DELETE FROM a RETURNING *) INSERT INTO b SELECT * FROM moved). The statements run once each, first to last, before the one that reads theirRETURNINGrows, which a statement withoutRETURNING(or a name nothing reads) simply doesn't give; all of it takes effect or none of it does (in a transaction, with it). They must be at the top: in a subquery it is 0A000, as in Postgres. Postgres shows every statement of theWITHthe tables as they were before any of them changed anything; here a statement sees what those before it wrote, so one that reads a table an earlier one changes (itsSELECTs, or the rows anUPDATEorDELETElooks through, by name or through a view) is refused (0A000) rather than answer differently: read the rows through theWITHquery'sRETURNINGinstead.UPDATE ... FROMorDELETE ... USINGwithRETURNINGin aWITHquery,EXPLAIN,PREPAREinside a function's body, and a temporary table as a target are 0A000. An agent needs the rights to write for one, as for any write. UPDATE ... FROMandDELETE ... USING: other tables (or views) to match against, as in Postgres. With several matching rows,UPDATEtakes the first one's values.MERGE INTO:MERGE INTO target [AS t] USING source [AS s] ON condition WHEN [NOT] MATCHED [AND condition] THEN UPDATE SET ... | DELETE | INSERT [(columns)] VALUES (...) | DO NOTHING, ...as in Postgres 16, told apart from Chronos'sMERGE BRANCH/MERGE WORLDby theINTO. The source is a table, a view or a(subquery); each source row is matched to the target rows theONcondition finds and runs the first clause that fits (WHEN MATCHEDfor a row found,WHEN NOT MATCHEDfor one that isn't, each with its ownANDcondition). It's one write: every clause takes effect or none does (in a transaction, with it), and it answersMERGE n, the rows it changed. A target row that more than one source row would change is 21000 (MERGE command cannot affect row a second time), and nothing changes. Parameters ($1) work in the source, the conditions and the values, through a driver orPREPARE. A target without a primary key has its rows matched by all their columns. A temporary table as the target reads only temporary tables and VALUES in its source. A view as the target (0A000 in Postgres 16 too),EXPLAIN MERGE,INSERT ... OVERRIDINGandINSERT DEFAULT VALUESinside a clause are refused (0A000). Postgres 17'sWHEN NOT MATCHED BY SOURCEandRETURNINGare a syntax error here, as in 16. An agent needs the rights to write for one, as for any write.- Set-returning functions in FROM:
generate_series(from, to[, step])(numbers, or timestamps and dates with an interval step),unnest(array),generate_subscripts(array, 1[, reverse]),jsonb_array_elements(_text),jsonb_each(_text)andjsonb_object_keys. They can read the tables before them, asLATERAL(from orders o, jsonb_array_elements(o.items) item), and a one-column function's name is its column (select g from generate_series(1, 5) g). - Arrays are kept as Postgres array text, in values and in array columns:
ARRAY[1, 2],'{a,b}'::text[],array_agg, forANYandunnest(see Arrays above). LATERAL:from customers c, lateral (select ... where o.customer = c.id order by at desc limit 3) recent, or[LEFT] JOIN LATERAL (...) x ON .... The subquery reads the tables before it and runs once for each distinct set of values it reads from them.
Window functions
select name, dept, pay,
rank() over (partition by dept order by pay desc),
sum(pay) over (partition by dept order by hired), -- running total
avg(pay) over (order by hired rows between 6 preceding and current row) -- moving average
from staff;
- Functions:
row_number,rank,dense_rank,percent_rank,cume_dist,ntile.lagandlead(with offset and default),first_value,last_value,nth_value.count,sum,avg,min,max, withOVER (PARTITION BY ... ORDER BY ...).
- Frames:
- Default, as in Postgres: with
ORDER BY, from the partition's start to the current row's last tie; without it, the whole partition. - Explicit:
ROWS BETWEEN n PRECEDING / UNBOUNDED ... AND CURRENT ROW / n FOLLOWING / UNBOUNDED, andRANGEwithUNBOUNDED,CURRENT ROWor an offset in the oneORDER BYcolumn's units (RANGE BETWEEN 5 PRECEDING AND CURRENT ROW,interval '7 days' PRECEDINGon dates and timestamps).
- Default, as in Postgres: with
- With
GROUP BY: windows run over the groups, e.g.rank() over (order by sum(x))orsum(sum(x)) over (). - Where they can go: only in the select list and
ORDER BY(42P20 elsewhere). - Named windows:
WINDOW w AS (partition by ... order by ...), thenOVER w, orOVER (w ORDER BY ...)to add an order, as in Postgres. FILTER (WHERE ...)works on window aggregates too.GROUPSframes andEXCLUDE:GROUPS BETWEEN 1 PRECEDING AND CURRENT ROWcounts groups of peer rows (it needsORDER BY, 42P20 otherwise), and a frame may endEXCLUDE CURRENT ROW | GROUP | TIES | NO OTHERS, for aggregates and forfirst_value,last_valueandnth_value(which take the first, last or nth row left). With an exclusion an aggregate is worked out again for each row, not slid along the frame.- Not yet:
DISTINCTinside a window aggregate.
Branches in SQL
create branch agent_7; -- instant copy of the current branch (or: from main)
use branch agent_7; -- this connection now works on it
update people set age = 37 where id = 1;
diff; -- table, id, change, before, after (jsonb)
merge branch agent_7; -- into its parent; you move back to the parent
show branches; -- name, parent, changes
drop branch agent_7; -- throw it away
- Connecting to a branch: it's a database, so
psql postgres://127.0.0.1:5433/agent_7works. - Branch names with dashes need quotes:
create branch "agent-7". - Merge conflicts: if both sides changed a row,
MERGE BRANCHfails with SQLSTATE 40001, lists the rows and merges nothing. CheckDIFF, then choose:MERGE BRANCH agent_7 USING OURSkeeps the branch's rows.USING THEIRSkeeps the parent's rows.
- After a crash: a branch that was open needs
MERGE BRANCH agent_7 CONFIRM, once you've checked itsDIFF.
Worlds
The same statements with WORLD, plus metadata and IDs (see concepts):
create world agent_7 with (owner = 'claude', task = 42); -- from the current world (or: from main)
fork world agent_7 as agent_7b meta '{"tags": ["retry"]}'; -- from a named world
switch world agent_7b; -- or: use world '<its id>'
alter world agent_7 set (task = null, reviewer = 'ada'); -- null removes a key
alter world agent_7 set ttl '2 hours'; -- discarded 2 hours from now (unless it has forks then)
alter world agent_7 reset ttl; -- never expires (agents need admin for either)
alter world agent_7 set pinned; -- never discarded for being idle (see world_idle_ttl); reset pinned undoes it
show worlds; -- name, id, parent, depth, created, version, meta (jsonb), owner, expires, active, pinned
show storage; -- per world: rows changed since its fork, and the pages and bytes only it holds
show storage for world agent_7;
show disk; -- bytes of pages in use, kept only for history, reclaimable; and the log's
diff world agent_7b; -- table, id, change, before, after, columns (what changed)
diff world agent_7b as sql; -- the same as statements: run them on main to make the change
diff world agent_7b readers; -- what reads each changed column (views, checks, keys, clients seen reading...), and what that can't see
register reader nightly_export on orders (status, total); -- a reader outside SQL, named by hand
show readers; -- registered readers, and the clients seen reading which columns
diff world a to b; -- between any two worlds, or moments (a@-1 hour)
merge world agent_7b dry run; -- what the merge would do, row by row, and why rows conflict (attached: checked against live Postgres too)
merge world agent_7b by columns resolve ('orders/7' = theirs, 'orders/9' = '{"id": 9, "status": "paid"}');
merge world agent_7b only tables (orders); -- just these; agent_7b stays open with the rest
merge world agent_7b only keys ('orders/7', 'orders/9');
merge world agent_7b into agent_7; -- into another world, three-way against agent_7b's fork point; it stays open
merge world agent_7b;
drop world agent_7; -- or: delete world agent_7; refused while it has forks
drop world agent_7 cascade; -- it and every world forked from it, deepest first
- Partial merges: rows merged with
ONLYaren't merged again, but a later change to one by either side conflicts. A row whose unique value or reference is tied to a row left out, or whose table the world changed, is refused (SQLSTATE 22023). See concepts. INTO: any live world but the one merged. Into its own parent, it's the ordinary merge.- Merge checks: queries a merge's result must find nothing in, run inside every merge (SQLSTATE 23514 when one finds rows, with the rows). See merge checks and the guide.
create merge check lists_keep_leads on tables (leads, lists) with (timeout = '2s') as
select id from lists where id not in (select list from leads);
show merge checks; -- name, tables, timeout_ms, query, created
drop merge check lists_keep_leads;
Databases
A database is a world, so the statements tools send to make one work:
create database app; -- an empty world with no parent: nothing of main's
create database app_test template app; -- a fork of the world `app`: instant at any size
create database "test_x" encoding 'UTF8' lc_collate 'C' owner = chronos;
drop database app_test; -- or: drop database if exists app_test (force)
- Connect to one by name (
dbname=app), as to any world.postgresandtemplate1, where Rails, Django and Prisma connect to make the others, aremainunless a world is named so;template0andtemplate1as aTEMPLATEmean an empty database. - A database made with
TEMPLATEis a fork of its template, so the template can't be dropped while it exists (2BP01;DROP WORLD ... CASCADEdrops both). A database made without one has no parent: it can't be merged (0A000), and idle expiry leaves it alone. - Checked as Postgres checks them: 42P04 for a name in use (
postgres,template0,template1andmaintoo), 3D000 for a missing template or database, 42601 for an option it doesn't know or one given twice, 22023 for a bad strategy, 42704 for an unknown encoding or tablespace, 55006 for dropping the database the session is in, 42809 fortemplate0, and 25001 inside a transaction block. An agent needs admin rights (42501). OWNER,ENCODING 'UTF8'(any spelling),STRATEGY,TABLESPACE pg_default,LOCALE_PROVIDERandOIDare accepted and change nothing. Options that would change what a query returns or who may connect are accepted with a warning that they aren't applied: aLOCALE/LC_COLLATEother thanC(text sorts by bytes),CONNECTION LIMIT n,ALLOW_CONNECTIONS false,IS_TEMPLATE true. Another encoding is 0A000.DROP DATABASE ... (FORCE)is accepted but doesn't cascade to worlds forked from it.ALTER DATABASEisn't parsed.pg_databaselists them;current_database()is the world's name.
Time travel
Any table can be read as it was at a moment in the retention window (1 hour for a new database unless you change it):
select * from orders as of '2026-09-20 10:00'; -- UTC unless the time says otherwise
select * from orders for system_time as of $1; -- the SQL:2011 spelling, with a parameter
select * from orders as of '-15 minutes'; -- a time ago
-- now against then, in one query
select now.id, now.status, old.status
from orders now join orders as of '-1 hour' old on old.id = now.id
where old.status <> now.status;
insert into orders select * from orders as of '-1 hour' where id = 42; -- bring a row back
restore world main to '-1 hour'; -- put a whole world back (an ordinary write: history keeps what it replaced)
fork world 'main@-1 hour' as check_it; -- a world holding main as it was: diff it, or merge it to restore
diff world 'main@-1 hour' to main; -- what changed since
undo merge of world agent_7; -- put back every row its latest merge changed
undo merge of world agent_7 skip changed; -- ...except rows changed again since (else they're named)
undo agent bot since '-2 hours'; -- put back every row agent bot changed in this world since then
undo agent bot since '-2 hours' skip changed; -- ...except rows someone changed after it (else they're named)
show history limit 20; -- at, world, event (write, commit, forked ..., merged ...), rows
alter system set history_retention = '7 days'; -- or '0' to keep none (a new database keeps 1 hour)
show history_retention;
alter system set synchronous_commit = normal; -- full (default), normal or off
show synchronous_commit;
- A table AS OF comes with its schema as it was then, and tables dropped since can still be read.
- A whole session in the past:
use world 'main@2026-09-20 10:00', or connect to the databasemain@2026-09-20 10:00with psql. It's read only (SQLSTATE 25006 on writes). - Times: a fixed time is rebuilt from history once, then cached; a time ago (
-15 minutes) is a new moment each time it's run. See concepts.
Simulations
simulate 1000 worlds from main as trial
run $$update prices set p = p * (0.9 + random() * 0.2) where sku % 100 = $1 % 100$$
score $$select sum(p * sold) from prices$$
keep 10 seed 42; -- world, id, index, seed, score, error, kept: best first
replay world trial_17; -- world, replay, identical, rows_differing, score, recorded_score, error
replay world trial_17 as trial_17_again; -- ...and keep the replay
select setseed(0.42); select random(); -- a repeatable random() for this session
- What it does: forks n worlds (
trial_0,trial_1, ...) from one moment of the base, runs the script in each on every core, then the score query in each, keeps the bestKEEP kand discards the rest.- Base:
FROM world, or the session's world;FROM 'main@-1 hour'starts from the past. - Best: the highest score first;
ASCfor the lowest.KEEP ALL, the default, keeps every world. - Parameters: in the script and the score query,
$1is the world's index and$2its seed. - Strings: the script and score can be dollar-quoted,
$$...$$or$tag$...$tag$, as in Postgres.
- Base:
- Failures: a world whose script or score fails is reported with its error and SQLSTATE, and the rest carry on. It's discarded, unless
KEEP ALL. The score query must be one SELECT whose first value is a number. - The same every time: in each world,
random(),gen_random_uuid()and the keys of tables without one followSEED(0 unless given) and the world's index, andnow()(withcurrent_timestamp,current_date, ...) is the base moment. So the same statement from the same moment gives the same worlds and scores on any machine, with any number of threads (THREADS n; every free core by default).- A seeded statement runs on one core; the worlds run side by side.
- Not covered:
serialnumbers, which come from the database's counters that every world shares (so merged worlds never collide), and so differ between runs. Use$1orgen_random_uuid()for keys.
- What a script can't do: fork, merge, restore, switch or drop worlds, or change settings (0A000). Transactions are fine.
- Replay: each kept world holds its inputs in its metadata,
sim: the seed, index, script, score query, score, and the base world and exact moment.REPLAY WORLD wforks the base as it was then (time travel), runs the script and score again, and says whether the rows and the score are identical.- The replay is thrown away, unless
AS namekeeps it. - It needs history back to that moment: not an in-memory database, nor past the retention window.
- Agents: the worlds are the agent's own and count toward its
max_worlds. They run in batches that fit, keeping the best so far; aKEEPthat can't fit is refused before anything runs (42501). Each world's statements are checked as the agent's, and its row quota applies in each world. - Size: worlds are forked 1,024 at a time, and the losers go after each batch, so about
KEEP+ 1,024 worlds are alive at once. 10,000 worlds that each insert a row take 0.4 s; 10,000 that each update 1,000 rows take about 30 s (8-core laptop busy with other work, release build). setseed(x)(x from -1 to 1) seeds the session'srandom()andgen_random_uuid(), as in Postgres, until the session ends or it's called again.
Metrics
SHOW METRICS lists each kind of operation since the database opened, with its count and latency in milliseconds (total_ms, p50_ms, p95_ms, p99_ms, max_ms), then counters (rows_written, errors, conflicts, worlds, bytes on disk) with only a count. See docs/operations.md.
Backups
backup database to '/backups/db-1'; -- files, bytes: a copy of the running database in a new folder
verify database; -- pages, bytes, log_records: every page and log record read and checked
See docs/operations.md for restoring (chronos restore).
Agents
create agent bot with (can = 'read,fork,write_own', max_worlds = 10, world_ttl = '1 day'); -- returns its token, once
alter agent bot set (writes_per_minute = 600, disabled = false);
alter agent bot set (max_query_ms = 30000, max_concurrent = 4, max_memory_mb = 512);
show agents; -- name, id, can, quotas, world_ttl, created, disabled, limits
show agent bot privileges; -- what bot may read, write and merge, table by table
show agent bot privileges in world w; -- the same, for world w's tables
show audit for agent bot limit 20; -- at, agent, world, action, rows
show merge outcomes; -- how each agent's worlds ended, and how often its merges were held or refused
show merge outcomes by policy; -- the same, a row per merge policy
checkpoint world bot_task as 'before'; -- name a moment of a world...
restore world bot_task to checkpoint 'before'; -- ...and go back to it
drop agent bot; -- its token stops working; its worlds stay
See concepts for what agents may do. The names guest and system are the database's own.
SHOW AGENT name PRIVILEGES puts together everything that decides what an agent may do with a table: its capabilities,
the privileges of a role of its name (both must allow), row-level security, masked columns and its
merge policy. A row per table of the world (the session's, or IN WORLD w), and one per column that differs from its
table (a column its role was granted SELECT, INSERT or UPDATE on by itself, a masked one, one its policy's
review_columns names):
| column | what it says |
|---|---|
read | yes, no, rows its policies allow (row-level security), or for a column masked (hash) |
write | which of insert, update and delete, and where: its own worlds, any world but main, main |
merge | on its own, on its own within policy p, waits for a person, refused (not in the policy's tables), not into main, or a person merges |
why | the rules behind the row, in words |
It reads the same rules the agent's statements meet; it runs nothing as the agent. An agent may show its own; another
agent's needs admin.
SHOW MERGE OUTCOMES reads the history window (show history_retention; since is where it starts) and counts each
world once, by how it ended:
| column | a world that ... |
|---|---|
on_rules | its owner merged, keeping to a merge policy, with no hold |
no_policy | its owner merged, keeping to no merge policy (the database's own users, or an agent without one) |
approved | someone other than its owner merged: a person, after a hold or without one |
rejected | was dropped after a hold |
discarded | was dropped with no hold |
open | is still open, nothing merged |
partly_merged | is still open, some of it merged (ONLY TABLES, ONLY KEYS, or INTO another world) |
before_window | was forked before the window: counted, not classed |
and counts attempts: conflicts (rows both sides changed), stale_reads (rows it relied on changed since it read
them), check_refusals (a merge check or a CHECK constraint the result breaks) and holds (a merge policy sent it to a
person), and effects_discarded: effects (NOTIFY ON MERGE) dropped with their worlds, never sent (see SHOW OUTBOX DISCARDED). A merge retried in a loop adds attempts, never worlds. rules_rate is on_rules / (on_rules + approved + rejected): the share of decided worlds that merged on their rules alone (NULL when none were). agent is NULL for the
database's own users; policy is each agent's policy now. A transaction's COMMIT that conflicts isn't counted (it's no
agent's merge).
Its limits: it replays the whole retained log, so it costs what SHOW AUDIT costs: 0.47 s over 1,022,531 log records
(340,000 worlds) on the 16-core test VM, 2026-10-16. A refused merge logs its world's metadata once (about 65 bytes
for a world with only the counts), and refusals don't count toward an agent's writes_per_minute, so an agent
retrying a refused merge in a loop grows the log by that much each time. A merge undone later with UNDO MERGE still counts as merged (the undo is logged as an ordinary write). After a crash, refusals since the last sync to
disk may be missing; merges never are. An in-memory database keeps no log: no rows, and a notice says so. Only the
database's own users may run it: an agent's session gets 42501.
Roles and privileges
Postgres's roles and grants, beside agents:
create role app login; -- a role a program logs in as
create role readers; -- a group
grant readers to app; -- app has readers' privileges (INHERIT), may SET ROLE to it
grant usage on schema s to readers;
grant select on s.orders to readers;
grant insert (note), update (note) on s.orders to app;
revoke execute on function s.f() from public;
alter default privileges for role app in schema s
grant select on tables to readers; -- what app makes in s from now on, readers may read
alter table s.orders owner to app;
set role app; -- current_user is app until RESET ROLE
select has_table_privilege('s.orders', 'insert'), pg_has_role('readers', 'member');
- What's checked: reading and writing rows (by column too), sequences, function calls, naming things in a schema and making things in it, and owning what's dropped or altered, as Postgres checks them; a view reads its tables with its owner's privileges, and a
SECURITY DEFINERfunction runs as its owner. See Postgres compatibility for the whole list and what isn't checked yet. - Who is a superuser: a role made
SUPERUSER, and every login whose name is no role: every login was a superuser before roles, and still is, so a database that never makes a role works as before. Objects such a login makes are owned bychronos, the bootstrap superuser. - Roles are the database's, grants the world's: every world sees the same roles; a world's tables carry their owners and grants through forks and merges, and default privileges (
ALTER DEFAULT PRIVILEGES) fork and merge with the world too. Role statements take effect at once, even inside a transaction. - The past: in a world as it was (
main@<moment>), a role that isn't a superuser needs the privileges a relation had then and those it has now (aREVOKEholds for the past too); a relation with no relation of its name now (renamed, moved or dropped since) has no privileges now to check, so only a superuser reads it there. - Agents keep their capabilities; an agent with a role of its name has that role's privileges too, and needs the
admincapability to make or grant roles. Roles that aren't superusers can't touch worlds (fork, merge, restore,DIFF): give a program an agent for that. - Passwords are kept as SCRAM-SHA-256 verifiers, never in the clear (
PASSWORD 'x'), withscram_iterationsiterations (4096 unless set) of the password after SASLprep, as Postgres keeps them, and a role with one logs in with it: SCRAM-SHA-256, or MD5 for one kept as an md5 hash (SET password_encryption = 'md5'). An agent's token has a verifier too, made byCREATE AGENTwith the session'sscram_iterations.
Row-level security
Postgres's policies, on the same roles: a table with row security shows each role only the rows a policy lets it see, and takes only the rows a policy lets it write.
alter table notes enable row level security;
create policy tenant on notes using (tenant = current_user); -- every command, every role
create policy org on orders using (org = current_setting('app.org_id')::int);
create policy read_shared on notes for select to readers using (shared);
create policy no_drafts on notes as restrictive for update using (not draft) with check (not draft);
set app.org_id = '42'; -- per session (or SET LOCAL per transaction)
- Who they apply to: every role but a superuser, a role with
BYPASSRLS, and the table's owner (and members of the owner role), unlessALTER TABLE ... FORCE ROW LEVEL SECURITY. With no policy that applies, a role sees no rows and writes none. - How they combine: a command's permissive policies (the default) are ORed, its restrictive ones ANDed with them, and one permissive policy at least must allow a row.
USINGdecides the rowsSELECT,UPDATEandDELETEsee (the others are passed by, not refused);WITH CHECK(orUSING, for a policy without one) the rowsINSERTandUPDATEwrite, which are refused with 42501 (new row violates row-level security policy for table "t"). Where a statement reads the table (aWHERE,RETURNING,ON CONFLICT DO UPDATE), its rows must also pass theSELECTpolicies, as in Postgres 16. - Everywhere rows are read: joins, subqueries wherever they are,
WITH(RECURSIVEtoo), views (as the view's owner, whose policies they are, whilecurrent_userstays the one asking), functions inFROMand their arguments, PL/pgSQL's expressions andEXECUTE,COPY ... TO,INSERT ... ON CONFLICT,UPDATE ... FROM,DELETE ... USINGandMERGE;REFRESH MATERIALIZED VIEWreads as the view's owner. A policy's own subqueries are read with the privileges and policies of the role reading, so a policy that reads another table's policy that reads it back is 42P17.COPY ... FROMinto a table whose policies apply is 0A000 (useINSERT). Foreign keys, unique keys andTRUNCATEsee every row. SET row_security = offmakes a statement the policies would limit fail (42501) instead, which is what pg_dump sets; a role that bypasses them is unaffected.- Partitions and inheritance: a parent's policies apply when the parent is read, a partition's or child's own when it is read directly.
- Worlds: policies and a table's row security are rows of the world, as its schema is: a fork has its parent's,
MERGE WORLDbrings a world's (two worlds adding policies to one table merge without a conflict),DIFFshows them (AS SQLtoo), andRESTOREputs them back as they were. A readAS OFa moment gets the policies the table had then, and those its name has now. - What a policy reads is bound when it's made: a table or function its maker's
search_pathfinds outside public is written with its schema in the policy, and its other names are public's (a built-in function's name, the built-in), whatever thesearch_pathof whoever reads; the tables it reads are the world's, never a temporary table of the name. Renaming a column it reads (in a subquery, or from another table's policy too), or a table or view it reads, renames it in the policy (through a subquery's*or aNATURALorUSINGjoin it can't: drop the policy first); dropping a column, table or view a policy reads needsCASCADE, which drops the policy. - Cost: a policy is parsed once, and a prepared statement is rewritten and planned once, again only when a policy, a table's row security, a view or a role it depends on changes, or another role runs it: a point read of a table with a tenant policy costs about what a plain one does. A table with policies joined to others keeps its index reads where its policies can go first (the join's conditions on it compare columns, parameters and constants).
- The row API: the HTTP and MCP
get,find,put,delete,batch,diffandmergework on rows as they are, so an agent whose role (a role of its name) a policy limits on a table is refused them there (403, 42501): it reads and writes those rows with SQL. - The catalog:
pg_class.relrowsecurityandrelforcerowsecurity,pg_tables.rowsecurity,pg_policyandpg_policies; psql's\dshows a table's policies and\dptheir column, andpg_dumpwrites them, which restore.
Masked columns
A masked column's values are never shown to an agent: it reads a keyed hash of each value instead, or NULL, or the
last four characters. People, and agents given the unmask capability, read the values as they are.
alter table customers alter column email set masked; -- WITH HASH, the default
alter table customers alter column phone set masked with partial; -- '******1234'
alter table customers alter column notes set masked with null;
alter table customers alter column email set masked with format; -- 'qzt@xkwrpla.mzc': same shape, same length
alter table customers alter column email drop masked;
alter agent support_bot set (can = 'read,fork,write_own,unmask'); -- this one reads them as they are
- What an agent reads:
HASHgivesm_and 24 hex digits, the same for the same value in every table, so an agent can still join, group and count distinct values (customers.email = leads.email) without seeing one. The hash is keyed by a secret the database keeps on main, where neither SQL nor the row API reaches, so it can't be looked up in a list of hashes of likely values, and no SQL function makes one, so an agent can't hash a guess to compare. - No oracle through filters: a read of the table becomes a subquery that masks first, so
WHERE, joins,ORDER BY,LIKEand every function above it see the mask, never the value:where email like 'a%'tests the hash. Row-level security's policies run inside, on the values as they are. - Everywhere it reads: joins, subqueries,
WITH, views (whoever owns them), functions' statements,COPY ... TO(a table is copied as aSELECTof it),AS OFreads,DIFFandMERGE ... DRY RUN(their rows show masked values masked), and a constraint's error doesn't show the table's key. - What is refused (42501), because it would read the values as they are: an
UPDATE,DELETEorINSERT ... ON CONFLICT/RETURNINGwhoseWHERE,SETorRETURNINGreads a masked column of the table it writes (update customers set tier = 'gold' where id = 5is fine;where email = '...'is refused), the table's whole row included (returning row_to_json(customers));MERGEinto the table; changing its definition, indexes, triggers, rules or policies (creating, dropping or renaming one);search()over it;DIFF ... AS SQL;FOR UPDATEon a table without a key whose columns include a masked one (its rows are found again by their values); and the HTTP and MCP row API (get,find,diff, ...) on it: the agent uses SQL. FORMAT: each ASCII letter becomes a letter of the same case, each digit a digit, any other letterx, and everything else (@,.,-, spaces) stays where it was: an email stays email-shaped and the same length, for agents that check formats. LikeHASHit's keyed and the same for the same value, so joins andGROUP BYwork; unlikeHASHit shows the value's shape and length.- Copies a person makes follow: a table made with
CREATE TABLE AS, or a materialized view, from masked columns masks its copies of them: a column copied as it is keeps its mode, one worked out from masked columns is masked withHASH(NULLif one it reads is masked withNULL), the rest aren't. A query too involved to follow column by column (UNION, a subquery or function inFROM) that reads a masked column masks every column withHASH;DROP MASKEDtakes off what isn't needed. (An agent's copy is made from the masks it reads.) - Generated columns follow: a generated column made from a masked one (
exposed text generated always as (email) stored), whether it was there first or added later, is masked too: with NULL if one it reads is masked with NULL, else with HASH. - Still a table:
SELECT ... FOR UPDATElocks the rows of a masked table as of any (by its key), andctidreads. - Who reads masks: a session with an agent's token (Postgres login, HTTP, MCP over HTTP),
serve --safe's guest,chronos mcp <folder> --agent NAME, and a plainchronos mcp <folder>: it acts as the folder's owner but serves a model, so its SQL reads masks and its row tools (get,find,diff, ...) are refused on tables with masked columns.chronos mcp <folder> --unmaskgives that model the values. - The mask is the column's: it forks, merges, renames and drops with the column,
DIFFshows it as a change to the table, and a partition or child of a masked table has its masks too. - What it doesn't hide: an agent may write a masked column, and an agent that may insert into a table with a unique
masked column can tell whether a value is there (the insert fails):
SHOW AGENT name PRIVILEGESsays so on the column (an insert reveals whether a value is there). An existing trigger that copies a value elsewhere copies it as it is, and a person who copies values withINSERT ... SELECTinto a table of their own makes a copy masks don't follow (mask its columns too). Give agents that mustn't learn the values no write on such tables, or put them under a merge policy'sreview_tables.SHOW AGENT name PRIVILEGESshows each masked column as the agent reads it.
Limits for the whole database
alter system set statement_timeout = '30s'; -- every session starts with it (0: none, the default)
alter system set max_worlds = 10000; -- live worlds besides main (0: no limit, the default)
alter system set world_idle_ttl = '7 days'; -- unused worlds are discarded (0: never, the default)
alter system set protect_main = on; -- main's rows change only by merging a world (off by default)
alter system set outbox_keep_payloads = on; -- discarded effects keep their payloads, not only a hash (off by default)
show max_worlds;
show world_idle_ttl;
show protect_main;
They're kept in the folder across restarts, as synchronous_commit is. Past max_worlds, a fork is refused with SQLSTATE 53400. See operations.
protect_main: with it on, nobody changes main's rows directly, the database's own user and admins included: an INSERT, UPDATE, DELETE, COPY, TRUNCATE or HTTP put there, or a transaction's COMMIT that changes rows of main, is refused with 42501, naming a row. Rows reach main by merging a world, so a mistaken DELETE is a diff someone sees (and a merge policy can hold) before it lands. Still direct: definitions that change no rows (CREATE TABLE, CREATE INDEX, ALTER TABLE ... ADD COLUMN without a default to fill), the database's own rows (agents, merge policies and checks, the outbox), and the ways back (RESTORE WORLD, UNDO MERGE, UNDO AGENT). Turning it off is ALTER SYSTEM SET protect_main = off, which needs the database's own user or admin.
Transactions
BEGIN, then statements, then COMMIT or ROLLBACK. Drivers' transaction APIs work as they do with Postgres.
- How it works: a transaction is a hidden branch.
- Snapshot: at
READ COMMITTED(the default, as in Postgres) each statement reads what was committed before it began, plus the transaction's own writes; atREPEATABLE READthe transaction reads the database as it was atBEGIN, plus its own writes. - Invisible until COMMIT: nobody else sees its writes before
COMMIT. - COMMIT is durable, like a merge.
- Writes wait for each other, as in Postgres: an
UPDATE,DELETEorSELECT ... FOR UPDATEtakes the rows it changes or locks until its transaction ends (outside one, until the statement ends), and a statement that meets a row another transaction holds waits for that transaction to commit or roll back (up tolock_timeout: 55P03; orstatement_timeout, or a cancel: 57014). Two transactions that wait for each other in a cycle get one 40P01 (deadlock detected). Then the statement reads the row as the other transaction left it: atREAD COMMITTEDit runs again on what is committed then (what Postgres's EvalPlanQual does: a row that no longer matches theWHEREis skipped, one that still does is updated from its newest version, a deleted one is skipped), and atREPEATABLE READit fails with 40001 (could not serialize access due to concurrent update) at the statement, as Postgres's does. A counter row that many clients update (pgbench'spgbench_branches) makes them take turns and none fails. Rows that were only read aren't held, as in Postgres. - Conflicts at COMMIT: what takes no row intents (a merged world, a change through the API, an
ALTER TABLEof a table both changed) can still makeCOMMITor the next statement fail with 40001; two transactions that insert one primary key or unique value make the second to commit fail with 23505, at its next statement or atCOMMIT, where Postgres makes it wait at theINSERT. A row a transaction inserted that another transaction deleted its parent of fails atCOMMITwith 23503, where Postgres makes the deleter wait. Retry on 40001 and 40P01, as you would for Postgres. - Row locks:
SELECT ... FOR UPDATE(alsoNO KEY UPDATE,SHARE,KEY SHARE,OF table,NOWAIT) holds the rows it returns until the transaction ends, in Postgres's modes (FOR KEY SHAREconflicts only withFOR UPDATE;FOR SHAREwithFOR UPDATEandFOR NO KEY UPDATE; shared locks don't clash with each other), and a statement that wants one in a mode that conflicts waits. Of two transactions that lock the same unit, check it's free and hold it, the second waits for the first and then sees the hold. AROLLBACK TOlets go of what was taken since its savepoint, and aRELEASEd savepoint's rows belong to the level it was released into. An error lets go of the transaction's row locks and transaction-level advisory locks at once, as Postgres's does (with savepoints, of those taken since the innermost one), though the transaction is refused (25P02) until itsROLLBACK.SKIP LOCKEDleaves out the rows another open transaction holds (or has changed or deleted), beforeLIMITandOFFSETcount, andNOWAITfails with 55P03 on one: a worker that takesSELECT ... FOR UPDATE SKIP LOCKED LIMIT 1in its own transaction never gets a row another worker is on. They work in a subquery or aWITHquery as well (WITH next AS (SELECT id FROM jobs WHERE state = 'new' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED) UPDATE jobs SET state = 'run' FROM next WHERE jobs.id = next.id). Not allowed withDISTINCT,GROUP BY, aggregates, window functions orUNION(0A000). A transaction that holds more than 10,000 rows holds their tables instead, so that big statements stay in bounded memory: others wait for any row of those tables. - Isolation levels:
BEGIN ISOLATION LEVEL ...,SET TRANSACTION ISOLATION LEVEL ...(before the first query: 25001 after),SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ...anddefault_transaction_isolationchooseREAD COMMITTED(the default;READ UNCOMMITTEDis it too, and says so inSHOW) orREPEATABLE READ;SHOW transaction_isolation,current_setting('transaction_isolation')andpg_settingssay which.SERIALIZABLEfails with 0A000: snapshot isolation lets write skew through, so a transaction that asked for more isn't handed less without a word. Lock the rows a decision rests on instead. A function a statement calls reads the statement's snapshot (Postgres gives a volatile function's own statements newer ones). - Savepoints:
SAVEPOINT name,RELEASE [SAVEPOINT] nameandROLLBACK TO [SAVEPOINT] name, nested as deep as you like. Each is a fork of the level before it:RELEASEmerges it back, andROLLBACK TOthrows its changes away and keeps the savepoint open.COMMITreleases any still open. - After an error: only
ROLLBACK,ROLLBACK TO SAVEPOINT(which makes the transaction usable again) orCOMMIT(which rolls back) is accepted, as in Postgres. - Not allowed inside a transaction: branch statements.
- Ending without COMMIT: a transaction still open when its connection or HTTP request ends is rolled back.
Advisory locks
Locks on numbers the application picks, as in Postgres: pg_advisory_lock(key), pg_advisory_xact_lock(key), pg_try_advisory_lock(key), pg_try_advisory_xact_lock(key), pg_advisory_unlock(key) and pg_advisory_unlock_all(), and the shared kinds of each (pg_advisory_lock_shared(key) and the others ending in _shared), with one bigint key or two integer keys (a separate set of locks). hashtext(text) gives Postgres's own hash, so pg_advisory_xact_lock(hashtext('setup:' || org_id)) locks the same key it would there.
- Who holds them: a session lock until
pg_advisory_unlockor the session ends; a transaction lock (_xact_) untilCOMMITorROLLBACK, or, outside a transaction, until the statement ends. A session can take its own lock again, and unlocks as many times as it took it. - Waiting: a lock another session holds waits, for as long as the statement may run (
statement_timeout, a cancel: 57014). Sessions that would wait on each other in a cycle fail at once with 40P01. Thetrykinds return false instead of waiting. Only a statement that writes nothing waits; one that writes (anINSERT ... SELECTthat takes a lock) fails with 55P03 when another session holds it, so take locks in aSELECTof their own. - What a transaction sees after the wait: a
READ COMMITTEDtransaction reads what is committed at each statement, so it sees what the holder committed. AREPEATABLE READone reads the database as it was atBEGIN, so after waiting for a lock that another transaction released at itsCOMMIT, it reads the database anew from the next statement on; one that already wrote (or is inside a savepoint) can't, and fails with 40001: take advisory locks before writing. - Shared locks: any number of sessions hold a key's shared lock at once; an exclusive lock waits for them all, and a shared one for an exclusive one held. The two modes of a key are two locks, as in Postgres:
pg_advisory_unlock_sharedlets go of the shared one, andpg_lockslists them apart (ShareLock,ExclusiveLock).
Maintenance, DISCARD and LOCK
Statements Postgres has for looking after storage and sessions. They're parsed as Postgres parses them (options in parentheses or in the older spelling, table lists with columns) and refuse what Postgres refuses: a name that doesn't exist (42P01, 42703, 3F000), the wrong kind of object (42809), ANALYZE missing from a VACUUM with a column list (0A000), and a transaction block where there mustn't be one (25001).
VACUUM,ANALYZE,REINDEX,CLUSTER: succeed and do nothing. Pages are copy-on-write, so there are no dead rows to reclaim; indexes are always current; no statistics are kept.VACUUMandREINDEX ... CONCURRENTLYare refused inside a transaction block, as in Postgres. A view or sequence given toVACUUMorANALYZEis skipped with a warning.CLUSTERdoesn't remember its index.DISCARD ALL: what a connection pool (pgbouncer'sserver_reset_query) sends between clients. It puts the session back as it was on connecting:SETvalues and custom settings (RESET ALL), the time zone,search_pathandstatement_timeout, prepared statements,LISTENs, temporary tables, session advisory locks,currval/lastval, and the world: the connection goes back to the world it logged in to (SWITCH WORLDis session state, so a pooled connection isn't handed on inside someone else's world). The worlds themselves are left as they are. Not inside a transaction (25001).DISCARD PLANSdoes nothing,DISCARD SEQUENCESforgetscurrvalandlastval,DISCARD TEMPdrops temporary tables (not inside a transaction: 0A000).COMMENT ON: kept for tables, views, materialized views, sequences, indexes, columns, table constraints and schemas (Comments); for any other object it checks nothing and says the comment isn't kept.LOCK TABLE: only in a transaction block (25P01). Writers here never take table locks, so nothing can be held against them. The modes that would keep writers out, or each other (ACCESS EXCLUSIVE, the default, andEXCLUSIVE,SHARE ROW EXCLUSIVE,SHARE UPDATE EXCLUSIVEandSHARE), take an advisory lock on the table's name untilCOMMITorROLLBACK: anotherLOCK TABLEon it waits (or fails at once with 55P03 underNOWAIT, or with 57014 at thestatement_timeout; a cycle of them is 40P01), but plain reads and writes go on, and a warning says so. That's what a tool that locks a table to run one migration at a time needs. The weaker modes (ACCESS SHARE,ROW SHARE,ROW EXCLUSIVE) stop nothing that isn't stopped already, so they only check the table. To keep writers out, takeSELECT ... FOR UPDATEon the rows, or an advisory lock that every writer takes.
Stopping a statement
SET statement_timeout = 5000(ms, or a string like'5s','500ms','1min') stops any statement of this session that runs longer, with SQLSTATE 57014, as Postgres does.0turns it off;RESET statement_timeout(orDEFAULT) goes back to the database's (ALTER SYSTEM SET statement_timeout, none unless set);SHOW statement_timeoutshows it.- An agent's
max_query_mscaps its sessions' timeout: it may set a lower one, never a higher one or none. - An agent's
max_memory_mb: a statement holding more rows than that is stopped with SQLSTATE 53200 (out of memory), and its sorts, groups and joins spill to disk at a quarter of it. - An agent's
max_concurrent: a statement past that many of its statements running at once is refused with SQLSTATE 53300. - Cancel requests (psql's Ctrl-C, a driver's cancel) stop the statement running on that connection, also with 57014.
- A client that disconnects stops its running statement too: nobody is waiting for the answer.
- How soon: statements check every few thousand rows, so they stop within milliseconds of the limit.
CHECK constraints
CHECK (condition) on a column or the table, with Postgres's default names (items_price_check, items_check, items_check1) or your own (CONSTRAINT name CHECK (...)).
- When they're checked: every insert and update, SQL or JSON, and every merge. A merge that brings in a
CHECKadded on a branch tests every row of the table, so rows the other side added meanwhile must pass too. - Null passes: a condition that comes out null (
price >= 0with a null price) doesn't stop the row, as in SQL. - Changing them:
ALTER TABLE ... ADD CONSTRAINT ... CHECKtests existing rows first;DROP CONSTRAINTremoves one. Renaming a column renames it inside its checks; dropping a column drops the checks that use it. - Not allowed: subqueries in a check (0A000), as in Postgres.
Functions and triggers
do $$ begin insert into audit values (now(), 'migrated'); end $$; -- an anonymous PL/pgSQL block
create procedure add_row(i int, s text) language plpgsql as $$ begin insert into t values (i, s); end $$;
call add_row(1, 'a');
call add_row(s => 'b', i => 2);
Parameter defaults: CREATE FUNCTION f(a int, b text DEFAULT 'x', c int = 3) (also for procedures): a call may leave out the last parameters (f(1), f(1, 'y')), or name the ones it gives (f(1, c => 7)); only the last parameters may have defaults (42P13), a default must be a value of its parameter's type (42804), and two functions a call fits equally well are 42725, as in Postgres. The default is worked out for each call. pg_get_function_arguments shows it, pg_get_function_identity_arguments doesn't, and pg_proc.pronargdefaults counts them.
DOand procedures:DO [LANGUAGE plpgsql] $$ ... $$(either order) runs a block once, taggedDO.CREATE [OR REPLACE] PROCEDURE,CALLandDROP PROCEDUREwork as their function counterparts, taggedCREATE PROCEDURE,CALLandDROP PROCEDURE; a call may use names. A block or procedure writes atomically with the statement, in the session's transaction, and an error in it undoes what it wrote. A procedure is stored as a function returning void:select proc()andcall some_function()work, where Postgres says 42809. Not yet:INOUT/OUTparameters, andCOMMIT/ROLLBACKinside a procedure (PL/pgSQL has no transaction control here).
create function add1(int) returns int language sql as 'select $1 + 1';
create function mul(a int, b int) returns int language sql immutable return a * b;
create function recent(n int) returns setof orders language sql as $$
select * from orders order by at desc limit n
$$;
select add1(41), mul(6, 7);
select * from recent(5);
create function log_change() returns trigger language plpgsql as $$
begin
insert into audit (op, tbl, row_id) values (tg_op, tg_table_name, coalesce(new.id, old.id));
return null; -- an AFTER trigger's result is ignored
end $$;
create trigger orders_audit after insert or update or delete on orders
for each row execute function log_change();
- Functions:
CREATE [OR REPLACE] FUNCTION name([name] type, ...) RETURNS type | SETOF type | SETOF table | TABLE (name type, ...) | trigger | void(types include enums),LANGUAGE sqlorplpgsql,IMMUTABLE/STABLE/VOLATILE,STRICT(alsoRETURNS NULL ON NULL INPUT,CALLED ON NULL INPUT), and the bodyAS $$ ... $$(or'...'),RETURN expressionorBEGIN ATOMIC ...; END.SECURITY,PARALLEL,LEAKPROOF,COSTandROWSare accepted and change nothing.- Calling: a function returning a value goes in any expression; one returning rows goes in
FROM(select * from recent(5) r), where it can read the tables before it, asLATERAL.public.name(...)works too. - Parameters:
$1,$2, ... or their names. In aLANGUAGE sqlbody a column of the same name (of a table, view, sequence or catalog table the statement reads) wins, as in Postgres. - Overloading: several functions may share a name with different argument types. A call picks the one whose types fit best (exact types, then numbers widening, then
textfor a quoted literal or NULL), as Postgres does in the common cases; one it can't choose is 42725.DROP FUNCTION [IF EXISTS] name [(types)], ... [CASCADE](CASCADEdrops the triggers that call it; without it they stop the drop, 2BP01). - SQL bodies:
SELECT,INSERT,UPDATEandDELETEstatements; the last one's first row (first column) is the result, or all its rows for a set. A body that's oneSELECT expressionis folded into the calling query, as Postgres inlines it. A body's statements (a PL/pgSQL function's too) read views, sequences and the catalog's tables as a statement of its own does, as they are each time they run:pg_prepared_statementsandpg_cursorsthere list the calling session's. - Writes: a
VOLATILEfunction (the default) may write; aSTABLEorIMMUTABLEone that tries gets 0A000. What a function writes commits with the statement that called it, or not at all. - Depth: functions and triggers calling one another more than 40 deep stop with 54001 (endless recursion, or near the end of a server thread's stack).
- Calling: a function returning a value goes in any expression; one returning rows goes in
- Triggers:
CREATE [OR REPLACE] TRIGGER name BEFORE | AFTER INSERT | UPDATE [OF col, ...] | DELETE [OR ...] ON table [FOR EACH ROW | STATEMENT] [WHEN (condition)] EXECUTE FUNCTION f(['arg', ...])(PROCEDUREtoo),DROP TRIGGER [IF EXISTS] name ON table, andALTER TABLE t ENABLE | DISABLE TRIGGER name | ALL | USER. As in Postgres, the default isFOR EACH STATEMENT, triggers of one kind fire in name order, andWHENreadsNEW.colandOLD.col.- BEFORE ROW triggers can change
NEW(the row then written, and whatRETURNINGshows), or skip the row by returning NULL;NOT NULL,CHECK, unique and foreign keys are checked after them. - AFTER ROW triggers fire once the statement's rows are written and checked, one row after another, and see the final rows. Statement triggers fire before and after them.
- Errors:
RAISE EXCEPTION(or any error) in a trigger stops the statement: nothing it or its triggers wrote is kept, and in a transaction the transaction fails, as in Postgres. - Where they fire: SQL's
INSERT,UPDATEandDELETE, on the world the statement runs on. They don't fire when a merge brings rows in (those rows are what the triggers already did on the branch; firing again would do it twice), or when a restart replays the log (it holds the rows triggers wrote). JSON writes to a table with triggers are refused (see JSON writes into SQL tables). - Not yet:
INSTEAD OF,TRUNCATE,REFERENCING(transition tables) and constraint triggers;INSERT ... ON CONFLICT DO UPDATEon a table with row triggers, andON DELETE CASCADE/SET NULLinto one (each refused with 0A000 rather than skipping its triggers).
- BEFORE ROW triggers can change
- In worlds: a function is a row (
_sonos_functions), and a table's triggers are part of its schema, so both fork, merge, travel in time (use world 'main@...'calls the function as it was) and show inDIFF(create function,+trigger name) andDIFF ... AS SQL. RunningDIFF ... AS SQL's statements fires the triggers where they run. - Speed: a call of a PL/pgSQL function costs about 1 µs; a row trigger that writes a row adds about 20 µs to each row written (an audit trigger on a 20,000-row
INSERT: 0.4 s).
PL/pgSQL
What Chronos runs, parsed when the function is made; anything else is refused then with 0A000 naming it, never guessed at when the function runs.
- Blocks:
[DECLARE ...] BEGIN ... END, nested blocks. Declarationsname [CONSTANT] type [NOT NULL] [:= | = | DEFAULT expression]andname RECORD; an inner block's variable hides an outer one of the same name. - Statements:
name := expression(also=, andrec.field :=,NEW.col :=),IF ... THEN ... ELSIF | ELSEIF ... ELSE ... END IF,CASE [x] WHEN ... THEN ... ELSE ... END CASE(no match and noELSE: 20000),LOOP,WHILE cond LOOP,FOR i IN [REVERSE] a..b [BY n] LOOP,FOR rec | a, b IN SELECT ... LOOP,EXIT [WHEN cond],CONTINUE [WHEN cond],RETURN [expression],RETURN NEXT [expression],RETURN QUERY SELECT ...,PERFORM ...,NULL,ASSERT cond [, message],GET [CURRENT] DIAGNOSTICS v = ROW_COUNT [, ...]. - Catching errors:
BEGIN ... EXCEPTION WHEN condition [OR condition ...] THEN ... [WHEN ...] END, as in Postgres. A condition is a name (division_by_zero,unique_violation,check_violation,no_data_found, ... Postgres's names for the errors Chronos raises, and their classes such asdata_exceptionandintegrity_constraint_violation, which catch every code of the class),SQLSTATE 'xxxxx'(a code, or a class'sxx000), orOTHERS. An unknown name is 42704 when the function is made.- The block's writes go back: when a handler catches an error, what the block's statements wrote (its functions and triggers included) is taken back, as Postgres's implicit savepoint does; what the handler then writes stays, and commits with the statement. Variables keep the values the block gave them. An error no handler catches goes on out, as before. The block's declarations are outside its handlers, as in Postgres.
- In the handler:
SQLSTATEandSQLERRMhold the error's code and message;GET STACKED DIAGNOSTICS v = RETURNED_SQLSTATE | MESSAGE_TEXT | PG_EXCEPTION_DETAIL | PG_EXCEPTION_HINT | PG_EXCEPTION_CONTEXT [, ...]reads them (detail and hint fromRAISE ... USING;PG_EXCEPTION_CONTEXTnames the function, without Postgres's line and statement;COLUMN_NAME,CONSTRAINT_NAME,TABLE_NAMEand the like are empty);RAISE;raises the error again. Outside a handler, both are 0Z002. - Timeouts and cancels stay fatal:
OTHERSdoesn't catch a statement timeout or cancel (57014) or a failedASSERT(P0004), as in Postgres. Namingquery_canceledcatches one, but the statement is stopped again at its next check, within milliseconds, so a loop in the handler can't outlive the timeout. - Cost: a block with handlers costs nothing extra until an error is caught (the savepoint is where the statement's writes stand, and a copy of the branch that costs O(1)).
- Dynamic SQL:
EXECUTE string [INTO [STRICT] targets] [USING expression, ...]runs aSELECT,INSERT,UPDATEorDELETEmade at run time, with$1,$2, ... theUSINGvalues (build names withformat('%I', ...)orquote_ident);FOR target IN EXECUTE string [USING ...] LOOPandRETURN QUERY EXECUTE string [USING ...]too. As in Postgres,EXECUTEsetsROW_COUNTand leavesFOUNDalone, a null string is 22004, and withoutSTRICT,INTOtakes the first row. Other statements (DDL) inEXECUTEare refused with 0A000. - SQL:
SELECT,INSERT,UPDATEandDELETE(andWITH), withINTO [STRICT] targets(variables, a record, orrec.fields) after aSELECT's list or aRETURNING. WithoutSTRICT, no row sets the targets to NULL; with it, no row is P0002 and several P0003 (several rows fromINSERT/UPDATE/DELETE ... RETURNING ... INTOtoo). ASELECTwithoutINTOis 42601, as in Postgres: usePERFORM. - Variables in SQL: statements and expressions read the variables; a name that is both a variable and a column of the statement's tables, views, sequences or catalog tables is 42702, as Postgres's default
plpgsql.variable_conflict = errormakes it. FOUNDafterSELECT INTO,PERFORM,INSERT/UPDATE/DELETE,FORloops andRETURN QUERY.RAISE [DEBUG | LOG | INFO | NOTICE | WARNING | EXCEPTION] 'format %', args [USING MESSAGE | ERRCODE | DETAIL | HINT = ...], orRAISE SQLSTATE 'xxxxx'orRAISE condition_name.%takes the next argument (<NULL>for null),%%is%.EXCEPTIONstops with its SQLSTATE (P0001 by default;ERRCODEtakes a code or a condition name such asunique_violation).NOTICE,WARNINGandINFOgo to the client as notices (psql shows them);DEBUGandLOGgo nowhere.DETAILandHINTgo with the error or notice to the client, and reach a handler'sGET STACKED DIAGNOSTICS.- Where an error came from: an error leaving a function has Postgres's CONTEXT lines, innermost first:
PL/pgSQL function f(integer) line 4 at RAISE(the line in the body, and the statement as Postgres names it:at assignment,at SQL statement,at PERFORM, ...;during statement block local variable initializationfor a default),SQL statement "..."for a statement that failed as it ran, and the functions it came through.RAISE;raises an error again with the CONTEXT it had;GET STACKED DIAGNOSTICS ... PG_EXCEPTION_CONTEXTreads it. - Cursors:
name [[NO] SCROLL] CURSOR [(arg type, ...)] FOR queryandrefcursorvariables;OPEN(a bound cursor with its arguments, by position orname := value;OPEN c [[NO] SCROLL] FOR queryorFOR EXECUTE ... [USING ...]),FETCH [direction] [FROM | IN] c INTO ...,MOVE,CLOSE, andFOR r IN c [(args)] LOOP. A cursor is a portal of the session, named by its variable (<unnamed portal n>for a refcursor with none): a function can return it and another call, or the session'sFETCHandCLOSE, read it until the transaction ends, andpg_cursorslists it. ALIAS FORa parameter ($1) or a variable (NEW), and an expression with aFROM(IF count(*) = 0 FROM t WHERE ... THEN), which is the querySELECT expression FROM ....- Triggers:
NEWandOLD(NULL where there is none, asOLDin anINSERT),TG_OP,TG_NAME,TG_WHEN,TG_LEVEL,TG_TABLE_NAME,TG_RELNAME,TG_TABLE_SCHEMA(public),TG_NARGSandTG_ARGV[i](from 0).RETURN NEW,RETURN OLD, another record, orRETURN NULL. - Ends: a function reaching
ENDwithoutRETURNis 2F005, except one returningvoidor a set. - Statement timeouts:
SET statement_timeoutand cancel requests stop a looping function (loops check, as scans do). - A record used whole:
to_jsonb(NEW),row_to_json(OLD),OLD IS NULL,NEW IS DISTINCT FROM OLDand a loop's record (to_jsonb(r)) read the record as its fields in a JSON object, null while it holds no row (OLD in an INSERT). So an audit trigger that keepsto_jsonb(OLD)andto_jsonb(NEW)works. Two records compare as those objects do: a field that is null in both is the same in both. Elsewhere a record is its row as Postgres writes it,(3,30,"a b"):RAISE NOTICE '%', NEW,NEW::text,format('%s', r).NEW.*is the record's fields:VALUES (NEW.*),SELECT r.*,ROW(OLD.*) = ROW(NEW.*)(field by field),NEW.*::text. - Labels:
<<name>>before a block or a loop,END nameandEND LOOP name,EXIT name(out of that block or loop) andCONTINUE name, andname.variablefor a variable of that block, even one a block further in hides (the function's own parameters by the function's name:f.param). FOREACH x [SLICE n] IN ARRAY a,GET DIAGNOSTICS ... PG_CONTEXT(the call stack, as Postgres writes it) andPG_ROUTINE_OID,#variable_conflict error | use_variable | use_columnandplpgsql.variable_conflict,#print_strict_paramsandplpgsql.print_strict_params,plpgsql.check_asserts, andplpgsql.extra_warnings/extra_errors(shadowed_variables,too_many_rows,strict_multi_assignment; checks on a record's fields aren't made).- Not yet:
OUT/INOUT/VARIADICparameters of procedures,RETURNS record,ROLLBACKin a procedure, DDL inside a function (EXECUTEincluded), and taking back a temporary table's changes when a handler catches an error (as withROLLBACK TO).
Indexes
CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] [name] ON table [USING btree] (column [ASC|DESC], ...) and DROP INDEX [IF EXISTS] name.
- What they speed up:
=,<,<=,>,>=,BETWEEN,IN (...),IN (SELECT ...),IS NULLandLIKE 'prefix%'on an index's first columns (equal values on the first columns, then a list or a range on the next), with the constant on either side and constant expressions such asnow() - interval '7 days'. - Two ranges: with a range on an index's first column and another on its second (
a BETWEEN 1 AND 5 AND b > 0on an index on(a, b)), the second is checked on each index entry, so rows outside it are never read. - Reading in order:
ORDER BYon an index's columns with aLIMITreads the index in order, forwards or backwards, and stops when it has enough rows: the latest 20 of 100,000 rows takes about 0.04 ms instead of 230 ms. Equal values on the first columns can come first (WHERE owner = $1 ORDER BY at DESC LIMIT 20uses an index on(owner, at)), and moreORDER BYkeys after the indexed ones are fine. - Wide ranges: a range that holds more than about a fifth of the table reads the table instead, which is faster. So does one whose row ids outgrow
CHRONOS_WORK_MEM(about 4 million rows at the default 256 MB): an index read holds the ids it finds, then reads each row as the query reaches it. - Building one on a big table reads the table once and sorts the entries within
CHRONOS_WORK_MEM, spilling to disk past it (asORDER BYdoes), so memory doesn't grow with the table. AUNIQUEindex (orALTER TABLE ... ADD CONSTRAINT ... UNIQUE) finds any duplicate before it writes anything. The entries are then written in parts with a checkpoint everyCHRONOS_WORK_MEMof them, and the index exists only once the last part is in: after a crash mid-build there is no index, and the entries written so far are unused rows until an index of that name is made again (which deletes them first) or the table is dropped. Writes and merges to the world wait while it builds; reads and forks don't. Made on a world and then merged, the merge builds the entries in memory; inside a bigBEGIN ... COMMITthe commit takes them as they are (see big transactions), and inside a small one it builds them in memory too. - Kept up by every write: SQL and JSON writes,
ON DELETEcascades,ALTER TABLEand merges. An index made on a branch arrives with the merge, built for every row. - Branches and crashes: entries are rows like any other, so forks share them for free and crash recovery rebuilds them.
DIFFdoesn't show them. - Unique indexes enforce uniqueness, like a
UNIQUEconstraint of the same name. On expressions (create unique index on users (lower(email))) they compare the expressions' values; partial ones (... where active) only compare rows theirWHEREholds for. - Names share one namespace across tables, as in Postgres. With no name, one is made up (
users_email_idx). - On expressions:
create index on users (lower(email))or((doc ->> 'status'))serves conditions on that same expression, likewhere lower(email) = $1. The expression can't use subqueries, aggregates, or functions whose value changes (now(),random()). - Partial:
create index on orders (placed) where not archivedholds entries only for rows itsWHEREis true for, so it stays small. A query uses it when its own conditions include thatWHERE. - Renames and drops: an index's expression or
WHEREfollows a renamed column, and goes when a column it reads is dropped, as in Postgres. - Trigram (pg_trgm):
create index on users using gin (name gin_trgm_ops)(orgist ... gist_trgm_ops, the same index here; or on an expression,(lower(name) gin_trgm_ops)) holds each text's trigrams, asshow_trgmgives them.name % 'jon'reads the rows that have at least the threshold's share (0.3) of the constant's trigrams, the only ones whose similarity can reach it;name LIKE '%smith%'(andILIKE, for an ASCII pattern) reads the rows that have every trigram of the pattern's runs of three or more letters or digits. Each row read is checked again, so the answers are the same as without the index. A pattern with no three letters in a row ('%ab%'), or a constant with no trigrams, reads every row.EXPLAINshowsBitmap Index Scan using ..., andpg_indexesshows the operator class. On one text, not unique. - Options ORMs write: operator classes that only say how to compare (
text_pattern_ops,varchar_pattern_ops,int4_opsand the other default ones: Django adds a_likeindex withvarchar_pattern_opsto every indexed CharField),COLLATE(the order the part keeps: see Collations),INCLUDE (cols)(the columns must exist),WITH (fillfactor = 70),TABLESPACE, andUSING hashorbrin, which are btrees here (they find what a btree finds). Another operator class is 0A000. - Not yet:
ON CONFLICT (lower(email))naming a unique index by its expressions (useON CONFLICT DO NOTHINGwithout a target), GIN on anything but a tsvector or a text withgin_trgm_ops, or GiST on anything but a point column (pgvector'shnswandivfflatare accepted, see Vectors; text and vector search are built in instead). Ajsonbcolumn can't be indexed as a whole; index an expression on it.
Vectors (pgvector)
pgvector's SQL works as it is, so tools written for Postgres with pgvector (Mem0's pgvector store, LangChain, LlamaIndex) run unchanged. Mem0's store is checked against it with psycopg 2 and 3.
create extension if not exists vector; -- accepted: vectors are built in
create table items (id uuid primary key, embedding vector(3), payload jsonb);
create index on items using hnsw (embedding vector_cosine_ops); -- the distance its graph is for
insert into items values ('a', '[1,0,0]', '{"user_id": "u1"}');
select id, embedding <=> '[1,0.1,0]' as distance from items
where payload->>'user_id' = 'u1' order by distance limit 5;
- The type:
vectororvector(n). Values are written'[1,2,3]', as an array ('{1,2,3}',ARRAY[1,2,3]: how drivers send lists) or as a JSON array.vector(n)refuses other lengths (22000); a vector has 1 to 16,000 numbers. - Distances:
<=>(cosine distance),<->(Euclidean),<#>(the negative inner product). Vectors of different lengths are an error (22000). A zero vector's cosine distance is NaN, as in pgvector. - Stored as 4-byte floats, as pgvector stores them: a 1,536-dimension vector takes 8 KB in a row and reads back without parsing numbers. Written out, vectors look as pgvector writes them (
[1,0.5,-2]); outside SQL they're JSON arrays, sofindsees them too (see search). SET hnsw.ef_search = n(1 to 1000, ordefault) sets the graph's beam for the session's searches, as in pgvector; it matters only for tables big enough to get a graph.- Without an index, exact:
ORDER BY distancecompares every row theWHEREkeeps, like pgvector without an index. - With
USING hnsw(orivfflat), fast:ORDER BY <column> <op> <constant vector> LIMIT kon one table gets its candidates from the search index (an exact scan, or its HNSW graph when the graph has shown it finds at least 99% of what the scan does, or the table is too big to scan; see search), then reads just the rows it returns and runs the query on them, so distances and conditions stay exact. Equality on a column or onjsonb ->> 'key'(Mem0'spayload->>'user_id' = $1) narrows the candidates in the index; other conditions are checked on them, and more candidates are fetched until theLIMITfills. On 76,000 real OpenAI embeddings that's 99.98% recall@10 in about 1 ms through the Postgres protocol (see benchmarks). Candidates are ranked by dot product, which is cosine for the unit-length vectors most embedding models give. The index's options (m,ef_construction) are ignored: the search index has its own. - Operator classes:
vector_cosine_ops,vector_l2_ops(the default) andvector_ip_opsare kept with the index and shown bypg_get_indexdefandpg_indexesas Postgres shows them (the default left out); any other is 42704. The search index ranks every vector column by inner product, which is also the cosine and L2 order for vectors of unit length (most embedding models give those). For vectors that aren't, a cosine or L2 index gives that distance a graph of its own (over unit-length copies for cosine, by Euclidean distance itself for L2), soORDER BY <=>or<->walks it rather than comparing every row; it takes as much memory again as the column's search index, and each answer is scored by the true distance. Until that graph is built and has tested itself (as above), those queries compare every row, exactly. - Indexes: an
hnswindex is a note on the table, since the search index does the work (DROP INDEXremoves it); GIN (or GiST) indexes onto_tsvector(...)or atsvectorcolumn, and GiST indexes on points, are real indexes (see the full-text and points entries above). Other index methods are refused (0A000). - Over the Postgres port: text as pgvector writes it (
[1,2,3]), and pgvector's binary form. The type's id is fixed (90000); drivers that don't know it read vectors as text. - Also for these tools:
information_schema.tables(the branch's tables, in schemapublic; see the catalog),public.tablenames inFROM, andDEALLOCATE(see Prepared statements).
EXPLAIN
EXPLAIN [ANALYZE] statement, or Postgres's EXPLAIN (ANALYZE, FORMAT TEXT, ...), for SELECT, INSERT, UPDATE and DELETE. It returns one QUERY PLAN column in Postgres's layout:
Limit (actual rows=20)
-> Sort
-> Index Scan Backward using events_owner_at on events (actual rows=20)
Execution Time: 0.061 ms
- Each table's read shows how Chronos reached its rows:
Index Scan using <index>with itsIndex Cond, the primary key, the search index, orSeq Scan. A table looked up once per row of a join showsloops=. ANALYZEruns the statement, as in Postgres (soEXPLAIN ANALYZE DELETEdeletes), and adds actual row counts and the time taken. Without it, a write only finds its rows.- Not shown: costs, and how joins pair rows beyond the order tables are read in.
Text order
Text compares and sorts by its bytes (UTF-8) unless it is given a collation, as Postgres does with COLLATE "C": 'Z' comes before 'a', and 'é' after 'z'. That is the database's collation (default, the same as C, POSIX and ucs_basic), and indexes keep the same order, so LIKE 'abc%' is a range. A Postgres database made with another collation (such as en_US.UTF-8) orders text differently: give the columns an ICU collation to sort as a language does.
Collations
ICU collations sort text as a language does. Chronos has the ones Postgres 16 makes when a database is created (unicode, und-x-icu, de-x-icu, sv-x-icu, en-US-x-icu, ... 869 of them: select collname from pg_collation), and CREATE COLLATION makes more:
create collation german_phonebook (provider = icu, locale = 'de-u-co-phonebk');
create collation numeric (provider = icu, locale = 'und-u-kn-true'); -- 'x9' before 'x10'
create table people (id int primary key, name text collate german_phonebook, city varchar(40) collate "sv-x-icu");
create index people_city on people (city); -- kept in sv's order
select name from people order by name; -- in the column's collation
select city from people order by city collate "de-x-icu" limit 10; -- or the one asked for
- Where a collation counts: comparisons (
<,<=,>,>=,BETWEEN),ORDER BY(of a query, a set operation, a window, an aggregate:string_agg(x, ',' order by x)),minandmax,greatestandleast,x < ANY (a, b), andupperandlower, which map case as ICU does in the collation's language (upper('straße' collate "de-x-icu")isSTRASSE; Turkish has its dotted i). Equality,GROUP BY,DISTINCT, unique keys and hashing are bytes in a deterministic collation: where ICU ties two strings, their bytes decide, as in Postgres. A nondeterministic one is below.ILIKEfolds both sides to lower case aslowerdoes in the collation's language (TurkishIisı);LIKEand regular expressions work as they do without one. - Which collation: as Postgres works it out. A column's (
COLLATEinCREATE TABLE,ADD COLUMNorALTER COLUMN ... TYPE ... COLLATE), a literal's the default, and a function of text takes its arguments'. An explicitCOLLATEwins over a column's, and a collation other than the default wins over the default. Two different explicit ones are 42P21; two different columns' where an order is needed are 42P21 (ORDER BY,UNION) or, where a comparison runs, 42P22: say which withCOLLATE. A subquery's, a view's, a materialized view's, a CTE's andCREATE TABLE AS's columns keep their collations, and a subquery reads an outer query's column in its collation (LATERAL too). A child table's column has its parents' collation: one written with another (or withoutCOLLATE, which is the default) is 42P21 inINHERITS,ALTER TABLE ... INHERIT,ATTACH PARTITIONand a parent'sADD COLUMN, as in Postgres, and aCOLLATEin aPARTITION OFcolumn list is taken and the parent's kept.LIKEmatches bytes in every collation, but two explicit collations on its sides are 42P21, as Postgres derives them. - Indexes: an index part keeps its column's collation, or the one
CREATE INDEX ... (col COLLATE "de-x-icu")names. A query uses it forORDER BY ... LIMITand for ranges only in that collation, and for equality in any. - CREATE COLLATION:
(provider = icu, locale = '...')takes a BCP 47 locale (de-DE,sv,und), a POSIX one (de_DE, kept asde-DE), or ICU's@keywords (de@collation=phonebook), with the keywordsco(phonebk,trad,pinyin,stroke, ...),kn(numbers by value),kf(upper or lower case first),ks(strength: atlevel2case ties, then bytes decide),ka,kvandkc. The locale must be one Postgres makes a collation of (select colliculocale from pg_collation) or a region alias checked to order the same (zh-TW,sr-RS): ICU4X resolves others (sr-ME,uz-AF,zh-Latn) to other rules than ICU4C, so they are refused (0A000), as are the typessearchandsearchjland Chinesestandard. A keyword's value ICU doesn't know (kf-xyz) is XX000, as in Postgres.deterministic = falsemakes a nondeterministic one (ICU only);provider = libctakesC,POSIXandC.UTF-8, and makes a libc locale (de_DE.UTF-8) the ICU collation of that locale (de-DE), and a DEFAULT naming a collation made here is 0A000.CREATE COLLATION name FROM othercopies one,ALTER COLLATION ... RENAME TOandSET SCHEMArename it (the columns and indexes of it follow), andDROP COLLATION ... CASCADEdrops the columns, generated columns, CHECKs, indexes (of it, or whose expression or WHERE names it) and views that use it, found by the SQL's tokens (without CASCADE, 2BP01; a materialized view of it is dropped by hand first). RENAME is refused (0A000) while a view, CHECK, generated column or index expression names it: they keep their SQL. That SQL names a collation written without its schema with the schema the search_path found it in when it was made (collate cmade undersearch_path = a, bis kept ascollate a.c), as Postgres keeps the collation itself: it compares in that collation, and DROP COLLATION finds it, under any later search_path. A primary key's column can't be dropped here, soDROP COLLATION ... CASCADEreaching one is refused (0A000); give the column another collation first. - In worlds: a collation is a row of its world: one made in a fork isn't in main until the fork merges,
DIFFshows it (create collation), and a merge brings it with the tables that use it. A merge that would drop a collation a table still uses is refused (2BP01). - Not carried through yet, so refused (0A000) rather than answered by bytes: text in an ICU collation ordered against a subquery's rows (
x < ALL (SELECT ...),(x, 1) < (SELECT ...);x < (SELECT ...)and= ANYwork) or againstANY (array), and arrays and rows of such text ordered (ORDER BY array[x],ORDER BY (x, id),max(array[x])). - Older names:
collate c,posix,"C.utf8"(any case), which earlier versions read as text by bytes, still are, so views, CHECKs and functions kept with them read as before. - ICU4X, not ICU4C: Chronos sorts with ICU4X (the pure-Rust ICU), where Postgres links ICU4C. For the locales Chronos has, the two order text the same (tests/pgdiff.rs compares them with Postgres 16 and ICU4C 78, and every locale was checked on 300 strings of many scripts), with one exception: ideographs outside the Basic Multilingual Plane (CJK Extension B and later,
𠀀) sort after the other Han ideographs in ICU4X and before them in ICU4C. 39 of Postgres's ICU collations are left out: ICU4X has no rules for 37 of their languages, and orders Marathi's and Konkani'sक्षotherwise (Known gaps). The ICU crates are pinned to exact versions, as an index keeps their sort keys; a collation made with another version warns when used, as Postgres does.
Nondeterministic collations
create collation ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false); -- case doesn't count
create table users (id int primary key, email text collate ci unique);
insert into users values (1, 'Ada@Example.com');
select id from users where email = 'ada@example.com'; -- 1
insert into users values (2, 'ADA@EXAMPLE.COM'); -- 23505: the same email
- Equal is what ICU ties: at
ks-level2strings that differ only by case are equal; atks-level1, by case or accents. - By that:
=,<>,<,<=,>,>=,BETWEEN,IN (a, b),x = ANY (a, b),CASE x WHEN,NULLIF,IS DISTINCT FROM, a join'sON a = b,ORDER BY,min,max,greatestandleast, unique constraints, unique indexes andON CONFLICTon a column of one, andGROUP BY,DISTINCTandDISTINCT ON(a group shows one of its strings, either of'Ada'and'ADA', as in Postgres). - Refused (0A000), not done by bytes:
UNION,INTERSECTandEXCEPT(notUNION ALL),count(DISTINCT x),PARTITION BY,IN,ANYandALLof a subquery's rows or of an array, comparing arrays or rows that hold such text, a primary key, a foreign key, and a partition key.LIKEandILIKEare refused as Postgres 16 refuses them. - Read through SQL: a comparison goes by the collation where SQL makes it. The JSON row API and the search index (
find) compare a column's text by its bytes. - Speed:
email = 'x'on such a column reads the rows; the unique constraint's own check is one lookup.
citext
create extension if not exists citext; -- the type is built in; this succeeds
create table users (id int primary key, email citext unique not null);
insert into users values (1, 'Ada@Example.com');
select id from users where email = 'ada@example.com'; -- 1
select id from users where email like 'ADA%'; -- 1: citext's LIKE doesn't look at case
- What it is: text in the built-in collation
citext, a nondeterministic one with no ICU locale: two strings are equal when their lower case is, and they sort by it. Everything above holds for it, what is refused too (set operations, a primary or a foreign key on a citext column);GROUP BYandDISTINCTgo by its lower case. - Where the type goes: a column (
email citext), a cast (x::citext), a domain (create domain email as citext check (...)),ALTER TABLE ... ADD COLUMN.format_type,information_schema.columns(USER-DEFINED,citext) and a diff's SQL saycitext; on the wire the column is text. - Against text: a citext compared with a quoted string is compared as citext. Compared with text of a type (a text column,
x::text) it is compared as text, by bytes, as Postgres picks text's operator there. - Not yet:
pg_typeofsaystext;~and the other regular-expression operators, andreplace,strpos,split_partandtranslate, look at case (the extension's don't); acitext[]is atext[]; a function'scitextparameter is text inside it.
hstore
create extension if not exists hstore; -- the type is built in; this succeeds
create table items (id int primary key, attrs hstore not null default '');
insert into items values (1, 'color=>red, size=>L'), (2, hstore('color', 'blue'));
select attrs -> 'color' from items where attrs ? 'size'; -- red
select id from items where attrs @> 'color=>blue'; -- 2
update items set attrs = attrs || 'stock=>3' where id = 1;
select key, value from items, each(attrs) where id = 1;
- What it is: text keys, each with a text value or NULL, kept and shown as Postgres writes it:
"color"=>"red", "size"=>"L", each key once, the shorter keys first. Two hstores with the same pairs are equal however they were written. - Operators:
->(a key's value, or an array of keys' values),||,?,?|,?&,@>,<@,-(a key, an array of keys, or another hstore's matching pairs),=,<>, andh['key']. An untyped string after-is read as an hstore, as in Postgres: writeattrs - 'key'::textto take a key away. - Functions:
hstore(key, value),hstore(text[]),hstore(keys[], values[]),akeys,avals,skeys,svals,each,exist,defined,delete,slice,fetchval,hstore_to_array,hstore_to_matrix,hstore_to_json,hstore_to_jsonband their_looseforms, and a cast tojsonorjsonb. - Indexes:
USING gin (attrs)andUSING gist (attrs)are made, listed and dumped, and hold no entries:@>and?read the rows. An index on one key,((attrs -> 'color')), is a btree that finds it. - By pg_dump's names:
public.hstore,public.akeys(...),attrs OPERATOR(public.->) 'k'andpublic.gin_hstore_opsread as the plain ones.pg_typehashstoreand_hstore, for a driver that looks the type up by name. - Not yet:
hstore(record),populate_record,%%,%#and#=;(each(h)).keyin a select list (writefrom each(h));<and>between hstores; binary parameters (send them as text).
ltree
create extension if not exists ltree; -- the type is built in; this succeeds
create table nodes (id int primary key, path ltree not null);
create index on nodes using gist (path);
insert into nodes values (1, 'top'), (2, 'top.science'), (3, 'top.science.astronomy');
select path from nodes where path <@ 'top.science'; -- top.science and what is under it
select path from nodes where path ~ '*.science.*{1}'; -- top.science.astronomy
select subpath(path, 0, 2), nlevel(path) from nodes where id = 3;
- What it is: a path of labels separated by dots, each of letters, digits,
_and-, kept as its text.lqueryis a pattern over paths:*,*{n,m},a|b,!a, and a label's*(starts with),@(any case) and%(its words, split at_). - Operators:
@>and<@(above and below, or equal),~(an lquery takes the path),?(any of anlquery[]does),||, and<,<=,>,>=,=,<>. With anltree[]on one side,@>,<@,~and?are true when any of its paths is. - Order: label by label, as Postgres orders them, so
top.acomes beforetop-x: inORDER BY, the comparisons, and an aggregate's or a window'sORDER BY.minandmaxgo by the text (Postgres has neither for ltree). - Functions:
nlevel,subltree,subpath,index,lca,text2ltree,ltree2text. - Indexes:
USING gist (path)(withgist_ltree_ops(siglen = n)too) andUSING ginare kept as a btree, which finds a path by=;<@,@>and~read the rows.ORDER BY path LIMIT nsorts rather than reading an index. - By pg_dump's names:
public.ltree,public.nlevel(...),path OPERATOR(public.<@) 'top'andpublic.gist_ltree_opsread as the plain ones.pg_typehasltree,lqueryand their arrays. - Not yet:
ltxtqueryand its@;?@>,?<@,?~(the first match of an array); binary parameters.
Moving from Postgres
chronos import with a Postgres URL moves a whole database, while it runs:
chronos import mydb postgres://ada:secret@db.example.com:5432/shop --dry-run # what would come over, nothing written
chronos import mydb postgres://ada:secret@db.example.com:5432/shop
"customers": 5000 rows
"orders": 18230 rows
imported 2 of 2 tables (23230 rows) into main, and 3 foreign keys, indexes and views
also brought over (3):
- function public.touch_updated_at()
- trigger orders_touch on orders
- 4 comments
brought over in part (1):
- index orders_status: a hash index came over as a btree (it finds the same rows)
- What comes over: schemas, types (enums, domains with their default,
NOT NULLand checks, composite types and ranges, in the order they depend on one another, in any schema), sequences (with theirCACHE, set to where Postgres had them, soserialand identity columns go on from there), SQL and PL/pgSQL functions and procedures, tables with their columns, types,NOT NULL, defaults,GENERATED ... AS IDENTITY(ALWAYSorBY DEFAULT, with its sequence's options), primary keys,UNIQUE,CHECKand exclusion constraints, partitions (partitioned tables with their partitions and bounds) and inheritance (INHERITS), then every row, then foreign keys (checked against the rows), indexes, views and materialized views, and last triggers, rules and comments (on schemas, tables, views, sequences, indexes, columns, constraints, enum types and functions). - What comes over in part, listed: an index's method that Chronos has no use for (
hashandbrinare btrees: they find the same rows),DESCandNULLS FIRST/LAST,INCLUDEcolumns, operator classes (but pgvector's and pg_trgm's, which are kept) and storage parameters, which an index here doesn't keep. A table whose partitioning or inheritance Chronos refuses comes over as one plain table with all its rows, and says so. - What doesn't, reported instead of stopping the rest: extensions Chronos doesn't have built in (those it has,
vector,postgis,pgcrypto,uuid-ossp,citextand the others, are made as Postgres had them), functions in other languages, aggregates, operators, event triggers, publications, foreign tables, row-level security and its policies, base types, roles and grants (make the roles andGRANTagain, or give each program an agent), and any table, type or statement Chronos refuses, with its reason. A table whose rows fail to load stays, empty, and is named in the report. - Row-level security: a table whose rows its policies would keep from the role you import as isn't brought over in part: it's made empty, named (
"customers": not copied), and the import exits with status 1. Import as the tables' owner, a superuser, or a role withBYPASSRLS(on Supabase,postgres). See Migrating from Postgres. - Rows move in COPY's text format, a table at a time, each as one
INSERT: Chronos reads each value as its column's type, so types whose binary form it doesn't read (uuid,bytea, arrays) come over too, and floats come over exactly (Postgres 12 and later print them so they read back the same). - Connecting:
sslmodeworks as in libpq:prefer(the default: TLS if the server offers it, without checking whose certificate),disable,require, andverify-full(checks the certificate againstsslrootcert=<file.pem>or the system's CAs;verify-cadoes the same). Logins: trust, a password, MD5 and SCRAM-SHA-256, bound to the server's certificate over TLS (SCRAM-SHA-256-PLUS) where the server offers that, as libpq'schannel_binding=preferbinds; a server that offers binding with a certificate whose hash can't be made (EdDSA) is refused, as libpq refuses it.channel_binding=requirein the URL logs in only bound, anddisablenever binds. The password can come fromPGPASSWORD. TCP only, no Unix sockets. - Where it goes: into
main, or-b <world>. The folder must not be open in another process. Names are kept:public.ordersisorders, other schemas' tables keep their schema (app.orders). - Text order: Chronos sorts text by bytes, as
COLLATE "C"does, unless a column has a collation; columns' collations aren't imported yet, and a database's own (en_US.UTF-8) isn't one (see Text order).
Dumping with pg_dump
Postgres's own pg_dump reads a Chronos database through the catalog and writes a script that makes it again: schemas, enum types, functions and procedures, tables with their defaults, checks and identity columns, sequences (owned by their columns, with a setval to where they are), foreign keys, indexes, views, materialized views, triggers, and every row (COPY).
pg_dump -h 127.0.0.1 -p 5433 -U postgres --no-owner main > main.sql
psql -h other-host -d newdb -f main.sql # into Postgres, or into another Chronos
- Owners and grants: what no role made is owned by
chronos, so without--no-ownerthe script hasALTER ... OWNER TO chronoslines, which Postgres refuses where there is no such role (and Chronos takesOWNER TO postgresaschronos). An object a role made is that role's (ALTER TABLE s.t OWNER TO app), and its grants areGRANTandREVOKElines. The roles themselves aren't in it (as in Postgres, they'repg_dumpall --roles-only's): make them first where it restores.ALTER TYPE | DATABASE ... OWNER TOare accepted with a notice (types and databases keep no owner here). - Restoring into Chronos: a dump of a Chronos database restores into Chronos, and a second dump of it is the first, byte for byte (
tests/pg_dump.rs). A dump of a Postgres database restores if it stays within what Chronos has (Not yet, Known gaps). - Into Postgres: the script restores there without an error, and the rows, sequences and functions come back as they were. Views come back as the query was written to Chronos (Postgres writes its own form of it), and types Chronos keeps as another (
jsonasjsonb,realasdouble precision) come back as that one. - What isn't in a dump: comments on functions, types and the like (
COMMENT ONkeeps those on tables, columns, views, sequences, indexes, constraints and schemas, andpg_dumpwrites them), roles, and what Chronos has beyond Postgres: worlds, agents, time travel. A world is a database: dump each one (pg_dump -d app). - Every query
pg_dumpsends is answered from the catalog, sopg_dump --schema-only,--data-only,-t table,-n schema,--insertsand-Fcwork the same way.
Importing a Postgres table
chronos import copies one table from Postgres into a new table, to try Chronos on your own data.
Make two files with psql, putting your table's name in place of TABLE (running chronos import
with no arguments prints these commands too):
psql -c "\copy TABLE to 'data.csv' csv header"
psql --csv -t -c "select c.column_name, c.data_type, c.is_nullable, exists (select 1 from information_schema.table_constraints tc join information_schema.key_column_usage k using (constraint_schema, constraint_name) where tc.table_name = c.table_name and tc.table_schema = c.table_schema and tc.constraint_type = 'PRIMARY KEY' and k.column_name = c.column_name) from information_schema.columns c where c.table_name = 'TABLE' order by c.ordinal_position" > schema.csv
chronos import mydb TABLE data.csv --schema schema.csv
- Types: integers,
bigint,real/double precision/numeric(as floats),text/varchar/char,uuid(as text),boolean,date,timestamp,timestamptz,interval,json/jsonb. Any other type (arrays, enums,bytea, ...) comes in as text, with a warning naming the column. - An empty field is NULL and a quoted empty field (
"") is an empty string, as\copywrites them. - The table needs a primary key, of one column or several. Tables with none are refused.
- It's all or nothing: a bad row or value stops the import, names the line and column, and leaves no table behind. Importing into a table name that already exists is refused.
-b <branch>imports into a branch other than main.
Sample data to try it on: cargo run --example sample_data -- sample/ writes 5,000 customers and
their orders (with dates relative to today; exactly 1,240 customers have been inactive for over 90
days) plus their schema files, ready for chronos import.
Speed
- Lookups by primary key read one row, and
key IN (...)reads each. A range on the key (BETWEEN,<,>, one-sided too) reads just the rows in it, however wide: keys are stored in the order SQL compares them, integers as numbers and text as text. Tables made with 0.1.0 or before keep integer keys as their digits: there a range on one is read key by key up to 100,000 keys wide, and wider ones scan the table. - B-tree indexes answer the conditions above (see Indexes).
column = valueconditions on integer, bigint, text or boolean columns with no B-tree index (joined byAND) use the search index the rest of Chronos already keeps for the table. The rest of theWHEREis checked on the rows the index finds. It's built on first use and updated incrementally, per branch. It holds the table in memory many times over (85 times for a 150,000-row table of short text, an md5 and timestamps: 1.57 GB; see operations), so a query builds it only for tables whose rows fit in an eighth ofCHRONOS_WORK_MEM(32 MB by default); bigger tables are scanned, unless a search already built their index.- Unique checks (
UNIQUE,ON CONFLICT) look up the constraint's own entry: one read, whatever the table's size. - Other conditions scan the table: conditions on unindexed columns,
OR, and float columns without an index. - Rows a query doesn't read are skipped as each row is decoded.
- Big queries use every core. Above about 8,000 rows, scans (reading, decoding and filtering), building and probing hash joins, and
GROUP BYsplit the rows into parts of about 4,000 that the cores take in turn, so fast and slow cores both stay busy. It scales with the machine on its own: Chronos uses as many threads as the operating system says it may (container CPU limits included). Queries running at the same time share those threads rather than each starting its own, so a busy server doesn't run more threads than cores.CHRONOS_THREADS=nsets the number instead. The parts depend on the data, not on the number of cores, so the answer is the same on any machine, sums of floats included. Queries with subqueries in their conditions, andcount(DISTINCT ...)-style aggregates, run on one core. - Memory: one-table
GROUP BY,DISTINCTaggregates andORDER BYspill to disk pastCHRONOS_WORK_MEM(256 MB by default), andORDER BY ... LIMIT kholds only k rows however big the table: see operations. So do the writes of a bigINSERT,UPDATE,DELETEorCOPY, which stays one atomic statement;ON CONFLICT,RETURNING, tables with triggers and a few other cases still hold them (the list is in operations). - Correlated
EXISTS,NOT EXISTSandINrun once.exists (select 1 from leads l2 where l2.company = l1.company and l2.created_at > l1.created_at)used to readleadsagain for every row ofl1. Now the subquery runs once without the conditions that read the outer row, its rows are kept by the value ofl2.company, and each outer row finds its candidates by that value and checks only the other conditions (l2.created_at > l1.created_at) on them. A null on either side of the=matches nothing, as before. It applies when the subquery has noLIMIT, grouping, aggregate, nested subquery or volatile function, reads the outer row only in itsWHERE, has an=between one of its own expressions and one of the outer row's (of types that hash as they compare: the same type, or integers with numeric; a float against an integer compares through a double, so it stays per outer row), and its rows fit in a quarter ofCHRONOS_WORK_MEM. Otherwise it runs per outer row, as before. So does one whose=compares an indexed column or the primary key, where each outer row's lookup is cheap: until those lookups have taken half a second in all, when it's hashed after all (an index on a column with few values picks too many rows to be cheap). On 150,600 rows with 15,000 of them outer, the first form took two minutes and takes about 0.1 s. - Joins on an expression hash too.
a join b on lower(a.email) = lower(b.email)hashes the expression of the second table's columns the way a join on a column does, instead of comparing every pair (both sides the same type, or integers and numeric). x IN (select ...)andNOT INhash the list once it has 16 rows or more, when its values are all integers and numeric, all floats, all text, all dates or all booleans (a null in the list still makesNOT INunknown); a table's rows are looked up in the set, not compared with every value in the list.- Big joins join while reading. In a join of two tables, each core reads part of the first and joins it straight away; with
GROUP BY, it groups the part too when the aggregates don't depend on how rows are split (count,min/maxof a column,sum/avgof an integer column). If the first table's own conditions leave 1,000 rows or fewer, the join looks each match up by key instead, as before. Integersumandavgadd in 128 bits, so only the total has to fit in abigint, as in Postgres. - Errors in big queries: when a query would fail in more than one way, which error it reports can vary between runs, as with Postgres's parallel queries. The results of queries that succeed never vary.
See BENCHMARKS.md for numbers against Postgres.
Every write to a SQL table, SQL or JSON, stores the column's type, so index lookups and scans agree.
Records
A function that gives several columns, called in a select list, gives one value: a record, written as Postgres writes a row, (10,1). (expr).field reads one of its fields ((r).x, (q.r).x, (information_schema._pg_expandarray(i.indkey)).n), typed as the field is; (1).x is 42809 (not a composite type) and a field it hasn't is 42703. Today one function gives records, information_schema._pg_expandarray(array), each element as x and its place as n (JDBC drivers read an index's columns with it); ROW(...) values and whole rows are still text and JSON, and (record).* and declaring a composite type (CREATE TYPE ... AS (...)) are not supported. pg_get_keywords() lists Postgres 16's SQL keywords (word, category code, whether it can be a bare label, and descriptions), and regproc and regprocedure read any of Postgres's built-in functions by name or oid and back ('now'::regproc::oid is 1299, 42::regproc is int4in, 'int4in(cstring)'::regprocedure), as the catalog's regproc columns (typinput) hold them.
Not yet
- Functions:
to_char'sEEEE,RN,THandVnumber patterns, among others. - COPY: the binary format for
COPY ... TO(FROMworks),COPY ... FROM ... WHERE, and files or programs on the server (refused on purpose; use psql's\copy). - Types and schemas: composite and range types (domains work).
- Temporary tables: savepoints (see Temporary tables).
- User-defined functions and triggers: the parts of
CREATE FUNCTION, PL/pgSQL andCREATE TRIGGERlisted as not yet under Functions and triggers, and other languages thansqlandplpgsql. - Views:
WITH CHECK OPTIONon views that aren't automatically updatable,ON CONFLICTthrough a view,ALTER VIEW ... RENAME COLUMN, and renaming a table or view another view reads. - Catalog:
pg_enum,pg_procand other catalog tables not listed under the catalog.
Each gives a clear error rather than a wrong answer.
Network address types
inet (a host or network address, v4 or v6, with an optional mask), cidr (a network: host bits must be zero), macaddr and macaddr8. They order as Postgres orders them (family, then address, then mask), index, aggregate with min/max, and read back as text. Operators: <<, <<=, >>, >>=, && (contained or contains), + and - with an integer, inet - inet, and &, |, ~. Functions: host, text, abbrev, family, masklen, netmask, hostmask, broadcast, network, set_masklen, inet_same_family, inet_merge, trunc (macaddr), macaddr8_set7bit.
SQL/JSON path
jsonb_path_exists(target, path [, vars [, silent]]), jsonb_path_match, jsonb_path_query_array, jsonb_path_query_first and the set-returning jsonb_path_query (each with a _tz form that may compare a date with a timestamp with time zone in the session's zone), and the operators jsonb @? path and jsonb @@ path (silent, no variables). The path language is Postgres 16's (not 17's new item methods .bigint(), .string(), ...). A path is text here: '$.a'::jsonpath is accepted, and a malformed one fails when it is used.