Survey — object-storage stratum

A database that lives on the bucket.

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.

RAM per connection
310 KiB
vs Postgres 18
~27× less
LATERAL JOIN
462× faster
SQL fragments pass
863/975
psql — basin
$ psql 'postgres://alice@localhost:5433/alice' psql (18.0, server basin/0.1.9) Type "help" for help. alice=> CREATE TABLE events ( id bigserial PRIMARY KEY, kind text NOT NULL, at timestamptz DEFAULT now() ); CREATE TABLE alice=> SELECT kind, count(*) FROM events GROUP BY kind ORDER BY 2 DESC; kind | count -----------+------- pageview | 41822 signup | 1190 (2 rows) -- scanned from Vortex on S3

ordinary pgwire — no driver changes

Ships today
  • pgwire v3
  • Row-Level Security
  • Vector search (HNSW + IVF)
  • Iceberg REST catalog
  • REST + Auth
  • Realtime SSE + WS
  • Blob storage
  • Wasm functions
  • CDC ring + webhooks
  • Cron + HTTP from SQL
  • HTAP hot tier
Architecture · the shape

Isolation is the storage layout, not a policy.

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

L1

Routers stateless

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.

basin-router
L2

Shard owners stateful

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.

basin-shard
L3

Regional WAL the durability boundary

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.

basin-wal
L4

Object storage + Iceberg catalog

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.

basin-storage

Fig. 1 — write path, top to bottom. Reads short-circuit at whichever layer already holds the answer.

A project prefix, in cross-section The Basin bowl cut open: four strata inside it, numbered one to four from the top down and matching the numbered key beside it — columnar data files at the top, then Iceberg metadata, then archived WAL segments, then snapshot and branch references at the floor.
  1. tables/{name}/data/…Vortex columnar files, date-partitioned. Parquet is opt-in per table.
  2. tables/{name}/metadata/Iceberg metadata and manifests, served over a Lakekeeper-compatible REST catalog.
  3. wal/{partition}/Archived WAL segments, flushed from the Raft group in ~200 ms batches.
  4. snapshots/ · branches/Point-in-time references and copy-on-write branches — metadata only, no data copy.
# the prefix is the unit of IAM, billing and branching /projects/alice/tables/events/data/2026/06/11/01J…parquet /projects/bob/ tables/events/data/2026/06/11/01J…parquet # basin-storage refuses any key outside the caller's prefix # at the API boundary — not at the call sites. alice reads /projects/bob/… → refused

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.

01 / prefix

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.

02 / columnar

Vortex on object storage

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.

03 / one binary

One process, whole surface

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.

Structural · the two that don't move

Some numbers are tuning. These two are arithmetic.

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.

Basin 310 KiB / conn

1,000 held 0 refused

Postgres 18 8,257 KiB / conn

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.

RAM per held-open connection
310 KiB
Against Postgres 18's 8,257 KiB on the same card — about 27× cheaper per socket.
RAM per idle project
2.16 KiB
Measured across 1,000 idle projects. A tenant nobody is using books no connection slot and no heap pages.
Bucket cost per idle project
$0.10 /mo
At typical SaaS-tail sizes. Cost tracks bytes actually stored, so ten thousand quiet projects stay quiet on the invoice.
Batteries · one binary

The rest of the stack is already in the process.

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.

F-01 shipped

Auth

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.

F-02 shipped

REST API

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.

F-03 harness-gated

Realtime

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.

F-04 v1 shipped

Blob storage

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.

F-05 shipped

Vector search

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.

F-06 shipped

WASM functions

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.

F-07 shipped

Change data capture

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.

F-08 shipped

Cron & HTTP from SQL

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.

F-09 subset

Geospatial

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.

F-10 shipped

Iceberg REST catalog

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.

F-11 shipped

Time series

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.

F-12 shipped

Text search

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.

Clients · ten languages, no broker

Every SDK talks to the engine itself.

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.

