PostgresOne shared table where every ALTER is coordinated, or copies you keep in sync.
SubstrateEach app adds its own tables around one stable ID. Nobody else’s rows change.
Changing the schema
PostgresALTER TABLE, backfills, and queries that suddenly return new columns.
SubstrateInstalls only ever add. Existing queries keep their exact shape.
Agents adding tables
PostgresA migration and a deploy, or an agent holding DROP rights.
SubstrateAgents propose tables in their own namespace. Once approved, they go live.
Access
PostgresRow-level security on shared tables.
SubstrateLinking to Alice and reading what others attached to her are separate grants. Revoking is instant, even mid-query.
Types
PostgresA separate codegen step.
SubstrateTypeScript and Rust clients generated from the schema. Plain SQL still works.
Where it fits.
One agent or fifty, one app or a whole ecosystem. All on one database.
01
One agent that keeps learning
In a normal database, every new thing an agent learns means a migration and a redeploy, or another JSON blob. Here the assistant adds a Preference, Contact or Routine table itself, mid-conversation, and can query it immediately.
Usually every agent gets its own database, or they all wait on one shared schema and its migrations. Here travel, finance and health each add their own tables about the same user, live. Travel reads the budget only if granted; health stays private.
Personone identity
←
travel.Tripfinance.Budgethealth.Medicationprivate
03
Apps with plugins
A plugin attaches reviews or deploy history to a project without adding a single column to Project. The core schema stays exactly as it was.
Projectone identity
←
review.Feedbackdeploy.Release
04
Apps built on the fly
An agent builds a habit tracker for one user, at runtime. The app defines its own tables when it installs, against the users you already have.
Personone identity
←
habits.Habithabits.Streak
05
Apps that share customers
Sales, support and billing each keep their own records about the same customer. Anyone allowed to see all three can join them in one query.
import { Person, TrainingGoal } from "./generated/schema.js";import { table, select, eq } from "./sdk/typescript/query.js";// Give an existing person new, typed memory.await client.insert(TrainingGoal, { person: alice.id, value: "Run my first 10K"});// Read it back together with their shared identity.const person = table(Person, "p");const goal = table(TrainingGoal, "g");const memory = await select({ name: person.c.name, goal: goal.c.value}) .from(person) .innerJoin(goal, eq(goal.c.person, person.c.id)) .where(eq(person.c.id, alice.id)) .execute(client);// [{ name: "Alice", goal: "Run my first 10K" }]
-- Combine identity, agent memory and activity.SELECT p.name, g.value AS goal, w.durationMinutesFROM core.Person pJOIN fitness.TrainingGoal g ON g.person = p.idJOIN fitness.Performed f ON f.athlete = p.idJOIN fitness.Workout w ON w.id = f.workoutWHERE p.id = ?;-- Bind Alice's existing UUID as the parameter.
use substrate_liquid::{Result, RetryPolicy, Session, TypedId};use generated::{core::Person, fitness::{Workout, Performed}};fn log_workout( fitness: &Session, alice: TypedId<Person>,) -> Result<Option<Workout>> { // The workout and its link commit together. let workout = fitness.transact(RetryPolicy::default(), |tx| { let workout = tx.insert_record(Workout { durationMinutes: 45, })?; tx.insert_record(Performed { athlete: alice, workout, })?; Ok(workout) })?; // TypedId<Workout> selects the generated decoder. fitness.snapshot().get_record(workout)}
Example resultAlice · Run my first 10K · 45
Example with a 45-minute workout. Requires read access to Person, TrainingGoal, Performed and Workout, and reference access when writing links.
Add a goal without changing Person, then join it back with typed fields and inferred results. Assumes the Schema tab is installed, an authorized client, and Alice’s existing identity with no goal yet.
Coach can add goals and workouts. CRM’s private deals stay out of reach.
Example grants. Read, write and link rights are granted separately.
Visibility, scopes, and revocation
New tables are readable by any signed-in client by default. Mark one private to limit it to its owner and grantees, or public for anyone. Visibility never grants writes. A scoped credential is a hard limit, and handing it on can only narrow it. Every read and write is checked before results leave or changes commit.
One million rows against SQLite and PostgreSQL, on the same CI runner. Including the queries where we lose.
SQL read benchmarks
26 Oct 2026 · 1 million records · Ubuntu 24.04 runner · µs/query ↓
Substrate SQL resident and SQL paged, SQLite and PostgreSQL: one million records across two namespaces, three rounds on the same GitHub Actions ubuntu-24.04 runner, all results verified. Microseconds per query, median of round means; lower is better.
Workload
Substrate SQL resident
Substrate SQL paged
SQLite embedded
PostgreSQL 18.3 · local socket*
Repeated point
0.73
0.34
1.27
8.88
Unique equality
0.69
0.51
1.04
8.86
Changing parameters, first pass
24.32
24.00
122.18
22.14
Same parameters, warm
1.91
2.02
3.28
13.16
Selective join
1.05
0.98
0.70
10.99
Full count/sum
1,775
117,016
7,225
29,686
Equality count/sum
22.35
1.20
0.25
11.84
Range aggregate, 1%
44.66
6,041
84.23
266.72
Broad aggregate, 90%
1,480
104,669
6,311
34,174
32 groups
4,811
121,391
366,894
45,976
Filtered groups, 50%
2,350
114,895
339,492
24,799
*Resident Substrate keeps current rows and indexes in memory; paged Substrate stores them on disk. PostgreSQL includes local IPC and decoding; the other modes are embedded.
Methodology & performance CI
500,000 Person records and 500,000 Score records across two namespaces. Three rounds per mode on the same GitHub Actions ubuntu-24.04 runner, with all results verified. Values are medians of round means, not request p50/p99. Every column measures SQL; native API timings are not shown. Grouping uses 32 buckets, the range aggregate selects 1% of scores, the broad aggregate selects 90%, and filtered grouping selects 50%.
Substrate is an early prototype. It runs on a single node and isn’t ready for production yet. The SDKs ship from the repository, not npm or crates.io. You’ll need Rust 1.94+ and Node 20+.