Embedded Postgres: reference¶
The contract of the embedded database (rowx[embedded]). The guide shows how to use it; this
page says what it promises. How it is built, why, how it is tested and what it depends on are on the
Contributing pages. Each section follows the topic page it
extends.
Overview¶
An embedded database is a PostgreSQL 18 that runs inside the Python process: no server, no Docker and no child process. It is development, checking and tests only. Production keeps a real server.
- An embedded database is a live Postgres 18 for the purposes of the checker's "always needs a live database" rule:
the engine reports
server_version18.4 and answersPREPAREand describe like a server. - In code,
rowx.embedded.Databaseopens one. The DSN grammar gains theembeddedscheme, accepted by the URL-taking entry points (rowx check,rowx check-compat,rowx migrateand the pytest plugin), and not accepted byconnect,PoolandConnectOptions.from_dsn. - It is optional, and all of it is in
rowx-embedded.rowx-corekeeps zero runtime dependencies and no WASM payload, androwx-postgreshas no embedded code:import rowx_postgresnever importsrowx_embedded.
Architecture¶
The layout of the companion (rowx-embedded, rowx-embedded-host and the Python glue in rowx_embedded), build
isolation, threads and runtime ownership, and the front proxy are under
Architecture. The rules the front proxy enforces are in
Driver.
API¶
rowx.embedded¶
rowx.embedded re-exports rowx_embedded's Database (and rowx_embedded exports it too). Importing it without the
extra raises an ImportError: Install "rowx[embedded]" to use rowx.embedded.
class Database(rowx_postgres.Pool):
def __init__(
self,
directory: str | PathLike[str] | None = None,
/,
*,
options: ConnectOptions | None = None,
acquire_timeout: float = 30.0,
init: Hook | None = None,
setup: Hook | None = None,
reset: Hook | None = None,
) -> None
directory: str | None # the normalised absolute path, or None for memory
ready: bool # the instance has started and the native pool is built
closed: bool
dsn: str # a plain Unix-socket DSN
# inherited: open, close, terminate, acquire, transaction, size, idle, waiting, min_size, max_size,
# expire_connections, async with
- A
Databaseis aPooland anExecutor:query(...).one(db),db.acquire()anddb.transaction()work, andisinstance(db, Pool)holds.Migrator.run(db)works beforeopen(). directorymust be absolute (a relative path is aValueErrorin the constructor);.,..and repeated or trailing slashes are collapsed, and the root is refused.Noneis a fresh in-memory database.optionscarriesapplication_name,server_settings,statement_cache_size,connect_timeout,extraanddatabase. Ahost,port,user,passwordorsslmodeis aValueError("... cannot be set for an embedded database"). The options are pointed at the instance's socket DSN when it starts.- The pool is fixed at one session:
max_sizeis 1 andmin_sizeis 0 (a nativemin_sizeof 1 would hold the only session as soon as the pool opens). There is nomax_size,idle_timeout,max_lifetime,max_queries,ping_afterorreset_queryargument; the pool uses thePooldefaults for them (reset_queryisRESET_QUERY). dsnispostgresql://postgres@/postgres?host=<dir>&sslmode=disable. Reading it before the database has started ("call open() first"), afterclose, or in a forked child of a started database raisesInterfaceError. Another client can use it only while the database holds no session, checked out or idle; after a query the idle connection holds the session until the default idle timeout (600 s), so callexpire_connections()first.terminate()does not block the caller for a running statement when the database is in memory: the instance is abandoned at once. A directory database is closed properly, soterminate()waits for a running statement.- Snapshots are not public API in v1:
start_from_snapshotandtake_snapshotare used only by the checker and the plugin, and the instance type (EmbeddedDatabase) is private.
The embedded:// grammar¶
Parsed with urllib.parse, by the URL-taking entry points (rowx check, rowx check-compat, rowx migrate, the
scratch service and the pytest plugin). The scheme is embedded (case-insensitive), the network location is empty, and
the path is empty (memory) or an absolute path (a directory). These are ValueErrors, and the message never echoes a
value:
- a host, port, user or password; a relative path; a fragment;
- the query parameters
host,hostaddr,port,user,passwordandsslmode, and a repeated parameter.
Other parameters (options, application_name, connect_timeout, dbname, ...) are forwarded to the socket DSN, so
options=-c search_path=... works. Rust never receives embedded:// or a missing host.
connect, Pool and ConnectOptions.from_dsn do not accept an embedded URL: it is not a connection string, and they
raise InterfaceError ("invalid connection string"). rowx database ... reports an embedded URL as unsupported (exit
2).
Ownership and the pool¶
- A
Databaseowns one instance, started on first use (open,acquireor a query) in a thread whose future every first-use caller shares. The constructor does no IO, so a module-level database is fine and works across event loops. A failed start leaves nothing behind and the next use tries again. Closing or terminating the database closes the instance, a close that races the start waits for it, and a database that was never opened closes cleanly. - Until the instance has started,
size,idleandwaitingare 0,readyis false andclosedreports the database's own state. - A database goes through Python for every query (as a pool with hooks does), because the native pool is built only once the instance has started.
- The first use (
open,acquireor a query) is bounded by the acquire timeout; the start continues in its thread, and a timeout is aPoolTimeoutError.
Fork rules and errors¶
| Condition | Result |
|---|---|
| Companion not installed (or no wheel for the platform: not macOS arm64, Linux x86_64/aarch64) | import rowx.embedded: ImportError Install "rowx[embedded]" to use rowx.embedded.. An embedded:// URL: "install rowx[embedded]". |
| An engine that fails to import | ImportError ("the embedded engine could not be loaded") with the cause. |
| An engine that fails to start | InterfaceError with the cause. |
| A directory another instance holds | InterfaceError: "database directory ... is in use by pid N". |
| A snapshot the engine refuses (corrupt, or from another build) | InterfaceError (internally a SnapshotRejectedError, which the plugin rebuilds once). |
| A second concurrent session | DatabaseError 53300, with a hint. |
A Database that started in the parent, used after a fork |
InterfaceError. The parent's instance survives. |
A Database that never started in the parent, used after a fork |
It starts its own instance in the child. |
connect, Pool or ConnectOptions.from_dsn given an embedded:// URL |
InterfaceError (not a connection string). |
Grammar violations, a relative directory, or server options on a Database |
ValueError. |
Driver¶
DSN resolution¶
A Database's options are resolved to a socket DSN before the driver sees them:
postgresql://postgres@/postgres?host=<dir>&sslmode=disable. Authentication is trust. The dbname of an embedded URL
is honoured only when it is postgres: the engine has one database, and another name fails with
database "x" does not exist.
Embedded¶
- One session per instance. The slot is Idle, Active or Tearing down. A startup that finds it Active is answered
with
FATAL 53300("sorry, too many clients already") and the hint "an embedded database serves one session at a time; use max_size=1", at once. - The teardown wait. A startup that arrives while the previous session is rolling back and discarding its state
waits for the slot, up to 10 s, so a sequential close-then-connect never sees
53300. A startup that arrives while the active session's client has already gone but its statement is still running waits up to 30 s for it. - Cancels are ignored. A
CancelRequestis closed at once and cancels nothing. A running statement cannot be interrupted, andstatement_timeoutis accepted but never fires. The caller of a cancelled task is released, and the next statement on that connection waits for the running one. - Socket and auth. The public socket is in a private root,
<TMPDIR>/rxe-<pid>-<random>, created0700and locked for the life of the instance. The socket path must fit a Unix socket (at most 100 bytes), or the start fails and namesTMPDIR. There is no TCP listener, no TLS and no password. - The owner lock. A directory is opened by one instance. The engine's lock error carries no pid, so a directory's
owner also records itself in
.<name>.rowx-ownerbeside the directory (not inside, because the engine refuses a directory with entries it does not know). A second opener's error names the pid. A start sweeps the private roots of processes that died without closing. - Durability. A directory instance keeps the engine's defaults (
fsync,full_page_writesandsynchronous_commiton), so a directory left by an aborted run opens again. A memory instance, including one booted from a snapshot, runs with all three off. - Snapshots. A snapshot is a self-describing container (
ROWXSNAP, a JSON header, the engine's backup) of a live instance, taken without stopping it. The header records the snapshot format, the engine and runtime versions, the SHA-256 of the wasm module and the extension set, and the payload digest; an instance refuses a snapshot that does not match all of them, before it trusts the payload. - Booting a snapshot. An engine backup needs crash recovery on every boot. So the first boot of a snapshot in a
process derives a cluster seed from it (restoring it into a private directory, opening and closing that cleanly, and
packing the result); later boots of the same snapshot start a memory database from the seed. If the seed cannot be
derived the snapshot is restored into a directory under the private root instead, which is the documented path and is
slower.
Instance.bootisfresh,seedorrestore. The seed format is not documented by the engine, so it is pinned tooliphaunt-wasix =0.3.2.
Checker¶
- Selection. An embedded URL makes the checker use the backend's embedded service (
backend.embedded(url)), which owns the instances. Checking never imports user code. - Memory URL. It implies
--scratch: the checker starts one instance, runs the migrations on it, and, with-j N, takes one snapshot and bootsN - 1more from it, one per worker. The number of workers ismin(jobs, distinct statements). It needs[tool.rowx.migrate](exit 2 otherwise). There is no drift gate, because the database is built from the files. - Directory URL. The instance is started once, outside connection retries, so a directory that is owned elsewhere
fails at once (exit 2). The drift gate runs against its socket DSN (unless
--no-drift-check), and every query is described on it with one worker, whatever--jobssays.--scratchwith a directory URL is a usage error. - Errors read as on a server ("the migrations failed on the scratch database: ..."), and every message has the socket DSN and its directory redacted.
- Interrupts. A memory run abandons its instances at once. A directory run, on the first Ctrl-C, says that it is waiting for the running statement and starts closing; a second Ctrl-C abandons the directory (it is left as a crash leaves it, and it opens again) and exits.
- Ownership at the end. No instance, socket directory or private root survives a diagnostic, an error or an interrupt of the check.
- The checker's
statement_timeoutdoes not fire on an embedded database. rowx check-compataccepts a memory URL: each revision's schema is built as the memory URL's scratch is (one instance per describe worker,--jobs), both schemas stay open for the run, and no disposable-server warning is printed. A directory URL is aCheckError(exit 2), because both schemas are built from the migrations. Messages redact the instances' socket DSNs and directories.
Migrations¶
rowx migrate run|info|revert|skip accept a directory URL. The command starts the instance once, before and outside the
connection retries, runs on its socket DSN and closes it, and an instance that an interrupted run could not close in
time is abandoned. A memory URL is exit 2 ("an in-memory embedded database is gone when the command exits"), and so is
rowx database ... for any embedded URL. rowx migrate add touches no database.
Migrations also run from code (Migrator.run on a connection, pool or Database). The PostgresMigrations service
takes an embedded URL only so that a scratch can be built from it: it never connects to one, and open refuses.
Testing¶
The plugin¶
rowx.testing asks the backend for a provisioner, and the backend decides what a template is. On embedded://:
- Snapshot mode. The plugin builds a snapshot of the migrated database once, and each test boots its own instance
from it (
rowx_database_urlis that instance's socket DSN,rowx_connectionandrowx_poolconnect to it). Dropping the database abandons its instance. - The pool is
max_size=1with a 15 s acquire timeout. That is the wait for the single slot, so a query that queues behind a statement of the same test for longer fails withPoolTimeoutError. A test that holdsrowx_connectionand usesrowx_poolfails after 15 s with the53300hint. - The cache. The snapshot is
<cache>/d/rowx-embedded/<p>_<h>.snapshotin pytest's cache directory (config.cache.mkdir("rowx-embedded")).<p>is the project prefix of the server template name, and<h>is the same hash, extended with the template's identity: therowx-embeddedversion, the engine, runtime and wasm digest, the snapshot format, the sorted extension set and the URL's forwarded query. A file is the snapshot followed by its SHA-256, written beside its final name and renamed into it, so a reader sees all of a snapshot or none. A build takes an exclusiveflockon<p>.lockper project (polled, so a cancelled wait leaves nothing locked), so-n 2builds once. - Stale snapshots. A build removes the same project's other
<p>_*.snapshotfiles, never another project's, unless--rowx-keep-templates. - No cacheprovider (
-p no:cacheprovider). The snapshot is built once per session in a temporary directory that is removed at the end. - A snapshot that the engine refuses is rebuilt once, until a boot has worked.
embedded:///diris a usage error ("rowx.testing boots a fresh database from a snapshot for every test"), and a missing companion fails the session with the install hint.
The cost per test is about one boot and one teardown. Measured on macOS arm64 (a snapshot of 40 MB): the first boot of a
process is about 0.72 s (it derives the seed), later boots about 0.19 s, a close about 0.21 s, and the plugin adds about
0.4 s per test. See bench/embedded.py.
The markers, the lanes and the recorded divergences are under Testing.
Dependencies¶
The engine is the WASIX build of PostgreSQL 18 and Wasmer, in rowx-embedded only. The verdicts and reasons are in
Dependency evaluation.
Security¶
- Each instance's socket lives in a private directory created
0700underTMPDIRand locked for the instance's life. Authentication is trust, and the directory's permissions are the only access control. There is no network listener. - A DSN of an embedded instance carries no secret, and every CLI message redacts it and its socket directory.
- There is no TLS (
SSLRequestis answeredN) and no password authentication, so the engine must not be used as a stand-in where either matters. - A memory instance runs with
fsyncoff; a directory is as durable as the engine's defaults.
Non-goals¶
- Production use of the engine.
- Several concurrent sessions per instance, query interruption and
statement_timeout. CREATE DATABASEworkflows (isolation uses snapshots), TLS, password authentication and a TCP listener.- Windows and macOS on Intel (no engine ships; the extra still installs).
- A public snapshot API.
- Extensions outside the shipped contrib set.
Decisions¶
The decision table E1 to E13 is under Decisions.