Projects are prefixes
The connection URL identifies a project, resolved once at connection accept. After that it's ordinary SQL — no per-query auth, no row-scoping boilerplate. Adding a tenant writes no new heap pages and books no connection slot.
Basin is a bucket-native, multi-tenant Postgres
alternative. Projects are S3 prefixes, not databases — so operator
cost tracks bytes actually stored, not databases provisioned. One binary,
pgwire on the front, Vortex-compressed columnar files on any S3-compatible
bucket on the back. Your Postgres drivers just work.
ordinary pgwire — no driver changes
A new project doesn't fork a process, doesn't book a VM, and doesn't draw a monthly minimum. It's a bucket prefix the engine refuses to read across. That one decision is where the RAM, the cost curve, and the tenant ceiling all come from.
client psql pgx asyncpg Drizzle Prisma sqlx Diesel Django GORM
pgwire v3 over TLS
pgwire termination, then a parse through libpg_query — the actual
PostgreSQL parser, vendored. Typed-AST dispatch sends DDL/DML to a handler or
lowers to a DataFusion logical plan. RLS predicates are injected here, before
placement lookup decides transactional or analytical.
gRPC, mTLS, per-project token
One owner per (project, partition), holding Arrow state lazily
loaded from WAL plus columnar files. Point lookups, range scans and
single-shard transactions land here; a background compactor folds them down
into the catalog.
append keyed by (project, partition)
A Raft group commits to local NVMe on quorum and flushes to the bucket in
batches every ~200 ms. That flush interval is the whole honesty story on
write benchmarks: the default acks before fsync, and
SET basin.synchronous_commit = on buys group-committed fsync
durability instead.
one prefix per project, no exceptions
Vortex-compressed columnar files under /projects/{id}/, a
Lakekeeper-compatible Iceberg REST catalog over them, and the WAL segment
archive alongside. Analytical queries read this layer directly, without
waking a shard owner.
Fig. 1 — write path, top to bottom. Reads short-circuit at whichever layer already holds the answer.
Fig. 2 — one project, one prefix. Nothing belonging to project A is
ever written under project B's prefix, and the check lives at the
basin-storage API boundary rather than in each caller.
The connection URL identifies a project, resolved once at connection accept. After that it's ordinary SQL — no per-query auth, no row-scoping boilerplate. Adding a tenant writes no new heap pages and books no connection slot.
Data lands as Vortex-compressed columnar files (Parquet opt-in per table)
on any S3-compatible bucket, with a file-backed WAL in front. Bytes at rest
compound: 102× smaller than Postgres on real S3 at 100k rows.
pgwire, REST, auth, RLS, vector search, cron and Wasm functions are one
binary. 310 KiB of RAM per held-open connection means a
connection-heavy front end stops being the thing that sizes your box.
Every other figure on this page is workload-dependent and argued about below. These two fall out of the architecture: a from-scratch tokio server instead of a forking daemon, and a prefix instead of a provisioned database.
1,000 held 0 refused
100 held 900 refused
held — one dot is ten connections refused at accept
Fig. 3 — 1,000 concurrent connections, same box. Measured on the
LocalFS server_lifecycle card. This is not a tuning result: it is the
difference between an async server and a process-per-connection daemon.
Auth, an HTTP API, realtime, blob storage, vector search, sandboxed functions, change capture, cron and the geo and text-search extension equivalents are not sidecars you deploy next to Basin. They are crates inside the same binary, reading the same catalog, enforcing the same RLS. Each tile carries the caveat that comes with it.
Signup, sign-in, magic link, password reset, email verify, JWT plus refresh, and API keys — issued and verified in-process.
Auth tables live in each project's own storage under the basin_auth schema. auth.uid(), auth.role() and auth.jwt() read from the JWT at connection open and work inside RLS policies.
A PostgREST-compatible HTTP surface generated from your schema: GET/POST/PATCH/DELETE on /rest/v1/<table>.
Bearer-JWT auth, RLS enforced on the same plans pgwire uses, and an RPC mount at POST /rest/v1/rpc/:fn that invokes LANGUAGE sql and LANGUAGE wasm functions over HTTP.
Row-change streams over SSE at /realtime/v1/sse/:project/:table and a multiplexed WebSocket at /realtime/v1/ws/:project.
Fed by the hot-tier UPDATE/DELETE fast paths as well as cold writes; in-transaction changes drain in order at COMMIT and are dropped on ROLLBACK. Per-project memory budget with isolated back-pressure. Implementation complete — some integration-harness slices are still #[ignore]-gated.
Catalog-backed object storage beside your rows: bucket CRUD, upload, download, list, and single or bulk delete under /storage/v1/.
Signed URLs are HMAC-SHA256 over (project, bucket, path, expiry) with constant-time verify and independent key rotation; per-object RLS and per-project byte counters included. HEAD, COPY and resumable multipart are deferred to v1.1.
Native vector(N) and halfvec(N) columns with <->, <#> and <=> operators. No extension to install.
HNSW and IVF-flat indexes, both accepting pgvector-style build params. The planner routes ORDER BY x <-> $1 LIMIT k to the index fast path only when the index opclass matches the operator, and falls back to brute force rather than answer wrongly.
CREATE FUNCTION … LANGUAGE wasm, running on Wasmtime with an epoch-interrupted CPU deadline and a memory cap per call.
i32/i64/f64 natively, plus text/bytea/timestamptz across a basin_alloc/basin_dealloc (ptr,len) ABI; JSONB rides the text path. Invocation is per row — a first-class jsonb argument type and vectorized calls are deferred.
A durable, commit-ordered ring on object storage: every committed mutation, hot-tier fast paths included, appended per project.
Resumable SSE cursor at /cdc/v1/sse/:project, a disk-backed webhook queue with exponential backoff, dead-letter file and SHA-256 idempotency keys, and a Kafka/Redpanda drain. Logical decoding is deliberately not the shape here.
cron.schedule(), cron.unschedule() and the cron.job / cron.job_run_details tables — pg_cron semantics, no extension.
net.http_get / net.http_post cover the pg_net shape behind a per-project URL allowlist that denies by default, a 10 req/s limit, a 10 MiB body cap and a 30 s timeout.
basin-geo ships the 2-D geometry codecs, measures and exact predicates — WKB, EWKB, WKT and GeoJSON in and out.
POINT is a native column type and the only R-tree-indexed one: ST_DWithin measures ~1.7× faster than PostGIS GIST and ~28× faster than an unindexed scan at 1M rows. && bbox counts and KNN still trail GIST badly, and constructive ops like ST_Union are not supported.
A Lakekeeper-compatible catalog over the same files: list namespaces and tables, load, create, commit and drop.
Commit maps Iceberg requirements onto the engine's own optimistic concurrency. Unmapped commit actions return a structured 501 rather than pretending; register-table and overwrite-style commits are v0.2.
Continuous aggregates and hypertables in the TimescaleDB shape: create_hypertable, time_bucket, first()/last(), retention policies.
Refresh is incremental for the date_trunc / time_bucket GROUP BY shape — only rows past the watermark plus the last partial bucket are re-aggregated. Bodies with no detectable bucket fall back to a full re-run.
similarity(), word_similarity() and the %, <%, <-> operators, with the same GUC thresholds pg_trgm uses.
A GIN trigram index prunes to candidate files using a conservative shared-trigram bound, then re-evaluates similarity() on the survivors. Correctness is pinned differentially against an unindexed twin.
Nothing above needs CREATE EXTENSION, a second container, or a managed
control plane. Fine-grained status for every row lives in
CAPABILITIES.md.
There is no gateway in the middle and no account to create. Each client speaks
pgwire for SQL and the engine's own HTTP surface for everything else, straight at
a basin-server you started. Point one at
http://localhost:5434 and it behaves exactly as it would against a
box in a rack.
Simple and extended query, TLS, COPY, prepared statements with
binary JSONB / UUID / BYTEA / ARRAY. Any Postgres driver works here, SDK or not.
/rest/v1 tables and RPC, /auth/v1,
/storage/v1, /realtime/v1 and
/cdc/v1. JSON by default; Arrow IPC streaming when you ask for it.
One process. Router, shard owner, WAL, auth, REST, realtime, blob, vector, cron and the WASM runtime are crates inside it, not services beside it.
Fig. 5 — no broker in the path. The TypeScript, Python, Go and Rust clients also carry realtime; all ten carry auth, query, REST, storage and functions.
Basin speaks pgwire and parses with libpg_query — the actual
PostgreSQL parser, vendored. Your ORM doesn't know the difference because,
at parse time, there isn't one.
Schema migrations, connection pools and per-row scoping collapse into the basin itself. The isolation isn't an extension — it's the storage shape.
EXPLAIN, same pg_stat_statementssqlx, flyway, prismaPostgres-compatible, not Postgres. 863 of 975 SQL fragments pass on the default configuration (88.5%). Every "no" below has a written rationale and the trigger that would change our mind.
1M rows on LocalFS, no index on either side, default configuration — no non-default flags. Postgres is the right answer for microsecond point mutations, and the table says so.
Fig. 4 — 17 shapes from the 2026-06-11 integrity run. Across the ~100 ms-shapes on this card Basin is faster on 51 and Postgres on 54.
Table view of Fig. 4 Basin (Vortex) vs Postgres 18 · 1M rows, LocalFS, single idle box — the headline shapes plus the ones that land at parity.
| Workload | Basin | Postgres 18 | Verdict |
|---|---|---|---|
| RAM per held-open connection | 310 KiB | 8,257 KiB | ~27× less |
| Connections under 1,000-conn flood | 1,000 held | 100 held / 900 refused | structural |
| LATERAL JOIN (correlated derived table) | 6.7 ms | 3,080 ms | 462× faster |
| Star join (events ⋈ users ⋈ categories) | 11.6 ms | 3,040 ms | 261× faster |
| Correlated subquery in SELECT p50 | 49 ms | 5,510 ms | 113× faster |
| Range scan p50 (~1k rows) | 0.40 ms | 32 ms | 81× faster |
| Bulk INSERT 1,000,000 rows | 2,080 ms | 8,100 ms | 3.9× faster |
| Point query p50 (unindexed PK) | 0.50 ms | 0.002 ms | slower |
| Single-row UPDATE p50 | 1.24 ms | 0.012 ms | slower |
| COUNT(*) full table p50 | 95 ms | 29 ms | slower |
| Deep top-K sort (ORDER BY … LIMIT 1000) | 161 ms | 53 ms | slower |
| On-disk bytes (1M rows, LocalFS) | 321 MB | 306 MB | ~5% larger |
Read the losses as the shape, not the footnote. Basin trades microsecond point mutations for columnar scans and bytes-at-rest. The on-disk row is an honest flip on this card — Basin is still 1.9× smaller at 100k rows and 102× smaller on real S3, where compression compounds against block storage. Published numbers use the default configuration; the HTAP fast paths are always on.
Every database here is good at something Basin isn't. The useful question is which shape your workload actually has.
The right answer for single-project, high-frequency OLTP and anything needing microsecond point-mutation latency at 1M+ rows. Basin isn't trying to be Postgres on those shapes. Basin wins on many-isolated-projects, append-shaped data, bulk ingest, columnar analytical scans, and the RAM-per-connection economics for connection-heavy front ends.
Serverless Postgres with branching — terrific for single-DB workloads that want copy-on-write forks. Basin matches the branching story (Iceberg forks are zero-copy too) but stores on plain S3 rather than a managed page server, so per-project cost tracks bytes rather than a provisioned pool.
Postgres + Auth + Edge Functions + Storage + Realtime. Basin covers the
SQL + Auth + REST surface in one binary, with auth.uid() /
auth.role() / auth.jwt() working identically. The
difference is the data layer: Vortex/Parquet on S3 instead of a Postgres heap
on block storage. Edge Functions, Realtime and Storage are out of scope.
Same problem space, built on real PostgreSQL with per-tenant virtual databases — which buys real PG semantics, real OLTP, real JSONB, real extensions and PL/pgSQL, exactly where Basin still trails. If your workload is point-mutation-heavy and JSONB-heavy with under 1k tenants, Nile is probably the easier answer today. Basin's structural answer is substrate economics: cold or low-traffic tenants stay near-zero because cost is O(bytes-on-S3) with shared compute.
The right answer for edge-distributed apps with many tiny SQLite-class databases. Basin is for centralized apps that want Postgres SQL on cheap object storage with a wire protocol ORMs already speak.
Point Basin at a data directory and run. Local development needs no external bucket — the same binary that runs on your laptop is the one that runs on S3.
No Rust toolchain. The image sets BASIN_BIND=0.0.0.0:5432, so pgwire
listens on :5432 inside the container and psql connects on
whatever you map it to.
Durable WAL and Vortex columnar files under your data dir; in-memory catalog for fast
iteration. Built from source the default bind is 127.0.0.1:5433 — not
5432; only the container image overrides it.
Basin is pre-alpha and publishes like it: the benchmark cards regenerate from integration tests on every push, wins and losses together, and the scope document says what is parked. If you think a card is unfair, the harness is in the repo — open a methodology issue and we will fix it, soften the claim, or show our working.
PRE-ALPHA public eval · v0.1
vector(N) + HNSW, pgvector-compatible operatorsINSERT … SELECT returns an honest typed reject.so extensionspostgres_fdw and dblink federationLicensed Apache-2.0. Written in Rust, self-hostable on any S3-compatible bucket.