Contents
- pg_pure design
- 48’s branch (the PR is titled “Make libpure.so safe to embed in a host
pg_pure design
Status: draft for review, 2026-10-27. Nothing here is built yet. The only
code is the prototype in _/plpure (600 lines of C, see
pg_pure-notes.md). Every number below was measured on an EC2 c7i.4xlarge
running Debian 13 (AMI debian-13-amd64-20261026-2643), PostgreSQL 17.11 and
18.6 from PGDG, LLVM 21.1.8, libpure from github.com/gburd/pure-lang branch
libpure-embeddable at a433e813. Most runs were repeated two or three
times. No variance analysis was done.
1. What this is
pg_pure makes Pure a procedural language in PostgreSQL, the way PL/Perl and PL/Python do for Perl and Python. A function body is Pure code. It runs in a Pure interpreter (libpure) inside the backend process. From there it can reach the database through SPI and Pure’s library modules (math, gsl, mpfr, csv, xml, …).
It ships two languages, as PL/Perl does with plperl and plperlu:
pl_pure_untrustedis untrusted and only superusers can use it. It ships first and works now.pl_pureis trusted. It is the language we ultimately ship. It needs a sandbox that libpure does not have yet (section 9), so for now its handler refuses to run: the extension and its grants exist, but apl_purefunction raises “pl_pure (trusted) is not available in this version”.
The project, the repository and the shared library are called pg_pure
(pg_pure.so). The two SQL languages are pl_pure and
pl_pure_untrusted. One .so backs both. They are separate extensions
(CREATE EXTENSION pl_pure, CREATE EXTENSION pl_pure_untrusted): a DBA must
be able to install one without the other, and two extensions cannot own the
same handler function. Tom Lane hit this when plperl and plpython became
extensions:
https://pg.ddx.io/m/pgsql-hackers/27364.1299286724@sss.pgh.pa.us/
1a. Status, 2026-10-27
The untrusted language is built and passes. pg_pure.c (about 1080 lines)
backs both languages; CREATE EXTENSION pl_pure_untrusted then
CREATE FUNCTION ... LANGUAGE pl_pure_untrusted works, and its regression
suite (sql/pl_pure_untrusted.sql, 1 file) passes against PostgreSQL 18 on
the box with the version-script’d libpure. The six prototype defects in
section 8 are fixed and each has a regression case: exact numeric round-trips
(12345678901234567890.123456789 in and out unchanged), an int2 result of
70000 is an ERROR, an int8 overflow is an ERROR, a non-string text result is
an ERROR, and a ; in a body no longer leaks a global rule. The crash matrix
(PostgreSQL JIT and Pure in one backend, both orders, three runs each) shows
0 crashes. pl_pure is installable and refuses to run, as described above.
Still missing from phase 1: set-returning functions, triggers, preload
warm-up wired into _PG_init (the code is there but untested end to end),
and the TAP suite. See ROADMAP.md.
2. How users write Pure in PostgreSQL
2.1 Functions
CREATE EXTENSION pl_pure_untrusted;
CREATE FUNCTION hyp(a float8, b float8) RETURNS float8
LANGUAGE pl_pure_untrusted IMMUTABLE STRICT
AS $$ sqrt (a*a + b*b) $$;
A function body is a single Pure expression, and the SQL argument names are
its variables. Helpers go in an ordinary with ... end or when ... end
block:
CREATE FUNCTION fib(n int) RETURNS numeric LANGUAGE pl_pure_untrusted AS $$
loop n 0L 1L with
loop 0 a _ = a;
loop k a b = loop (k-1) b (a+b)
end
$$;
SELECT fib(100); -- 354224848179261915075
A body can open with using declarations, which load Pure modules into the
backend’s interpreter:
CREATE FUNCTION sd(xs float8[]) RETURNS float8 LANGUAGE pl_pure_untrusted AS $$
using gsl;
gsl::stats::sd (matrix xs)
$$;
Pure’s distinctive feature is symbolic rewriting, and it is available in SQL:
CREATE FUNCTION simplify(e text) RETURNS text LANGUAGE pl_pure_untrusted AS $$
str (reduce (val e) with
x*1 = x; 1*x = x; 0*x = 0; x*0 = 0; x+0 = x; 0+x = x
end)
$$;
SELECT simplify('x*1 + 0*y + (x+0)'); -- x+x
Argument names have to be valid Pure variable names. That excludes Pure
keywords (case, end, of, type, when, with, …) and constant
symbols such as true and false. An argument called true would turn
into a constant pattern, so the function would reduce only when the argument
equals 1 and would silently return an unevaluated expression otherwise. The
validator rejects such names.
2.2 Types
| SQL type | Pure value passed in | Accepted on return |
|---|---|---|
| int2, int4 | machine int | int or bigint; out-of-range is an ERROR |
| int8 | bigint (5L) |
int or bigint; out-of-range is an ERROR |
| numeric | bigint if integral, else an exact rational (x%y); NaN and Infinity as doubles |
int, bigint, rational, double |
| float4, float8 | double | double, int, bigint, rational |
| bool | 1 or 0 (Pure’s true and false) |
int; nonzero is true |
| text, varchar, char, name | string, converted to UTF-8 | strings only |
| arrays | lists (nested lists for multi-dimensional arrays) | list or matrix |
| composite types, record | record {"col"=>value, ...} |
record, or tuple in column order |
| NULL | sql::null |
sql::null |
| any other type | its text form, as a string | a string, passed to the type’s input function |
The table follows three rules:
- Nothing is lost silently. The prototype turned numeric into a double:
12345678901234567890.123456789came back as12345678901234600000. It also returned 4464 for an int2 result of 70000, and gave-9223372036709301616for the int8 square of 3037000500. Each of these becomes either exact or an ERROR. - A text result must be a string. In Pure, an application that no rule
matches is a normal form:
take 5L xsstaystake 5L xs, becausetakewants a machine int. If any value were turned into text withstr, a type mistake would come back as wrong text instead of an error. - int2 and int4 map to Pure’s own machine int, so prelude functions and
literal patterns (
f 0 = ...) work as Pure programmers expect. That brings Pure’s machine-int behaviour with it: arithmetic wraps silently, as the Pure manual says (2147483647 + 1gives-2147483648). The result is range-checked on return, but a wrap inside the computation goes undetected.pmul(65536, 65536)with bodyx*y+1returns 1, where SQL would raise “integer out of range”. See open question 2.
2.3 Set-returning functions
CREATE FUNCTION primes(n int) RETURNS SETOF int LANGUAGE pl_pure_untrusted AS $$
take n (sieve (2..inf)) with
sieve (p:qs) = p : sieve [q | q = qs; q mod p] &
end
$$;
SELECT * FROM primes(10);
A set-returning function returns a list or a lazy stream. The handler forces
it one element at a time and writes each element into a tuplestore, which is
what the executor does with any set-returning function in FROM anyway
(ExecMakeTableFunctionResult). An infinite stream runs until
statement_timeout or a cancel stops it, since the interrupt hook is
checked while the stream is forced. For RETURNS TABLE(...) each element is
a tuple or a record.
2.4 Database access
Everything pg_pure adds to Pure lives in the namespace sql:
| Pure | Meaning |
|---|---|
sql::query q, sql::query (q, p1, p2, ...) |
run a query and return its rows as a list; a row is a tuple of column values, and a one-column row is the bare value |
sql::exec q, sql::exec (q, p1, ...) |
run a command and return the number of rows processed |
sql::notice msg, sql::warning msg |
send a message to the client |
sql::null |
SQL NULL |
sql::error sqlstate message |
the exception an SQL error turns into; throwing it raises that SQL error |
CREATE FUNCTION order_total(id int) RETURNS numeric LANGUAGE pl_pure_untrusted AS $$
foldl (+) 0 [qty * price | (qty, price) =
sql::query ("SELECT qty, price FROM line_item WHERE order_id = $1", id)]
$$;
CREATE FUNCTION add_user(email text) RETURNS bool LANGUAGE pl_pure_untrusted AS $$
catch dup (sql::exec ("INSERT INTO users(email) VALUES ($1)", email) > 0)
with dup (sql::error "23505" _) = false; dup e = throw e end
$$;
Parameters go to the server as text with no declared type, as libpq’s
PQexecParams does when it is given no types. The server works out each
parameter’s type from the query. Doubles are sent with 17 significant digits
so they round-trip exactly, lists go as array literals, and sql::null goes
as NULL. Callers pass parameters instead of splicing values into the SQL
string.
Each sql::query and sql::exec call runs in its own internal
subtransaction, as in PL/Python. If the statement fails, the subtransaction
is rolled back, which releases its locks, pins and cache references. The
SQL error then reaches Pure as the exception sql::error sqlstate message,
so Pure code can match on SQLSTATE (above, 23505 for a unique violation).
When Pure code catches the error, the transaction carries on. When it does
not, the original error is re-raised with its detail, hint and position
intact. Any other uncaught Pure exception becomes ERROR: pl_pure_untrusted: uncaught
exception: <value> with SQLSTATE 38000.
2.5 Triggers
CREATE FUNCTION line_total() RETURNS trigger LANGUAGE pl_pure_untrusted AS $$
insert new ("total" => new!"qty" * new!"price")
$$;
CREATE TRIGGER line_total BEFORE INSERT OR UPDATE ON line_item
FOR EACH ROW EXECUTE FUNCTION line_total();
A trigger body sees three variables:
newandoldare records, orsql::nullwhen the operation has none.tgis a record with the keys “name”, “when”, “level”, “op”, “table_name”, “table_schema” and “args”.
In a row-level BEFORE trigger, returning a record makes it the row, and
returning sql::null skips the operation, which is PostgreSQL’s own rule
(returning NULL means skip). PostgreSQL ignores the return value of
statement-level and AFTER triggers.
2.6 DO blocks and procedures
DO LANGUAGE pl_pure_untrusted $$
sql::exec "UPDATE line_item SET total = qty * price WHERE total IS NULL"
$$;
A DO block is compiled as a temporary named function, called once and then
cleared, so its compiled code can be freed. CREATE PROCEDURE ... LANGUAGE
pl_pure_untrusted works with CALL. Transaction control inside procedures
(sql::commit, sql::rollback) is a later phase.
2.7 Configuration
shared_preload_libraries = 'pg_pure'is recommended but not required. With it, the postmaster builds the interpreter once, loads the modules, warms up, and every backend inherits the result through fork. See section 3 for what that buys.pg_pure.using(default'math') lists the modules every interpreter loads. Exact numeric needsmathfor rationals. It is read when the interpreter is created, so with preloading that happens at postmaster start.
That is the only setting. The prelude is found through libpure’s built-in library path, or through PURELIB in the server’s environment.
3. Measured costs
| What | Measured |
|---|---|
| Create an interpreter with the prelude | 1.53 s, +87 MB RSS (3 runs) |
Create an interpreter with --noprelude |
0.002 s, +4 MB |
| First call of the first function (LLVM compiles the prelude code the call reaches) | 1.91 to 1.94 s more (3 runs) |
| First pl_pure_untrusted call in a new backend, no preload | 5.24 to 5.33 s (5 runs) |
| Same, preloaded, no warm-up | 1.17 to 1.18 s |
| Same, preloaded and warmed in the postmaster | 0.16 to 0.17 s; 8 new backends in parallel take 0.24 s wall |
| Postmaster start with preload and warm-up | 5.5 s, 251 MB RSS |
| First call of each further new function | 72 to 165 ms |
| Backend private memory, preloaded | 2.5 MB at connect, 104.5 MB after the first Pure call, 110.9 MB after 1M calls |
1M calls of x*y+1 in one SELECT (jit = off) |
plain SQL 96 to 99 ms, PL/pgSQL 305 to 308 ms, pl_pure_untrusted 455 ms |
| 1M calls of string concatenation | PL/pgSQL 406 ms, pl_pure_untrusted 578 ms |
One pure_appv call, outside PostgreSQL |
210 to 216 ns with machine-int arguments, 427 ns with bigint arguments |
What follows from the table:
- Preloading turns a 5.3 s first call into 0.16 s, so the docs recommend it and the tests cover both modes. A per-backend interpreter makes a connection pooler worth having either way.
pure_interp_compile(ip, 0)(“compile everything”) ran in 0.01 s and did not reduce the 1.9 s first call. The warm-up therefore has to run real code.- The first call dirties about 100 MB of memory inherited from the postmaster. The likely cause is that Pure’s reference counting writes into every shared expression it touches, and LLVM writes to its own state. I have not confirmed this.
- About 1.8 s of the 5.3 s cold first call is unexplained compared with the
standalone 3.5 s (create plus first call). It is probably the prototype’s
using math, but I have not broken it down.
4. The engine
- One interpreter per backend. A backend creates it on first use or
inherits it from the postmaster. libpure starts no threads (a process with
a live interpreter has one thread), so forking with an interpreter in
memory is safe. EXEC_BACKEND builds, which means Windows, cannot inherit
it.
_PG_initreturns at once underIsBinaryUpgrade, as pg_cron’s does. - Function cache. Entries are keyed by function oid and validated by
the pg_proc row’s xmin and tid, as PL/Perl does. A function is compiled
as
__pg_pure_<oid> a b = (<body>);. The parentheses keep a stray;from defining globals. In the prototype, the bodyx; helper y = y+100definedhelperfor every other function in the backend. A recompileclears the old rules first, because Pure appends new rules rather than replacing old ones. - Calls. Arguments are converted before the call. The call goes through
pure_appxv, and the result is converted after Pure has returned. - Boundary rules. These are correctness invariants, carried over from
the prototype and libpure’s embedding contract:
- No PostgreSQL ERROR may longjmp through Pure frames. Every callback
from Pure into PostgreSQL (SPI, notices, type I/O) runs under
PG_TRY, and SPI calls also run in an internal subtransaction. A caught error turns into a Pure exception. The savedErrorDatais re-raised if Pure does not catch it. - No Pure exception escapes into PostgreSQL. The x-variants return the
exception value, and the handler turns it into
ereportafter the Pure call has returned. The value must be released withpure_freenew, notpure_free. - Conversions that can raise an error run outside any Pure call.
- Cancel: the interrupt hook only reads
QueryCancelPending || ProcDiePending. After a failed call,CHECK_FOR_INTERRUPTS()lets PostgreSQL report its own cancel or terminate error. - Stack:
pure_set_stack_limit(&frame, max_stack_depth / 2)runs before each call, so runaway recursion becomesstack_faultand then an ERROR. - Memory: Pure values live in Pure’s malloc’d heap. The only Pure values kept across calls are the cached function closures.
- No PostgreSQL ERROR may longjmp through Pure frames. Every callback
from Pure into PostgreSQL (SPI, notices, type I/O) runs under
- libpure must not export LLVM symbols. See section 5.
5. The LLVM symbol clash (blocking)
PGDG’s PostgreSQL 17 and 18 packages for Debian 13 ship llvmjit.so linked
against libLLVM 19.1, while libpure links libLLVM 21.1. Both can end up in
the same backend: jit = on is the default, and PostgreSQL JIT-compiles any
query that costs more than jit_above_cost (100000).
libpure exports about 200 C++ symbols that are template instances over LLVM
types, for example
std::_Sp_counted_ptr_inplace<llvm::orc::ThreadSafeContext::State, ...>::_M_dispose.
The dynamic linker binds the other LLVM’s references to libpure’s copies.
When the backend exits, LLVM 19’s ORC teardown runs code built against LLVM
21, and the backend dies with SIGSEGV. A backend crash makes the postmaster
restart every backend, so one crash takes down every session on the server.
The fix is to link libpure with -Wl,-Bsymbolic and a version script that
hides *4llvm* symbols, and to link the pure executable directly against
libLLVM, since it had been getting LLVM symbols through libpure. Each case
below ran on a fresh server with a fresh backend. A case counted only after
both query results came back correct:
| libpure build | Pure only | PG JIT only | Pure, then PG JIT | PG JIT, then Pure |
|---|---|---|---|---|
| stock | ok | ok | SIGSEGV | SIGSEGV |
-Bsymbolic |
ok | ok | SIGSEGV | ok |
-Bsymbolic plus the version script (0 LLVM exports) |
ok 3/3 | ok 3/3 | ok 3/3 | ok 3/3 |
With the fix, libpure’s make check still passes 95 tests, the same as the
stock build, and make check-embed passes. The prototype’s regression
output is unchanged with jit_above_cost = 0.
This is a libpure change, so it goes to the fork (open question 3). The
pg_pure test suite keeps a test that exercises PostgreSQL JIT and Pure in
the same backend, in both orders, and reads the server log for “terminated
by signal”. Verified: with the fixed libpure, the pg_pure extension (not
just the prototype) runs the matrix 0-crash, 3 runs each, on a PG 18 server
with JIT forced on (jit_above_cost = 0).
5a. Two other ways to avoid the clash
The -Bsymbolic fix lets libpure (LLVM 21) and llvmjit (LLVM 19) coexist.
There are two situations where the fix is not even needed:
- A PostgreSQL built without LLVM. On Debian,
llvmjit.sois a separate package (postgresql-18-jit). With it uninstalled, no backend ever loads libLLVM, andjit = onis silently a no-op. The stock libpure (LLVM symbols still exported) then runs 0-crash withjit = onandjit_above_cost = 0(verified). So one supported deployment is: install PostgreSQL without the JIT package, and pg_pure works with any libpure build. The server loses its own JIT, which for most pg_pure workloads is a fair trade. - A PostgreSQL built against the same LLVM major as libpure. When the
server’s
llvmjitand libpure both link libLLVM 21, there is one copy and no interposition. This is the path the LLVM-21 upgrade work makes available. Not yet tested here, because PGDG’sllvmjitfor Debian 13 is pinned to libLLVM 19; it will be exercised by the from-source cassert build in phase 1.8.
The -Bsymbolic fix is kept regardless, because it is the only option that
works with a stock PGDG server (LLVM 19) and an LLVM-21 libpure in the same
backend, and it costs nothing when the other two conditions also hold.
6. Pure behaviour users need to know
The docs explain each of these with an example:
- An application that no rule matches stays unevaluated. The result then fails to convert, and the error message says so with a hint: “check the argument types your rules expect; 5L is a bigint and does not match the pattern 5”.
- Machine-int arithmetic wraps (section 2.2).
0Ldoes not match the pattern0, andtake 5L xsdoes not reduce. int8 arguments arrive as bigints, so code should use==for comparisons orint nfor the conversion.str 3Lis"3L". pg_pure’s own conversions to SQL never add theL.- Definitions made at run time with
evalare not transactional and survive a ROLLBACK. - A catch-all handler can catch the cancel exception. The cancel stays
pending and PostgreSQL acts on it once the call returns, which I checked
for one case (
catch (\_ -> -1) (loop n)understatement_timeout). Loops that catch and keep going are untested.
7. Pure modules
Pure modules are scripts in PURELIB, often with a C shared object next to
them (for example gsl.pure and gsl.so). Users load them with
pg_pure.using or with a using line at the top of a body. Before a module
goes on the tested list, it is checked for process exits and aborts, signal
handlers, threads, writes to stdout, and global error handlers.
| Module | Status |
|---|---|
| math (prelude) | loaded by default, used for rationals |
| pure-gsl | turns off GSL’s abort-on-error handler (gsl_set_error_handler_off). Builds on the box; gsl::stats::mean and sd work in standalone Pure. Not yet run inside PostgreSQL. |
| pure-mpfr, pure-csv, pure-xml, pure-stldict, pure-stllib, pure-rational | candidates for the tested list; not yet checked |
| pure-glpk | GLPK aborts the process on an internal error unless an error hook jumps out, and pure-glpk installs no such hook (found by reading the code). Needs a fix before use. |
| pure-ffi, pure-sql3, pure-odbc, pure-sockets | untrusted only, at the user’s risk |
| gtk, tk, gl, audio, midi, faust, lv2, lilv, octave, avahi, bonjour (starts threads), fastcgi | out of scope |
Licenses: pg_pure itself is under the PostgreSQL license, like pg_fts and pg_weave, and links libpure (LGPL-3) dynamically. pure-gsl, pure-glpk and pure-rational are GPL. They are loaded by the user, not shipped with pg_pure. This is not legal advice.
8. What the prototype gets wrong
Each item was reproduced on the box. v1 fixes all of them:
- A
;in a body defines global rules that other functions see. - numeric goes through double and loses digits.
- int2 and int4 results are not range-checked (70000 comes back as 4464).
- An int8 overflow returns a wrapped value instead of an error.
- Any non-string result becomes text through
str, so unevaluated expressions are returned as text. - It does not build on PostgreSQL 17:
max_stack_depthlives intcop/tcopprot.hthere and inmiscadmin.hin 18. With that one include fixed, its regression output is identical on 17 and 18.
9. The trusted language, pl_pure
A Pure program can call any C function through an extern declaration. The
standard prelude itself declares system, popen, fopen and exit, plus
malloc, free and pointer access. Pure also has inline C (%< ... %>,
which runs clang), eval and evalcmd, and using of any file on disk.
libpure has a restricted flag, but it only blocks a few interpreter
operations and is no sandbox. A trusted language therefore needs a “safe
mode” in libpure that:
- rejects
extern, inline code,evalandevalcmd, andusingoutside an allowlist; - loads a curated prelude without the system, posix and pointer modules;
- runs one interpreter per SQL user, as trusted PL/Perl does, so that SECURITY DEFINER functions cannot share state across users.
Each per-user interpreter costs the full creation price (1.5 s, about 100 MB) because an interpreter cannot be cloned. Any sandbox escape gives the OS user that runs PostgreSQL to whoever can create functions. This is the riskiest part of the project. It is a separate phase, and it starts only after the libpure safe-mode design has been reviewed (open question 4).
10. Platforms
Tested (regression suite green, 0 backend crashes):
- Linux (Debian 13), PostgreSQL 17, 18 and 19, libpure on LLVM 21.
- FreeBSD 14.5, PostgreSQL 17, libpure on LLVM 21. pg_pure built clean against the fork’s libpure with no source change; the suite passed on a JIT-off cluster (FreeBSD’s PG 17 llvmjit is LLVM 19, so the same coexistence rule applies as on Debian, section 5a).
Not planned:
- Windows. Three independent blockers, each checked, not assumed:
(1) Windows backends use EXEC_BACKEND, not fork, so they cannot inherit a
postmaster-built interpreter;
_PG_initruns in each backend withIsUnderPostmaster = true, so the preload warm-up buys nothing and every backend pays the full ~5 s interpreter creation. (2) A Windows libpure exists only on the fork’s unmergedmsvc-portbranch (PR #50), built with MSVC/CMake; mixing that with a MinGW-built PostgreSQL in one process is an ABI hazard. (3) The embedding contract (no process exit, host hooks) was validated only on POSIX. santorini (Windows 11 ARM64) has clang 20 and a PostgreSQL 16 from Strawberry Perl, neither of which matches what a port would need. Revisit only if there is a real user. - Background workers or scheduling written in Pure. pg_cron contributed
only the
_PG_initpreload pattern and the GUC conventions. - Multithreaded PostgreSQL.
- Custom aggregates and operators need no extra work: they call ordinary functions.
11. Open questions
- Naming. Decided (Greg, 2026-10-27):
pl_pureis the trusted language and the one we ship;pl_pure_untrustedis the superuser-only one. Both are separate extensions over the onepg_pure.so. - int4 semantics. Still open. One option is Pure’s machine ints: wraps
go undetected inside a computation (the result is still range-checked on
return), but the prelude and literal patterns (
f 0 = ...) work. The other is to pass every integer as a bigint: SQL-style overflow errors, about 2x the per-call cost (427 ns against 216 ns), andtake n xsorf 0 = ...stop working for SQL integers. The built extension uses machine ints for int2/int4 and bigints for int8. - Where the libpure symbol fix lands. It could be a new commit on PR
48’s branch (the PR is titled “Make libpure.so safe to embed in a host
that owns the process") or a separate PR. Greg is separately moving PostgreSQL to LLVM 21, which removes the clash for a from-source server (section 5a); the fix is still wanted for stock PGDG servers.
- The trusted language.
pl_pureis the ship target, so the libpure safe mode (section 9) is on the critical path, not optional. The remaining question is only whether safe mode is designed with the Pure maintainer upstream or carried in the fork.