Tables & Database
Store and query structured data. Create tables with custom fields, then read and write data from your published app or backend functions.
Tables are like spreadsheets with typed columns. Define your schema in the dashboard, then use the SDK or backend functions to interact with your data. All operations respect access rules.
On this page
Creating Tables
Tables live inside a base, which is an account-level resource — not something you create from inside a single app.
- Open the Base Editor from your dashboard (or your app's Config → Base) and open — or create — a base
- Click New Table and give it a name (e.g., "Orders", "Products")
- Add fields (columns) with the appropriate type
- Make sure the base is linked to your app in Config → Base
- Set access rules for who can read, create, update, and delete rows
Field Types
| Type | Description | Example |
|---|---|---|
| Text | Short or long text | "Alice Smith" |
| Number | Integer or decimal | 42, 9.99 |
| Checkbox | True or false | true |
| Date | Date only, no time | 2026-04-17 |
| Datetime | Date and time | 2026-04-17T14:30:00 |
| Select | Dropdown with predefined options | "Active", "Pending" |
| URL | Web link — accepts bare domains too, validated on save | "example.com" |
| Email address, validated on save | "alice@example.com" | |
| Long text | Multi-line text | Notes, descriptions |
| Multiselect | Multiple choices from predefined options | ["vip", "pilot"] |
| Status | Color-coded workflow stages | "In progress" |
| Phone | Phone number, validated on save | "+254 700 000001" |
| Currency / Percent | Formatted numbers with symbol / % display | $450.50, 62.5% |
| Duration | Stored in minutes, shown as human time | 90 → "1h 30m" |
| Rating | 0–5 stars | ★★★★☆ |
| File / Image | Attachments from your Drive; images render thumbnails | photo.jpg |
| JSON | Validated structured data | {"tier": "pilot"} |
| Secret | Encrypted at rest, always masked (••••••••); reveal on demand in the grid | API keys, webhook URLs |
| Slug | URL-safe identifier, normalized as you type | "my-product-name" |
| Color | Hex color with a swatch | #22C55E |
| Button | Clickable action — trigger a flow or webhook per row | "Send email" |
| Hash | Auto-generated unique ID per row (read-only) | a3f9c2e1… |
| Created / Updated time | System timestamps (read-only) | Auto-filled |
| Formula | Calculated from other fields — see Formulas | {Price} * {Qty} |
| Link / Lookup / Rollup | Connect rows across tables and aggregate over them | SUM of linked Orders |
Lookups, Rollups & Conditional Aggregation
Link fields connect rows across tables. Lookup fields show values from the connected records, and Rollup fields calculate across them — always up to date, computed by the database itself. Full guide: Linked Records, Lookup & Rollup.
| Field | Aggregations |
|---|---|
| Rollup (13) | COUNT, COUNT_EMPTY, COUNT_FILLED, COUNT_UNIQUE, SUM, AVG, MIN, MAX, MEDIAN, CONCATENATE, ARRAY, ARRAY_UNIQUE, ARRAY_COMPACT |
| Lookup (9) | CONCATENATE (default), FIRST, LAST, ARRAY, COUNT, SUM, AVG, MIN, MAX |
Rollup COUNT counts linked records (including blanks); lookup COUNT and COUNT_FILLED count non-empty values. MEDIAN and COUNT_UNIQUE are real database aggregations, not client-side approximations.
Conditional aggregation — aggregate only the linked rows that match a filter. This is something Airtable cannot do natively: an Airtable rollup needs a helper formula column in the other table. Here the condition lives in the rollup itself:
"Sum of Daily Send Limit across linked Accounts — but only Accounts where Purpose is Outreach"
// rollupConfig with a filter over LINKED-table fields —
// only matching linked rows are aggregated
{
relationshipFieldId: "<link field id>",
sourceFieldId: "<Daily Send Limit>",
aggregation: "SUM",
filter: {
logic: "and",
conditions: [{ fieldId: "<Purpose>", operator: "equals", value: "Outreach" }]
}
}Conditions use the same operators as view filters and combine with and/or logic. Lookups accept the same filter too — e.g. "emails of linked contacts where Role is Billing".
Linking to people: when authentication is enabled, your app's users appear as read-only rows in the system Users table — so "Deals → Owner", "Orders → Customer" or "Courses → Instructor" are ordinary link fields, with lookups of the person's email and rollups like "deals per rep" working normally. Users themselves are managed in Site → Config → Users; the rows update automatically.
Chains: a rollup's source can itself be a lookup, rollup, or formula on the linked table (up to 3 computed hops; circular chains are rejected when you save the field).
Read-only: lookup, rollup, and formula values are computed — writes that include them don't fail, the computed keys are simply skipped and reported back under ignoredComputedFields.
Queryable: scalar aggregations (the COUNT family, SUM, AVG, MIN, MAX, MEDIAN, CONCATENATE, FIRST, LAST) over a direct source can be filtered and sorted server-side, and appear as real columns in SQL. List aggregations (ARRAY family) and chained sources are display-only — filtering them raises a clear error instead of quietly returning nothing.
Field Behaviors — Defaults, Unique & Links
| Behavior | What it does |
|---|---|
| Default value | Fills in automatically on every new row that doesn't provide the field — created in the grid, via the API, or by import. |
| Unique | No two rows can hold the same value. Enforced everywhere — single edits, bulk updates, and imports (an import with duplicates is rejected whole, nothing partial). |
| Link cardinality | One-to-one links accept exactly one connected record; attempts to link more are rejected with a clear error. |
| On-delete rules | RESTRICT blocks deleting a record while others still link to it. CASCADE deletes the linking records along with it (bounded: a cascade that would remove more than 500 rows is rejected — delete in smaller batches). SET_NULL (default) just removes the connection. NO_ACTION deletes the record and leaves the linking rows untouched. |
| Deleting fields | A field that formulas, lookups, or rollups are built on can't be deleted until those are removed — the error names exactly which fields depend on it. |
| Renaming fields | Formulas that reference the field update automatically — {Old Name} becomes {New Name}, values keep working. |
Formulas
Formula fields calculate a value per row from other fields in the same table. Reference fields by name in curly braces:
{Price} * {Quantity}
IF({Status} = 'active', 'Live', 'Paused')
{First Name} & ' ' & {Last Name}
ROUND(DATETIME_DIFF({Due Date}, TODAY(), 'days'), 0)Over 45 functions are available:
| Category | Functions |
|---|---|
| Logic | IF, SWITCH, AND, OR, NOT, BLANK, ISBLANK |
| Row | RECORD_ID() — the row's permanent id, e.g. 'REF-' & RECORD_ID() |
| Math | ROUND, ABS, CEILING, FLOOR, INT, MOD, POWER, SQRT, SUM, AVERAGE, MIN, MAX, VALUE |
| Text | UPPER, LOWER, LEN, TRIM, CONCAT, LEFT, RIGHT, MID, FIND, SUBSTITUTE, REPT |
| Regex | REGEX_MATCH, REGEX_EXTRACT, REGEX_REPLACE |
| Dates | TODAY, NOW, YEAR, MONTH, DAY, HOUR, WEEKDAY, DATETIME_DIFF, DATEADD, IS_AFTER, IS_BEFORE, DATETIME_FORMAT |
| Coming from Airtable / Excel? | Use instead |
|---|---|
| LENGTH() | LEN() |
| SEARCH() | FIND() |
| DATEDIFF() | DATETIME_DIFF() |
| ISEMPTY() | ISBLANK() |
| COALESCE() / IFEMPTY() | IF(ISBLANK(…), …) |
| AVG() | AVERAGE() |
| TRUNC() | INT() or FLOOR() |
| RECORD_ID() | Supported natively — RECORD_ID() works in formulas here too. (Code usually doesn't need it: every read already returns the row's id, and SQL exposes it as the id column) |
| CREATED_TIME() | Add a created_time field |
Formulas are checked when you save them — a reference to a field that doesn't exist, a function name typo (with a "did you mean" hint), a self-reference, or a circular chain between formulas is rejected immediately instead of showing an error in every row later.
Formulas can reference lookups, rollups, and other formulas. They recalculate automatically whenever their inputs change.
Semantics worth knowing: function names are case-insensitive (if() works; docs show uppercase by convention). Null propagates sanely — null arithmetic and divide-by-zero return null, never a crash. resultType is a display hint, not a validation contract — a "number" formula can still return text. Math is standard IEEE-754 floating point (0.1 + 0.2 shows the classic 0.30000000000000004 — wrap money in ROUND(…, 2)).
SQL Queries
Ask the AI to query your base with real SQL — joins, GROUP BY, aggregates — or use it yourself through the API. Tables and columns go by their names:
SELECT c."Business Name", SUM(o."Amount") AS total
FROM "Customers" c
JOIN "Orders" o ON o.id = ANY(...)
GROUP BY c."Business Name"
ORDER BY total DESCWhere you can use it
- Asking the AI in the builder — it runs
tables_queryagainst your base - Backend functions —
ctx.tables.query(sql, params), server-side code you write - Not from published-app pages — deliberately. SQL runs with owner-level access, so it is never exposed to site visitors; page code uses the SDK (
entities.*), which goes through the access-rule engine. To give visitors a SQL-powered report, wrap the query in a backend function and put access rules on the function
Scope & security
- Scoped to ONE base you own — ownership is checked before anything runs, and the query can only see that base's tables. It does not and cannot expose the platform database, other users' data, or even your other bases
- Table names in your SQL are rewritten to internal per-base views before execution — physical tables, system catalogs, and schema-qualified names are unreachable by construction, not by filtering
- Read-only
SELECTonly: one statement, writes rejected, the transaction itself is READ ONLY as a backstop - Dangerous/system functions are deny-listed; secret and auth-system fields never become columns at all
- 4-second timeout per query
Columns
- Every table also exposes
id,createdAtandupdatedAt - Lookup and rollup columns are queryable — computed live by the database
- Formula columns are queryable too — values are stored on the row and recalculated automatically when inputs change. The one exception: formulas using
NOW()/TODAY()read as empty (a stored "now" would always be wrong) and are listed underunavailableColumns. - Results are capped at 2,000 rows per query
SDK Operations
Use the SDK in your published app pages to interact with tables:
// Import from the SDK
import { entities } from '../api/sdk';
// List rows (sort, limit, offset)
const rows = await entities.Products.list('-createdAt', 20);
// Get a single row by ID
const product = await entities.Products.get(rowId);
// Create a row — returns { success: true, id }, NOT the full row
const { id } = await entities.Products.create({
Name: 'Widget', Price: 9.99, InStock: true
});
// Update a row
await entities.Products.update(rowId, { Price: 12.99 });
// Delete a row
await entities.Products.delete(rowId);
// Filter rows
const results = await entities.Products.filter({
Name: { $contains: 'widget' }
});Secrets: secret-field values written from a published app are encrypted before storage, and reads always return the mask (••••••••) — the stored value never reaches the browser.
Computed fields: lookup, rollup and formula values arrive in every read like normal fields, and filter() can filter BY them too — scalar lookups/rollups and formulas alike. The exceptions raise a clear error: list-style aggregations (ARRAY), chained aggregations, and formulas using NOW()/TODAY() — filter on their input fields instead.
Note: create() only returns { success, id } — if you need the full row back, follow up with get(id).
Filter semantics: $contains is case-insensitive and matches literally (typing % or _ searches for those characters); $eq is exact and case-sensitive with no type coercion (the string "50" won't match the number 50 — mind HTML form values); { field: null } means "is empty"; $in: [] matches nothing. Multiselect fields filter by membership, time fields compare as dates, and link fields filter by linked row id. Bad operators, unknown fields, sort keys and value types all return a clear 400 instead of wrong rows.
Limits: tables cap at 100 fields (columns) — past that, split into related tables joined by link fields.
Table names are matched case-insensitively, and the row's creator is stamped automatically from the signed-in member — you don't pass it yourself.
For the full list of methods including bulk operations and upsert, see ctx.tables reference. Note: ctx.tables in backend functions is creator-scoped only, not the full access-rule engine — has_role/has_entitlement/field-level rules are not applied there the way they are for these page-level SDK calls.
Filtering & Sorting
The same filter language works everywhere — entities.<Table>.filter() in pages, ctx.tables.filterRows() in backend functions, and the AI's table tools. Top-level conditions AND together; use $or for alternatives:
const rows = await entities.Orders.filter({
Status: 'active', // plain value = equals
Total: { $between: [100, 500] }, // operators compose
$or: [ // groups nest arbitrarily
{ Priority: { $gte: 8 } },
{ Tags: { $contains: 'urgent' } },
],
}, {
limit: 50,
offset: 0,
sort: '-Total', // any FIELD NAME or rowOrder/createdAt/updatedAt; "-" = descending
});| Operator | Meaning |
|---|---|
| $eq | Equals — exact, case-sensitive, no type coercion ("50" ≠ 50) |
| $ne | Not equals — includes rows where the field is empty |
| $gt / $gte / $lt / $lte | Ordered comparison (numbers numerically; text lexicographically — ISO dates compare correctly) |
| $between | Inclusive range: { $between: [10, 100] } |
| $contains | Text contains — case-insensitive, matches literally (%, _ are not wildcards). On multiselect: tag membership |
| $startsWith / $endsWith | Prefix / suffix match, case-insensitive, literal |
| $regex | Postgres regular expression, case-sensitive — prefix (?i) for case-insensitive. Invalid patterns return a clear 400 |
| $in | Any of the listed values. An empty list matches nothing |
| $nin | None of the listed values — blank rows count as "not in the list" |
| $not | Exact complement of any inner condition: { $not: { $contains: "spam" } } — blank rows count as not-matching |
| $isEmpty / $isNotEmpty | Empty means missing, null, "" or an empty list. { field: null } is shorthand for is-empty |
| $or / $and | Group combinators — each takes an array of filter objects and nests arbitrarily |
| $eqField, $neField, $gtField, $gteField, $ltField, $lteField | Compare one field against ANOTHER FIELD of the same row: { Spent: { $gtField: "Budget" } } finds over-budget rows |
| Field type | How it filters |
|---|---|
| Multiselect | By membership — $contains one tag, $in = has any of, plain scalar = has that tag; $isEmpty sees empty lists |
| Created / Updated time | As dates against the real timestamps — ISO strings or epoch ms; use ranges ({ $gte: "2026-07-01", $lt: "2026-08-01" }) |
| Link | By linked ROW ID, resolved through the relation table (always in sync): $contains one id, $in any of, $isEmpty/$isNotEmpty. Top level of the filter only |
| Lookup / Rollup | Scalar aggregations filter server-side (computed in SQL); list aggregations and chained sources raise a clear error |
| Formula | Self-contained formulas (only same-row stored fields) filter and sort like data. Formulas using NOW()/TODAY() or referencing lookups/rollups/links raise a clear error — filter their inputs instead |
Sorting
- Sort by
rowOrder(manual grid order),createdAt,updatedAt— or any field name:sort: '-Total' - Numbers sort numerically, text alphabetically (case-insensitive), checkboxes false→true; blank cells always sort last
- Scalar lookup/rollup values and self-contained formulas sort server-side too
- Not sortable: link fields (sort by a rollup over them instead), NOW()/TODAY() formulas, list aggregations
- An unknown sort key returns a clear 400 — it is never silently ignored
There's no separate order option — direction is encoded in the sort string itself.
Import & Export
- Import matches columns by field NAME, applies default values, validates types/enums per row, and enforces unique fields atomically — an import containing duplicates is rejected whole, never partially applied
- Values for computed fields (formula, lookup, rollup) in an import are skipped and reported — they are recalculated, not stored
- Export produces CSV/JSON with linked records resolved from the relation table (the source of truth), not stale cell arrays
Limits
| Limit | Value |
|---|---|
| Fields (columns) per table | 100 — past that, split into related tables joined by link fields |
| Rows per list/filter call | 1,000 per page — paginate with limit + offset |
| SQL query results | 2,000 rows, read-only, 4s timeout |
| Cascade delete | 500 rows per delete — larger cascades are rejected |
| Computed/link filter breadth | 10,000 matching rows when access rules also apply — narrow the filter beyond that |
| Bulk update | 100 rows per call |
Every limit fails loudly with a clear error — no operation silently truncates or partially applies.
Views
Create saved views of your table with pre-configured filters, sorting, and visible columns. Views help you organize your data without changing the underlying table.
Examples:
- "Active Orders" — filtered to Status = active, sorted by date
- "High Value" — filtered to Total > $1000
- "My Orders" — filtered to creator = current user