The question we get more than any other is some version of "how do I set up my database?" Where is the schema designer. Where do I define the tables. What about indexes, migrations, the query planner. The answer is that you do not. There is nothing to set up. You write a record, and the table exists. You delete the last record, and the table is gone. If that sounds like something that cannot possibly hold up under real load, this article is for you. It explains the model behind the Skapi database, schema-free and index-on-write: why it does the work you reach for a relational database to do, why it never falls into the full-table scan that quietly drains production budgets, why the same mechanism is what keeps your data secure, and why it is the right shape for a world where AI agents write most of the code.

The question, answered once
A Skapi project has no database to set up, because there is no moment at which structure is decided ahead of data.
Tables are not created. A table appears when the first record is written into it and disappears when the last one leaves. Indexes are not defined. An index is an argument you pass when you write a record. Access rules are not configured. They are an argument too. There are no migrations, because there is nothing to migrate: a record written today with a new field does not care what the record next to it looks like, and the database does not care either.
So when you ask "how should I design my schema", the honest answer is this. Write the records your product needs, and say, on each one, how you want to find it again. That is the whole discipline. Everything below is about why that is enough.
Where the confusion comes from
If you come from traditional backend engineering, you have been trained on two ways a database decides what data looks like.
The first is schema-on-write. You design the structure before any data exists: tables, columns, types, constraints, indexes. The database refuses anything that does not fit. This is the relational model, and it is very good at protecting the integrity of data. What it does not protect is the cost of reading it. Every query is handed to a planner at runtime, and the planner decides how to find the rows. If your condition happens to be covered by an index, it seeks. If it is not, it scans the whole table. Both are legal. Nothing warns you. The scan is the silent default for any question nobody anticipated when the schema was drawn.
The second is schema-on-read. You store whatever arrives and decide what it means when you query it. This is the data lake, and it is also what a document store becomes when its indexes are an afterthought. There is no structure to maintain, which is pleasant, and every read is a scan by definition, which is not.
Both models share one trait. The cost of a read is decided at read time, by machinery you do not see, over data you wrote earlier without saying how you would read it. The expensive decision is made at the moment you have the least information and the least control.
Index-on-write
Skapi moves that decision to the one moment you know the most about a record: when you write it.
When you post a record, you say a few things alongside the data. Which table it belongs to. Which access group may read it. Optionally, one index name and value to find it by. Optionally, tags. Optionally, the record it references. The database stores the record under keys built from those declarations, in sorted order, typed so that numbers sort as numbers and strings as strings.
await skapi.postRecord(
{ title: 'Dukkha', artist: 'Asian Spice House', tracks: 7 },
{
table: { name: 'Albums', access_group: 'public' },
index: { name: 'year', value: 2023 },
tags: ['Indie', 'Experimental']
}
);That is the schema. It is local to this record, it was decided by the code that had the record in hand, and it is already applied by the time the write returns.
A query is the mirror image: a table, an access group, and at most one index condition or range.
await skapi.getRecords({
table: { name: 'Albums', access_group: 'public' },
index: { name: 'year', value: 2010, condition: '>=' }
});Every such query maps to one contiguous range of sorted keys. The read is a seek to the start of the range and a walk to the end of it, a page at a time. Nothing outside the range is touched. There is no planner, because there is nothing to plan.
One record, written once. The table, access group and index you pass become a sorted key, and a query reads one range of those keys. Rows outside the range are never touched.
| Structure is decided | Access path is decided | What a read costs | |
|---|---|---|---|
| Schema-on-write (relational) | Before any data, for the whole table | At query time, by the planner | A seek if an index happens to cover it, a scan if not |
| Schema-on-read (lakes, loose document stores) | At query time | At query time | A scan |
| Index-on-write (Skapi) | Per record, when it is written | When it is written | One key range, proportional to the result |
The expensive query cannot be written
Here is the theory in one line. A scan costs in proportion to the size of the table. A seek costs in proportion to the size of the result. Everything that goes wrong with database bills lives in the gap between those two.
A scan reads every row, so its cost grows with the table. A seek reads the matching range, so its cost grows with the result. On Skapi, only the seek exists.
Every experienced engineer has seen the pattern. A query runs fine in development against ten thousand rows. It ships. The table grows to fifty million rows, and the same query is now five thousand times more expensive, because it was always a scan and nobody noticed while the table was small. On a database billed per read, that is not a slow page. That is an invoice. The fix is an index that someone had to think of in advance, which is exactly the thinking the schema was supposed to force, and did not.
On Skapi the scan is not slow. It does not exist. There is no way to ask the database for "every record whose data contains some value", because the data is not what the database reads. The keys are. If you want to find records by a value, you index on that value when you write. If you did not, the query is not expensive, it is not a query.
The consequence is that the cost of any read is proportional to what it returns, not to how much you have stored. A table of ten records and a table of ten million answer the same query at the same cost per record returned. There is no slow-query log to read, no planner to second-guess, and no index advisor telling you what you should have done last month. A junior developer and a senior one write the same query, because there is only one kind of query to write.
Everything you build a relational database for
The objection at this point is that a single index per record cannot possibly cover what a relational database does. It covers what you actually build. Every item below is a key read, and each one is a few lines of skapi-js.
Find by id. Every record has an id, and you can give it your own: an order number, a slug, a SKU. Both are direct lookups.
await skapi.postRecord(order, {
table: { name: 'Orders', access_group: 'authorized' },
unique_id: 'order-1042'
});
await skapi.getRecords({ unique_id: 'order-1042' });
await skapi.getRecords({ record_id: order.record_id });Newest first, oldest first. Every record is ordered by when it was uploaded and when it was last updated. Both are reserved index names, so "everything from the last seven days, newest first" is a range read.
const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
await skapi.getRecords(
{
table: { name: 'Orders', access_group: 'authorized' },
index: { name: '$uploaded', value: since, condition: '>=' }
},
{ ascending: false, limit: 50 }
);Conditions and ranges. Equal, greater, greater or equal, less, less or equal, and a range between two values. On a string, the same operators give you "starts with" and "ends with".
// albums released from 1960 to 1970
await skapi.getRecords({
table: { name: 'Albums', access_group: 'public' },
index: { name: 'year', value: 1960, range: 1970 }
});
// titles that end with "Gilberto" ('>=' on a string is "starts with")
await skapi.getRecords({
table: { name: 'Albums', access_group: 'public' },
index: { name: 'title', value: 'Gilberto', condition: '<=' }
});Relations. A record can reference another record. Comments reference a post, order items reference an order, a like references what was liked. Fetch everything that references a record, and the relationship is already there. A join is the engine discovering a relationship at query time. A reference is the relationship recorded at write time, which is the only moment anyone actually knew it.
const post = await skapi.postRecord({ text: 'What do you think?' }, {
table: { name: 'Posts', access_group: 'public' }
});
await skapi.postRecord({ text: 'I like it!' }, {
table: { name: 'Comments', access_group: 'public' },
reference: post.record_id
});
// every comment on that post
await skapi.getRecords({
table: { name: 'Comments', access_group: 'public' },
reference: post.record_id
});
A post is written once. Each comment and like points at it, and the post carries a count of how many records point at it. Fetching by reference reads those records and nothing else.
Counting relations. How many records reference a record is itself an index. "The most commented posts" is a range read, not a GROUP BY over a comments table.
// posts with more than ten comments, most commented first
await skapi.getRecords(
{
table: { name: 'Posts', access_group: 'public' },
index: { name: '$referenced_count', value: 10, condition: '>' }
},
{ ascending: false }
);Hierarchy. A compound index name such as Band.AsianSpiceHouse.year can be queried at any level: every band, bands by name, or one band's albums by year. Here is the write, and the index part of three queries on the same table.
await skapi.postRecord(album, {
table: { name: 'Albums', access_group: 'public' },
index: { name: 'Band.AsianSpiceHouse.year', value: 2023 }
});
// every album by a band
index: { name: 'Band.', value: '', condition: '>' }
// bands whose name starts with "Asian"
index: { name: 'Band.', value: 'Asian', condition: '>=' }
// one band's albums released after 2010
index: { name: 'Band.AsianSpiceHouse.year', value: 2010, condition: '>' }Tags. Labels that cut across indexes. A tag narrows any query, and every tag carries its own record count.
await skapi.postRecord(album, {
table: { name: 'Albums', access_group: 'public' },
index: { name: 'year', value: 2023 },
tags: ['Indie', 'Experimental']
});
// albums released after 2020 that are tagged Experimental
await skapi.getRecords({
table: { name: 'Albums', access_group: 'public' },
index: { name: 'year', value: 2020, condition: '>' },
tag: 'Experimental'
});Aggregates without scanning. Record counts per table, per tag and per index. Totals and averages of numeric indexes. The rate of true values for boolean indexes. All of it is kept up to date as records are written, so a rating board does not average votes at read time. The average is already there.
// each vote is a record with a boolean index
await skapi.postRecord(null, {
table: { name: 'VoteBoard', access_group: 'public' },
index: { name: 'Vote.Beer', value: true }
});
await skapi.getIndexes({ table: 'VoteBoard', index: 'Vote.Beer' });
// average_bool: the share of true votes, boolean_count: how many voted
await skapi.getTables({ table: 'Albums' });
// number_of_records and size, per table
await skapi.getTags({ table: 'Albums' });
// every tag in the table with its number_of_recordsPer user. Every user's records, per table, and subscriptions and feeds on top of that. A user follows another user, and reads one feed of everything the people they follow have posted to it.
// everything one user posted to a table
await skapi.getRecords({
table: { name: 'Posts', access_group: 'public' },
index: { name: '$user_id', value: user_id }
});
// follow a user, then read the feed of everyone you follow
await skapi.subscribe({ user_id, get_feed: true });
await skapi.getFeed({ access_group: 'authorized' });Notice what is not on the list: anything that would require reading the whole table to answer. That is not an omission. That is the point.
Permission is an address, not a filter
On most backends, access control is a filter. A policy is a predicate added to each query and evaluated per row by the same engine that may or may not have an index for it. It is a step in the read, and like any step it can be slow, and it can be skipped by any path that writes to the database without going through it.
On Skapi, the access group is part of the key a record is stored under. Public records, authorized records, numbered groups from 0 to 99, admin, private: each is a different address. A query names the group it wants to read. A user in access group 5 can ask for group 5 or any group below it, and the read lands on that group's keys alone. A user in group 3 who asks for group 5 is refused before a single key is read. The rows for group 50 are not filtered out of anyone's result. They are never read, because they do not live where the query is looking. Private records are reachable only by their uploader and by users the uploader explicitly grants, and that grant is an address too.
A query names the access group it wants. The read lands on that group's address and nowhere else. Higher groups and private records are not filtered out of the result. They are never read.
This is why security and cost come from the same mechanism. A permission check is not a stage that a badly written query can bypass or a busy engine can make expensive. It is where the data is.
Sealed in the browser
The model goes one step further for the data a user keeps to themselves. Turn on record encryption, and the data of a private record is encrypted in the browser before it leaves the device, with keys derived from the user's own password that never leave that device. A provider who dumps the database, the storage bucket and every request log cannot recover the contents without guessing the user's password.
Index-on-write is what makes this possible without breaking the database. The database reads structure: table, access group, index, tags, reference. It stores content. Because those are separate, the structure can stay readable, so every query above keeps working, while the content is sealed. The same is true of attached files: their bytes are encrypted, their names and sizes are not. The details, and the exact security claim, are in the encryption guide.
Why this is the database for AI agents
Agents write code fast and in volume, and they write it against a backend you did not fully review. A database has to be safe under that kind of writer.
Against a schema-on-write database, every change an agent makes is global. One more field means a migration, a policy per operation, regenerated types and a redeploy, and the loop starts again on the next request. We measured that loop in tokens. The deeper reason it costs so much is that a schema is shared state: an agent must hold the whole of it in context to change any of it safely.
Index-on-write has no shared state. Every decision is local to one write: this record, this table, this index, this access group. The agent decides at the point of maximum information and never has to reconcile the decision with anything else. When it needs to know the shape of the data, it asks the database, which lists tables, indexes and tags with their counts, maintained as the data was written. The schema is discovered from the data instead of being maintained beside it.
Three agents changing a schema-on-write backend all pass through one shared schema and the migrate, policies, types, deploy loop. On index-on-write, each agent writes its own record with its own rules, and nothing has to be reconciled.
And the cost guard holds for agents exactly as it holds for people. Agents will write queries you never read. On a relational database, one of them will be a scan. On Skapi it cannot be, because the only query that exists is a key read. The worst an agent can do is fetch a page it did not need.
For fifty years, database design has meant deciding structure before data and paying for every question nobody anticipated. Agents invert the workflow. Data first, structure as a property of each record, access as part of the address, and a database that stays cheap and safe no matter who, or what, is writing to it. That is index-on-write, and it is what the Skapi database has been from the beginning.
Start with a record
Create a Free project, write one record from the browser, and you have a database. The database guide shows every argument from the example above, and there is nothing to set up before you begin.