clients
  • TypeScript@bas-in/basin-js
  • Pythonbasin-sdk
  • Gosdks/go
  • Rustbasin-sdk
  • JavaBasin
  • Rubybasin-sdk
  • C#.NET
  • PHPbasin/basin-php
  • Dartbasin_sdk
  • SwiftBasin
protocols

pgwire v3:5433

Simple and extended query, TLS, COPY, prepared statements with binary JSONB / UUID / BYTEA / ARRAY. Any Postgres driver works here, SDK or not.

HTTP:5434

/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.

engine

basin-server

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.

app.ts TypeScript
import { createClient } from "@bas-in/basin-js"; const basin = createClient( "http://localhost:5434", apiKey); const { data } = await basin .from("events").select("*") .eq("kind", "signup").limit(50); // streams NDJSON, never buffers for await (const row of basin .from("events").select().stream()) …
app.py Python
from basin import create_client client = create_client( "http://localhost:5434", api_key) res = await client.from_("events") \ .select("*").eq("kind", "signup") \ .limit(50) # cursor pagination, transparent async for row in client.from_("events") \ .select().paginate(): …
main.go Go
import basin "github.com/vul-os/basin/sdks/go" client := basin.New( "http://localhost:5434", apiKey, basin.WithProjectID(projectID), ) sess, err := client.Auth.SignIn(ctx, "alice@example.com", password, "") // Arrow IPC via arrow-go/v18
Code · psql session

The same SQL your app already speaks.

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.

What you get for free

Schema migrations, connection pools and per-row scoping collapse into the basin itself. The isolation isn't an extension — it's the storage shape.

  • Project bound at connection accept — no per-query auth
  • No row-level security policy boilerplate
  • Same EXPLAIN, same pg_stat_statements
  • Migrations: sqlx, flyway, prisma
project_isolation.sql psql
-- Two projects, one engine, one bucket. -- The prefix is the boundary. alice=> SELECT count(*) FROM events; count ------- 43012 bob=> SELECT count(*) FROM events; count ------- 0 -- Same table name. Different prefix. -- No policy was written to make this true. bob=> ALTER TABLE events ENABLE ROW LEVEL SECURITY; ALTER TABLE
pgwire v3 · server basin/0.1.9
What we do · what we don't

Postgres-compatible, with edges.

Postgres-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.

supported your driver, unchanged
  • pgwire v3 — simple + extended query, TLS, COPY, prepared statements, binary params
  • SQL surface — CREATE/INSERT/UPDATE/DELETE, joins, ORDER BY, LIMIT, ALTER TABLE, GENERATED columns, CHECK / PRIMARY KEY / FOREIGN KEY (single-shard)
  • Row-Level Security — ENABLE ROW LEVEL SECURITY + CREATE POLICY, plan-layer enforcement
  • Native vector search — vector(N) + HNSW, pgvector-compatible operators
  • Extension equivalents — pgcrypto, uuid-ossp, pg_trgm, basin-cron, basin-net, basin-geo (PostGIS subset), basin-cv (continuous aggregates)
  • LANGUAGE sql functions + CALL procedures, planning-time inlined
not supported by design
  • Replication protocol — wrong shape for object-store storage; logical decoding / CDC out of scope.
  • PL/pgSQL, PL/Python, PL/Perl — no alt-language stored procedures. Use LANGUAGE sql + change-event reactors instead.
  • Loadable .so extensions — no upstream extension binaries. The common ones ship as Basin-flavored crates with the same SQL semantics.
  • postgres_fdw / dblink — no foreign-PG query federation. Use basin-net for HTTP-shaped cross-system reads.
  • Types — INTERVAL, MONEY, XML, full geometric (LINESTRING / POLYGON) on the wire are not shipped; basin-geo covers the Point + box subset.
  • Cross-region 2PC — Spanner-class distributed transactions are out of scope.
Benchmarks · wins and losses

Measured, including where we lose.

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.

Ratio against Postgres 18 — 1M rows, LocalFS, log axis

LATERAL JOIN (correlated derived table)
462×
Star join (events ⋈ users ⋈ categories)
261×
Correlated subquery in SELECT p50
113×
Range scan p50 (~1k rows)
81×
WHERE col = ANY(int[])
63×
Selective low-card COUNT
51×
COUNT(DISTINCT user_id) per status p50
30×
2-table JOIN GROUP BY p50
28×
Large result stream (100k-row drain)
25×
Bulk INSERT 1,000,000 rows
3.9×
Point query p50 (unindexed PK)
250×
Single-row UPDATE p50
103×
Mixed read-write 8R+4W (600 ops)
17×
Concurrent SELECT (16 sessions)
8.3×
COUNT(*) full table p50
3.3×
Deep top-K sort (ORDER BY … LIMIT 1000)
Bulk UPDATE (~1/3 of rows)
2.8×

Basin faster Postgres faster Bars run out from parity; the axis is log-scaled, so each label is 4× the last.

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.

WorkloadBasinPostgres 18Verdict
RAM per held-open connection310 KiB8,257 KiB~27× less
Connections under 1,000-conn flood1,000 held100 held / 900 refusedstructural
LATERAL JOIN (correlated derived table)6.7 ms3,080 ms462× faster
Star join (events ⋈ users ⋈ categories)11.6 ms3,040 ms261× faster
Correlated subquery in SELECT p5049 ms5,510 ms113× faster
Range scan p50 (~1k rows)0.40 ms32 ms81× faster
Bulk INSERT 1,000,000 rows2,080 ms8,100 ms3.9× faster
Point query p50 (unindexed PK)0.50 ms0.002 msslower
Single-row UPDATE p501.24 ms0.012 msslower
COUNT(*) full table p5095 ms29 msslower
Deep top-K sort (ORDER BY … LIMIT 1000)161 ms53 msslower
On-disk bytes (1M rows, LocalFS)321 MB306 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.

Compare · where each one wins

When Basin is the wrong answer.

Every database here is good at something Basin isn't. The useful question is which shape your workload actually has.

PostgresAurora · RDS

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.

Neonserverless PG

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.

SupabaseBaaS in a box

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.

Nilemulti-tenant PG

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.

TursolibSQL · edge

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.

Quickstart · self-host

One binary. No object store required to start.

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.

Docker — under five minutes

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.

# pgwire on :5432 in the image docker run --rm \ -p 5432:5432 \ -v basin-data:/var/basin \ --name basin \ basin-server

From source

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.

# pgwire on 127.0.0.1:5433 BASIN_DATA_DIR=/tmp/basin \ cargo run -p basin-server # then, from anywhere: psql postgres://localhost:5433
Field status · v0.1 cut

Every number here has a test behind 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.

Shipped and on by default

  • pgwire v3 — TLS, COPY, extended query, binary JSONB / UUID / BYTEA
  • Row-Level Security, enforced in the plan layer
  • Native vector(N) + HNSW, pgvector-compatible operators
  • Auth, REST and blob storage in the same binary
  • Iceberg REST catalog, snapshots and copy-on-write branches
  • HTAP hot-tier fast paths, with a no-redeploy kill switch

Measured, still behind

  • Point mutations at 1M rows — 1.24 ms against Postgres's 0.012 ms
  • Mixed read-write concurrency, 8R+4W — 202 ms against 12 ms
  • INSERT … SELECT returns an honest typed reject
  • Bulk UPDATE over a third of a table — 2.8× slower
  • Realtime ships complete; some harness slices are still gated

Out of scope, deliberately

  • PL/pgSQL, PL/Python and PL/Perl stored procedures
  • Loadable .so extensions
  • Logical decoding, replication protocol, CDC
  • postgres_fdw and dblink federation
  • Cross-region two-phase commit

Licensed Apache-2.0. Written in Rust, self-hostable on any S3-compatible bucket.